From 3c87f071e87fb3a84221079296a305709f3cdcdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 11:01:57 -0400 Subject: [PATCH 1/7] fix(search): Pass regex log queries through as written Recognize `key://pattern//` regex filters as Sentry search syntax so regex-only log queries are no longer treated as natural language and replaced by Seer or embedded-agent rewrites. Treat a regex rewritten into a plain or wildcard filter as a downgrade, keep regex values with spaces or apostrophes as one token, and mention regex syntax in search_logs. Refs LOGS-1015 Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/skillDefinitions.json | 6 +- packages/mcp-core/src/toolDefinitions.json | 2 +- .../search-events-environment-note.test.ts | 12 +++ .../src/tools/catalog/search-events.test.ts | 95 +++++++++++++++++++ .../mcp-core/src/tools/catalog/search-logs.ts | 1 + .../src/tools/support/search-events/search.ts | 11 ++- .../tools/support/search-events/utils.test.ts | 47 +++++++++ .../src/tools/support/search-events/utils.ts | 89 +++++++++++++++-- 8 files changed, 249 insertions(+), 14 deletions(-) diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 39b74539a..f939a58d9 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -214,7 +214,7 @@ }, { "name": "search_logs", - "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n- Sentry search syntax for logs includes RE2 regex filters, never quoted: `message://^Timeout after \\d+ms//`.\n", "requiredScopes": ["event:read"] }, { @@ -309,7 +309,7 @@ }, { "name": "search_logs", - "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n- Sentry search syntax for logs includes RE2 regex filters, never quoted: `message://^Timeout after \\d+ms//`.\n", "requiredScopes": ["event:read"] }, { @@ -475,7 +475,7 @@ }, { "name": "search_logs", - "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n- Sentry search syntax for logs includes RE2 regex filters, never quoted: `message://^Timeout after \\d+ms//`.\n", "requiredScopes": ["event:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 63709f5c6..e11d1815f 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -7764,7 +7764,7 @@ }, { "name": "search_logs", - "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n- Sentry search syntax for logs includes RE2 regex filters, never quoted: `message://^Timeout after \\d+ms//`.\n", "inputSchema": { "type": "object", "properties": { diff --git a/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts b/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts index b8b4a05a1..b3bd58d0a 100644 --- a/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts @@ -59,6 +59,18 @@ describe("collectRequestedEnvironments", () => { ), ).toEqual(["qa"]); }); + + it("keeps regex values with spaces and apostrophes as one token", () => { + expect( + collectRequestedEnvironments( + null, + "message://can't connect// environment:qa", + ), + ).toEqual(["qa"]); + expect( + collectRequestedEnvironments(null, "message://a environment:foo b//"), + ).toEqual([]); + }); }); describe("formatUnknownEnvironmentNote", () => { diff --git a/packages/mcp-core/src/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts index 683ba068b..b32a9739c 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3073,6 +3073,97 @@ describe("search_events", () => { expect(result).toContain("TimeoutError"); }); + describe("with regex log queries", () => { + const regexSearchParams = { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "logs" as const, + fields: null, + sort: null, + period: "24h", + limit: 10, + includeExplanation: false, + }; + const regexSearchContext = { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }; + + const captureEventsQueries = () => { + const queries: Array = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + queries.push(new URL(request.url).searchParams.get("query")); + return HttpResponse.json({ data: [] }); + }, + ), + ); + return queries; + }; + + it("runs a regex-only query as written without the agent when fields and sort are explicit", async () => { + const queries = captureEventsQueries(); + + await searchEvents.handler( + { + ...regexSearchParams, + query: "message://^Timeout after \\d+ms//", + fields: ["timestamp", "message"], + sort: "-timestamp", + }, + regexSearchContext, + ); + + expect(mockGenerateText).not.toHaveBeenCalled(); + expect(queries).toEqual(["message://^Timeout after \\d+ms//"]); + }); + + it("keeps the regex filter when the agent rewrites it into a wildcard", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("logs", 'message:"*Timeout after*"'), + ); + const queries = captureEventsQueries(); + + await searchEvents.handler( + { ...regexSearchParams, query: "message://^Timeout after \\d+ms//" }, + regexSearchContext, + ); + + expect(mockGenerateText).toHaveBeenCalledTimes(1); + expect(queries).toEqual(["message://^Timeout after \\d+ms//"]); + }); + + it("accepts agent repairs that keep a regex value with spaces and apostrophes", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse( + "logs", + "severity:error message://can't connect to \\w+// has:trace", + ), + ); + const queries = captureEventsQueries(); + + await searchEvents.handler( + { + ...regexSearchParams, + query: "message://can't connect to \\w+// severity:error", + }, + regexSearchContext, + ); + + expect(queries).toEqual([ + "severity:error message://can't connect to \\w+// has:trace", + ]); + }); + }); + it("keeps caller fields when the agent returns an empty fields array", async () => { let eventsRequestUrl: URL | undefined; @@ -3955,6 +4046,10 @@ describe("search_events", () => { it.each([ ["a structured query", { query: "span.op:http.client" }], + [ + "a regex-only logs query", + { dataset: "logs" as const, query: "message://^Timeout//" }, + ], ["explicit fields", { fields: ["span.description", "count()"] }], ["an explicit sort", { sort: "-count()" }], ])("should skip Seer for %s", async (_, overrides) => { diff --git a/packages/mcp-core/src/tools/catalog/search-logs.ts b/packages/mcp-core/src/tools/catalog/search-logs.ts index 1a60689ed..bd98ca29d 100644 --- a/packages/mcp-core/src/tools/catalog/search-logs.ts +++ b/packages/mcp-core/src/tools/catalog/search-logs.ts @@ -27,6 +27,7 @@ export default defineTool({ "", "- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.", "- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.", + "- Sentry search syntax for logs includes RE2 regex filters, never quoted: `message://^Timeout after \\d+ms//`.", "", ].join("\n"), inputSchema: buildDatasetSearchInputSchema(), diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts index 9925d3cf9..1fd11e6d8 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -52,6 +52,7 @@ import { isAggregateQuery, isSemanticFilterDowngrade, looksLikeSentrySearchSyntax, + readRegexFilterValue, recordEventsSearchValidationTelemetry, validateEventsSearch, } from "./utils"; @@ -262,7 +263,8 @@ function tokenizeSearchQuery(query: string): string[] { let quote: '"' | "'" | null = null; let escaped = false; - for (const char of query) { + for (let i = 0; i < query.length; i += 1) { + const char = query[i]; if (escaped) { currentToken += char; escaped = false; @@ -283,6 +285,13 @@ function tokenizeSearchQuery(query: string): string[] { continue; } + const regexValue = readRegexFilterValue(query, i); + if (regexValue) { + currentToken += regexValue; + i += regexValue.length - 1; + continue; + } + if (char === '"' || char === "'") { currentToken += char; quote = char; diff --git a/packages/mcp-core/src/tools/support/search-events/utils.test.ts b/packages/mcp-core/src/tools/support/search-events/utils.test.ts index 4dbb0c158..9f9e83e7a 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.test.ts @@ -297,6 +297,53 @@ describe("search query helpers", () => { expect(looksLikeSentrySearchSyntax("ERROR: service is down")).toBe(false); }); + it("should detect regex filters", () => { + expect(looksLikeSentrySearchSyntax("message://^Timeout//")).toBe(true); + expect( + looksLikeSentrySearchSyntax("message://^Timeout after \\d+ms//"), + ).toBe(true); + expect(looksLikeSentrySearchSyntax("!message://(?i)healthcheck//")).toBe( + true, + ); + expect(looksLikeSentrySearchSyntax("open http://example.com/a//b")).toBe( + false, + ); + }); + + it("detects regex filters rewritten into plain or wildcard filters", () => { + expect( + isSemanticFilterDowngrade("message://^Timeout//", 'message:"*Timeout*"'), + ).toBe(true); + expect( + isSemanticFilterDowngrade( + "message://can't connect to \\w+//", + "message:*connect*", + ), + ).toBe(true); + expect( + isSemanticFilterDowngrade( + "custom://^order-\\d+$// level:error", + 'level:error message:"*order-*"', + ), + ).toBe(true); + expect( + isSemanticFilterDowngrade("custom://^order-\\d+$//", "custom:order-*"), + ).toBe(true); + + expect( + isSemanticFilterDowngrade( + "message://^Timeout//", + "message://^Timeout// severity:error", + ), + ).toBe(false); + expect( + isSemanticFilterDowngrade( + "custom://^order-\\d+$//", + "tags[custom]://^order-\\d+$//", + ), + ).toBe(false); + }); + it("detects message full-text downgrades but allows real field renames", () => { expect( isSemanticFilterDowngrade("conv_id:ZYGC-86ZR", 'message:"*ZYGC-86ZR*"'), diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index 6641fdfe1..be2dc98b3 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -27,8 +27,16 @@ export type FlexibleEventData = Record; const DEFAULT_MAX_VALUE_LENGTH = 200; const DEFAULT_MAX_ARRAY_ITEMS = 20; -const SENTRY_SEARCH_TOKEN_PATTERN = - /(^|\s)!?([A-Za-z_][A-Za-z0-9_.[\],-]*):(?=\S)(?!\/\/)/g; +const REGEX_FILTER_VALUE_SOURCE = String.raw`\/\/(?!\/\/(?:[\t\n )]|$))[^\n]{1,1024}?\/\/(?=[\t\n )]|$)`; +const REGEX_FILTER_VALUE_PATTERN = new RegExp(`^${REGEX_FILTER_VALUE_SOURCE}`); +const SEARCH_FILTER_KEY_SOURCE = String.raw`(^|\s)!?([A-Za-z_][A-Za-z0-9_.[\],-]*):`; +const SEARCH_FILTER_KEY_BEFORE_PATTERN = new RegExp( + `${SEARCH_FILTER_KEY_SOURCE}$`, +); +const SENTRY_SEARCH_TOKEN_PATTERN = new RegExp( + String.raw`${SEARCH_FILTER_KEY_SOURCE}(?=\S)(?:(?!\/\/)|(?=${REGEX_FILTER_VALUE_SOURCE}))`, + "g", +); const KNOWN_SENTRY_SEARCH_KEYS = new Set([ "browser", "device", @@ -116,19 +124,34 @@ export function looksLikeSentrySearchSyntax(query?: string): boolean { return false; } +export function readRegexFilterValue( + query: string, + index: number, +): string | undefined { + if ( + !query.startsWith("//", index) || + !SEARCH_FILTER_KEY_BEFORE_PATTERN.test(query.slice(0, index)) + ) { + return undefined; + } + return REGEX_FILTER_VALUE_PATTERN.exec(query.slice(index))?.[0]; +} + const FULL_TEXT_SEARCH_KEYS = new Set(["message", "log.body"]); /** - * Replace quoted regions with same-length placeholders so colons inside quotes - * are not treated as filter-key separators (e.g. transaction:"handle message:hello"). - * Length is preserved so offsets still map back to the original query. + * Replace quoted regions and regex values with same-length placeholders so + * colons inside them are not treated as filter-key separators (e.g. + * transaction:"handle message:hello"). Length is preserved so offsets still + * map back to the original query. */ function maskQuotedRegions(query: string): string { let out = ""; let quote: '"' | "'" | null = null; let escaped = false; - for (const char of query) { + for (let i = 0; i < query.length; i += 1) { + const char = query[i]; if (escaped) { escaped = false; out += quote ? "x" : char; @@ -149,6 +172,12 @@ function maskQuotedRegions(query: string): string { } continue; } + const regexValue = readRegexFilterValue(query, i); + if (regexValue) { + out += `//${regexValue.slice(2, -2).replace(/[:\s]/g, "x")}//`; + i += regexValue.length - 1; + continue; + } if (char === '"' || char === "'") { quote = char; out += char; @@ -163,6 +192,7 @@ function maskQuotedRegions(query: string): string { type SearchFilterOccurrence = { key: string; value: string; + regex: boolean; }; function normalizeFilterValue(rawValue: string): string { @@ -228,6 +258,16 @@ function searchFilterOccurrences(query: string): SearchFilterOccurrence[] { // Read the real value from the original query at the same offset so quotes // and multi-word quoted values are preserved before normalization. const valueStart = match.index + match[0].indexOf(":") + 1; + const regexValue = readRegexFilterValue(query, valueStart); + if (regexValue) { + occurrences.push({ + key, + value: regexValue.slice(2, -2).toLowerCase(), + regex: true, + }); + continue; + } + const rawValue = readRawFilterValue(query, valueStart); if (!rawValue) { continue; @@ -238,7 +278,7 @@ function searchFilterOccurrences(query: string): SearchFilterOccurrence[] { continue; } - occurrences.push({ key, value }); + occurrences.push({ key, value, regex: false }); } return occurrences; @@ -257,7 +297,7 @@ function fullTextFilterValues(query: string): string[] { } function filterOccurrenceIdentity(occurrence: SearchFilterOccurrence): string { - return `${occurrence.key}\0${occurrence.value}`; + return `${occurrence.key}\0${occurrence.regex}\0${occurrence.value}`; } function escapeRegExp(value: string): string { @@ -316,9 +356,36 @@ function unmatchedStructuredFilters( return unmatched; } +function isRegexFilterDowngrade( + originalQuery: string, + repairedQuery: string, +): boolean { + const originalRegexFilters = searchFilterOccurrences(originalQuery).filter( + (occurrence) => occurrence.regex, + ); + if (originalRegexFilters.length === 0) { + return false; + } + + const repairedFilters = searchFilterOccurrences(repairedQuery); + const repairedPlainFilters = repairedFilters.filter( + (occurrence) => !occurrence.regex, + ); + return unmatchedStructuredFilters(originalRegexFilters, repairedFilters).some( + (filter) => + repairedPlainFilters.some( + (repaired) => + repaired.key === filter.key || + (FULL_TEXT_SEARCH_KEYS.has(repaired.key) && + isRelatedFilterValue(filter.value, repaired.value)), + ), + ); +} + /** * True when a structured field:value filter was replaced with message/log.body - * full-text matching (false-success path). Allows real attribute renames. + * full-text matching (false-success path), or a `key://pattern//` regex filter + * was replaced with a plain or wildcard filter. Allows real attribute renames. * * Uses multiset key+value matching so dropping one of several identical keys * (e.g. `custom:foo custom:bar` → `custom:bar message:"*foo*"`) is still caught. @@ -333,6 +400,10 @@ export function isSemanticFilterDowngrade( return false; } + if (isRegexFilterDowngrade(originalQuery, repairedQuery)) { + return true; + } + const originalFilters = structuredFilterOccurrences(originalQuery); if (originalFilters.length === 0) { return false; From a09956fdeef8ae557ceee60139d843b61a62747e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 12:32:25 -0400 Subject: [PATCH 2/7] fix(search): Only treat regex filters as search syntax for logs Sentry only runs key://pattern// as a regex on logs; other datasets match the literal text, so a regex sent to search_spans or search_errors now lets the agent's rewrite through instead of forcing an empty search. Refs LOGS-1015 Co-Authored-By: Claude Opus 5.5 --- .../src/tools/catalog/search-events.test.ts | 18 +++++ .../src/tools/support/search-events/search.ts | 17 +++-- .../tools/support/search-events/utils.test.ts | 59 +++++++++++++--- .../src/tools/support/search-events/utils.ts | 67 +++++++++++++------ 4 files changed, 129 insertions(+), 32 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts index b32a9739c..2637bd5b6 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3162,6 +3162,24 @@ describe("search_events", () => { "severity:error message://can't connect to \\w+// has:trace", ]); }); + + it("uses the agent's rewrite of regex syntax outside logs", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", 'span.description:"GET /api/*"'), + ); + const queries = captureEventsQueries(); + + await searchEvents.handler( + { + ...regexSearchParams, + dataset: "spans", + query: "span.description://^GET \\/api\\/\\d+//", + }, + regexSearchContext, + ); + + expect(queries).toEqual(['span.description:"GET /api/*"']); + }); }); it("keeps caller fields when the agent returns an empty fields array", async () => { diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts index 1fd11e6d8..6c48fd244 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -331,6 +331,7 @@ function choosePreservingRepairedQuery(params: { originalQuery: string; repairedQuery?: string | null; filter?: string; + dataset: PublicEventsDataset | "replays"; }): string { const originalQuery = params.originalQuery.trim(); const repairedQuery = params.repairedQuery?.trim(); @@ -338,7 +339,7 @@ function choosePreservingRepairedQuery(params: { return appendSearchFilter(originalQuery, params.filter); } - if (isSemanticFilterDowngrade(originalQuery, repairedQuery)) { + if (isSemanticFilterDowngrade(originalQuery, repairedQuery, params.dataset)) { return appendSearchFilter(originalQuery, params.filter); } @@ -620,7 +621,10 @@ export async function runSearchEvents( setTargetTagsAndAttributes(params); const inputDataset = params.dataset ?? "errors"; - const hasStructuredQuery = looksLikeSentrySearchSyntax(params.query); + const hasStructuredQuery = looksLikeSentrySearchSyntax( + params.query, + inputDataset, + ); const canApplyEnvironmentFilter = inputDataset !== "replays" && isTraceItemDataset(inputDataset) && @@ -789,9 +793,14 @@ export async function runSearchEvents( originalQuery: params.query ?? "", repairedQuery: parsed.query, filter: environmentFilter, + dataset, }) - : looksLikeSentrySearchSyntax(params.query) && - isSemanticFilterDowngrade(params.query ?? "", parsed.query || "") + : looksLikeSentrySearchSyntax(params.query, dataset) && + isSemanticFilterDowngrade( + params.query ?? "", + parsed.query || "", + dataset, + ) ? (params.query ?? "") : parsed.query || ""; sortParam = diff --git a/packages/mcp-core/src/tools/support/search-events/utils.test.ts b/packages/mcp-core/src/tools/support/search-events/utils.test.ts index 9f9e83e7a..5309228f0 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.test.ts @@ -297,49 +297,90 @@ describe("search query helpers", () => { expect(looksLikeSentrySearchSyntax("ERROR: service is down")).toBe(false); }); - it("should detect regex filters", () => { - expect(looksLikeSentrySearchSyntax("message://^Timeout//")).toBe(true); - expect( - looksLikeSentrySearchSyntax("message://^Timeout after \\d+ms//"), - ).toBe(true); - expect(looksLikeSentrySearchSyntax("!message://(?i)healthcheck//")).toBe( + it("should detect regex filters in logs queries", () => { + expect(looksLikeSentrySearchSyntax("message://^Timeout//", "logs")).toBe( true, ); - expect(looksLikeSentrySearchSyntax("open http://example.com/a//b")).toBe( + expect( + looksLikeSentrySearchSyntax("message://^Timeout after \\d+ms//", "logs"), + ).toBe(true); + expect( + looksLikeSentrySearchSyntax("!message://(?i)healthcheck//", "logs"), + ).toBe(true); + expect( + looksLikeSentrySearchSyntax("open http://example.com/a//b", "logs"), + ).toBe(false); + }); + + it("should not treat regex filters as search syntax outside logs", () => { + expect(looksLikeSentrySearchSyntax("message://^Timeout//")).toBe(false); + expect( + looksLikeSentrySearchSyntax("span.description://^GET \\/api//", "spans"), + ).toBe(false); + expect(looksLikeSentrySearchSyntax("message://^Timeout//", "errors")).toBe( false, ); }); it("detects regex filters rewritten into plain or wildcard filters", () => { expect( - isSemanticFilterDowngrade("message://^Timeout//", 'message:"*Timeout*"'), + isSemanticFilterDowngrade( + "message://^Timeout//", + 'message:"*Timeout*"', + "logs", + ), ).toBe(true); expect( isSemanticFilterDowngrade( "message://can't connect to \\w+//", "message:*connect*", + "logs", ), ).toBe(true); expect( isSemanticFilterDowngrade( "custom://^order-\\d+$// level:error", 'level:error message:"*order-*"', + "logs", ), ).toBe(true); expect( - isSemanticFilterDowngrade("custom://^order-\\d+$//", "custom:order-*"), + isSemanticFilterDowngrade( + "custom://^order-\\d+$//", + "custom:order-*", + "logs", + ), ).toBe(true); expect( isSemanticFilterDowngrade( "message://^Timeout//", "message://^Timeout// severity:error", + "logs", ), ).toBe(false); expect( isSemanticFilterDowngrade( "custom://^order-\\d+$//", "tags[custom]://^order-\\d+$//", + "logs", + ), + ).toBe(false); + }); + + it("allows rewriting regex filters outside logs", () => { + expect( + isSemanticFilterDowngrade( + "span.description://^GET \\/api\\/\\d+//", + 'span.description:"GET /api/*"', + "spans", + ), + ).toBe(false); + expect( + isSemanticFilterDowngrade( + "message://^Timeout//", + 'message:"*Timeout*"', + "errors", ), ).toBe(false); }); diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index be2dc98b3..fcdff489e 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -34,6 +34,10 @@ const SEARCH_FILTER_KEY_BEFORE_PATTERN = new RegExp( `${SEARCH_FILTER_KEY_SOURCE}$`, ); const SENTRY_SEARCH_TOKEN_PATTERN = new RegExp( + String.raw`${SEARCH_FILTER_KEY_SOURCE}(?=\S)(?!\/\/)`, + "g", +); +const SENTRY_SEARCH_TOKEN_WITH_REGEX_PATTERN = new RegExp( String.raw`${SEARCH_FILTER_KEY_SOURCE}(?=\S)(?:(?!\/\/)|(?=${REGEX_FILTER_VALUE_SOURCE}))`, "g", ); @@ -92,13 +96,26 @@ export function isAggregateQuery(fields: string[]): boolean { return fields.some((field) => field.includes("(") && field.includes(")")); } -export function looksLikeSentrySearchSyntax(query?: string): boolean { +type SearchSyntaxDataset = EventsDataset | "replays"; + +// Sentry only honors key://pattern// as a regex on logs; other datasets match +// the literal text //pattern//. +function searchTokenPattern(dataset?: SearchSyntaxDataset): RegExp { + return dataset === "logs" + ? SENTRY_SEARCH_TOKEN_WITH_REGEX_PATTERN + : SENTRY_SEARCH_TOKEN_PATTERN; +} + +export function looksLikeSentrySearchSyntax( + query?: string, + dataset?: SearchSyntaxDataset, +): boolean { const trimmedQuery = query?.trim(); if (!trimmedQuery) { return false; } - for (const match of trimmedQuery.matchAll(SENTRY_SEARCH_TOKEN_PATTERN)) { + for (const match of trimmedQuery.matchAll(searchTokenPattern(dataset))) { const key = match[2]; if (!key) { continue; @@ -245,11 +262,14 @@ function readRawFilterValue( * boundaries. Values are normalized for comparison (strip wrapping quotes and * leading/trailing wildcards). */ -function searchFilterOccurrences(query: string): SearchFilterOccurrence[] { +function searchFilterOccurrences( + query: string, + dataset?: SearchSyntaxDataset, +): SearchFilterOccurrence[] { const occurrences: SearchFilterOccurrence[] = []; const masked = maskQuotedRegions(query); - for (const match of masked.matchAll(SENTRY_SEARCH_TOKEN_PATTERN)) { + for (const match of masked.matchAll(searchTokenPattern(dataset))) { const key = match[2]?.toLowerCase(); if (!key || match.index === undefined) { continue; @@ -284,14 +304,20 @@ function searchFilterOccurrences(query: string): SearchFilterOccurrence[] { return occurrences; } -function structuredFilterOccurrences(query: string): SearchFilterOccurrence[] { - return searchFilterOccurrences(query).filter( +function structuredFilterOccurrences( + query: string, + dataset?: SearchSyntaxDataset, +): SearchFilterOccurrence[] { + return searchFilterOccurrences(query, dataset).filter( (occurrence) => !FULL_TEXT_SEARCH_KEYS.has(occurrence.key), ); } -function fullTextFilterValues(query: string): string[] { - return searchFilterOccurrences(query) +function fullTextFilterValues( + query: string, + dataset?: SearchSyntaxDataset, +): string[] { + return searchFilterOccurrences(query, dataset) .filter((occurrence) => FULL_TEXT_SEARCH_KEYS.has(occurrence.key)) .map((occurrence) => occurrence.value); } @@ -359,15 +385,17 @@ function unmatchedStructuredFilters( function isRegexFilterDowngrade( originalQuery: string, repairedQuery: string, + dataset?: SearchSyntaxDataset, ): boolean { - const originalRegexFilters = searchFilterOccurrences(originalQuery).filter( - (occurrence) => occurrence.regex, - ); + const originalRegexFilters = searchFilterOccurrences( + originalQuery, + dataset, + ).filter((occurrence) => occurrence.regex); if (originalRegexFilters.length === 0) { return false; } - const repairedFilters = searchFilterOccurrences(repairedQuery); + const repairedFilters = searchFilterOccurrences(repairedQuery, dataset); const repairedPlainFilters = repairedFilters.filter( (occurrence) => !occurrence.regex, ); @@ -384,8 +412,8 @@ function isRegexFilterDowngrade( /** * True when a structured field:value filter was replaced with message/log.body - * full-text matching (false-success path), or a `key://pattern//` regex filter - * was replaced with a plain or wildcard filter. Allows real attribute renames. + * full-text matching (false-success path), or a logs `key://pattern//` regex + * filter was replaced with a plain or wildcard filter. Allows real attribute renames. * * Uses multiset key+value matching so dropping one of several identical keys * (e.g. `custom:foo custom:bar` → `custom:bar message:"*foo*"`) is still caught. @@ -395,21 +423,22 @@ function isRegexFilterDowngrade( export function isSemanticFilterDowngrade( originalQuery: string, repairedQuery: string, + dataset?: SearchSyntaxDataset, ): boolean { - if (!looksLikeSentrySearchSyntax(originalQuery)) { + if (!looksLikeSentrySearchSyntax(originalQuery, dataset)) { return false; } - if (isRegexFilterDowngrade(originalQuery, repairedQuery)) { + if (isRegexFilterDowngrade(originalQuery, repairedQuery, dataset)) { return true; } - const originalFilters = structuredFilterOccurrences(originalQuery); + const originalFilters = structuredFilterOccurrences(originalQuery, dataset); if (originalFilters.length === 0) { return false; } - const repairedFilters = structuredFilterOccurrences(repairedQuery); + const repairedFilters = structuredFilterOccurrences(repairedQuery, dataset); const droppedFilters = unmatchedStructuredFilters( originalFilters, repairedFilters, @@ -418,7 +447,7 @@ export function isSemanticFilterDowngrade( return false; } - const repairedFullTextValues = fullTextFilterValues(repairedQuery); + const repairedFullTextValues = fullTextFilterValues(repairedQuery, dataset); if (repairedFullTextValues.length === 0) { // Renames keep values on non-full-text attributes and do not need this guard. return false; From 540b392ee7b85cad56b66364cc195e44979a9d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 15:05:09 -0400 Subject: [PATCH 3/7] fix(search): Recognize regex filters in groups and on all Sentry key shapes A regex right after ( or on a typed, colon, quoted, or [*] key now counts as a regex filter, and changing the pattern (including its case) counts as a downgrade. Refs LOGS-1015 Co-Authored-By: Claude Opus 5.5 --- .../search-events-environment-note.test.ts | 3 ++ .../tools/support/search-events/utils.test.ts | 40 +++++++++++++++++++ .../src/tools/support/search-events/utils.ts | 29 ++++++++------ 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts b/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts index b3bd58d0a..612be897e 100644 --- a/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts @@ -70,6 +70,9 @@ describe("collectRequestedEnvironments", () => { expect( collectRequestedEnvironments(null, "message://a environment:foo b//"), ).toEqual([]); + expect( + collectRequestedEnvironments(null, "(message://a environment:foo b//)"), + ).toEqual([]); }); }); diff --git a/packages/mcp-core/src/tools/support/search-events/utils.test.ts b/packages/mcp-core/src/tools/support/search-events/utils.test.ts index 5309228f0..56a9b635b 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.test.ts @@ -312,6 +312,19 @@ describe("search query helpers", () => { ).toBe(false); }); + it("should detect regex filters inside parentheses and on Sentry's other key shapes", () => { + expect(looksLikeSentrySearchSyntax("(message://a b//)", "logs")).toBe(true); + expect( + looksLikeSentrySearchSyntax("tags[sentry:user]://^id \\d+//", "logs"), + ).toBe(true); + expect( + looksLikeSentrySearchSyntax("tags[foo, string]://a b//", "logs"), + ).toBe(true); + expect(looksLikeSentrySearchSyntax('"mykey"://a b//', "logs")).toBe(true); + expect(looksLikeSentrySearchSyntax("arr[*]://a b//", "logs")).toBe(true); + expect(looksLikeSentrySearchSyntax("(level:error)", "logs")).toBe(false); + }); + it("should not treat regex filters as search syntax outside logs", () => { expect(looksLikeSentrySearchSyntax("message://^Timeout//")).toBe(false); expect( @@ -368,6 +381,33 @@ describe("search query helpers", () => { ).toBe(false); }); + it("detects regex filter downgrades inside parentheses and on colon keys", () => { + expect( + isSemanticFilterDowngrade( + "severity:error (message://^Timeout after \\d+ms//)", + 'severity:error message:"*Timeout after*"', + "logs", + ), + ).toBe(true); + expect( + isSemanticFilterDowngrade( + "tags[sentry:user]://^id \\d+//", + 'message:"*id*"', + "logs", + ), + ).toBe(true); + }); + + it("detects regex filters whose pattern changed case", () => { + expect( + isSemanticFilterDowngrade( + "message://^Timeout//", + "message://^timeout//", + "logs", + ), + ).toBe(true); + }); + it("allows rewriting regex filters outside logs", () => { expect( isSemanticFilterDowngrade( diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index fcdff489e..6e487e385 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -30,15 +30,16 @@ const DEFAULT_MAX_ARRAY_ITEMS = 20; const REGEX_FILTER_VALUE_SOURCE = String.raw`\/\/(?!\/\/(?:[\t\n )]|$))[^\n]{1,1024}?\/\/(?=[\t\n )]|$)`; const REGEX_FILTER_VALUE_PATTERN = new RegExp(`^${REGEX_FILTER_VALUE_SOURCE}`); const SEARCH_FILTER_KEY_SOURCE = String.raw`(^|\s)!?([A-Za-z_][A-Za-z0-9_.[\],-]*):`; -const SEARCH_FILTER_KEY_BEFORE_PATTERN = new RegExp( - `${SEARCH_FILTER_KEY_SOURCE}$`, +const REGEX_FILTER_KEY_SOURCE = String.raw`(^|[\s(])!?((?:tags|flags)\[[\w.:-]+(?: *, *(?:string|number|boolean|array))?\](?:\[\*\])?|"[\w.:-]+"(?:\[\*\])?|[A-Za-z_][A-Za-z0-9_.[\],-]*(?:\[\*\])?):`; +const REGEX_FILTER_KEY_BEFORE_PATTERN = new RegExp( + `${REGEX_FILTER_KEY_SOURCE}$`, ); const SENTRY_SEARCH_TOKEN_PATTERN = new RegExp( String.raw`${SEARCH_FILTER_KEY_SOURCE}(?=\S)(?!\/\/)`, "g", ); const SENTRY_SEARCH_TOKEN_WITH_REGEX_PATTERN = new RegExp( - String.raw`${SEARCH_FILTER_KEY_SOURCE}(?=\S)(?:(?!\/\/)|(?=${REGEX_FILTER_VALUE_SOURCE}))`, + String.raw`${SEARCH_FILTER_KEY_SOURCE}(?=\S)(?!\/\/)|${REGEX_FILTER_KEY_SOURCE}(?=${REGEX_FILTER_VALUE_SOURCE})`, "g", ); const KNOWN_SENTRY_SEARCH_KEYS = new Set([ @@ -116,6 +117,10 @@ export function looksLikeSentrySearchSyntax( } for (const match of trimmedQuery.matchAll(searchTokenPattern(dataset))) { + if (match[4]) { + return true; + } + const key = match[2]; if (!key) { continue; @@ -147,7 +152,7 @@ export function readRegexFilterValue( ): string | undefined { if ( !query.startsWith("//", index) || - !SEARCH_FILTER_KEY_BEFORE_PATTERN.test(query.slice(0, index)) + !REGEX_FILTER_KEY_BEFORE_PATTERN.test(query.slice(0, index)) ) { return undefined; } @@ -270,19 +275,19 @@ function searchFilterOccurrences( const masked = maskQuotedRegions(query); for (const match of masked.matchAll(searchTokenPattern(dataset))) { - const key = match[2]?.toLowerCase(); + const key = (match[2] ?? match[4])?.toLowerCase(); if (!key || match.index === undefined) { continue; } // Read the real value from the original query at the same offset so quotes // and multi-word quoted values are preserved before normalization. - const valueStart = match.index + match[0].indexOf(":") + 1; + const valueStart = match.index + match[0].length; const regexValue = readRegexFilterValue(query, valueStart); if (regexValue) { occurrences.push({ key, - value: regexValue.slice(2, -2).toLowerCase(), + value: regexValue.slice(2, -2), regex: true, }); continue; @@ -396,16 +401,16 @@ function isRegexFilterDowngrade( } const repairedFilters = searchFilterOccurrences(repairedQuery, dataset); - const repairedPlainFilters = repairedFilters.filter( - (occurrence) => !occurrence.regex, - ); return unmatchedStructuredFilters(originalRegexFilters, repairedFilters).some( (filter) => - repairedPlainFilters.some( + repairedFilters.some( (repaired) => repaired.key === filter.key || (FULL_TEXT_SEARCH_KEYS.has(repaired.key) && - isRelatedFilterValue(filter.value, repaired.value)), + isRelatedFilterValue( + filter.value.toLowerCase(), + repaired.value.toLowerCase(), + )), ), ); } From 4bfee6a7be4f2e4d2cc0b1fac066e59dfaed89c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 15:25:11 -0400 Subject: [PATCH 4/7] fix(search): Match regex keys before plain keys and bound the key scan Colon and uppercase bracket keys now take the regex path, wildcard rewrites are caught on every regex key shape, a regex may follow ) or a quote, and key detection no longer rescans the whole query prefix. Refs LOGS-1015 Co-Authored-By: Claude Opus 5.5 --- .../tools/support/search-events/utils.test.ts | 26 ++++++++++++++-- .../src/tools/support/search-events/utils.ts | 30 ++++++++++++------- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/packages/mcp-core/src/tools/support/search-events/utils.test.ts b/packages/mcp-core/src/tools/support/search-events/utils.test.ts index 56a9b635b..a8e9678d6 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.test.ts @@ -315,13 +315,19 @@ describe("search query helpers", () => { it("should detect regex filters inside parentheses and on Sentry's other key shapes", () => { expect(looksLikeSentrySearchSyntax("(message://a b//)", "logs")).toBe(true); expect( - looksLikeSentrySearchSyntax("tags[sentry:user]://^id \\d+//", "logs"), + looksLikeSentrySearchSyntax("tags[App:Region]://^us-//", "logs"), ).toBe(true); + expect(looksLikeSentrySearchSyntax("flags[Feature:X]://y//", "logs")).toBe( + true, + ); expect( looksLikeSentrySearchSyntax("tags[foo, string]://a b//", "logs"), ).toBe(true); expect(looksLikeSentrySearchSyntax('"mykey"://a b//', "logs")).toBe(true); expect(looksLikeSentrySearchSyntax("arr[*]://a b//", "logs")).toBe(true); + expect( + looksLikeSentrySearchSyntax('"Note"message://a b//', "logs"), + ).toBe(true); expect(looksLikeSentrySearchSyntax("(level:error)", "logs")).toBe(false); }); @@ -392,7 +398,23 @@ describe("search query helpers", () => { expect( isSemanticFilterDowngrade( "tags[sentry:user]://^id \\d+//", - 'message:"*id*"', + "tags[sentry:user]://^id//", + "logs", + ), + ).toBe(true); + }); + + it("detects regex filters rewritten into wildcards on array, quoted, and spaced keys", () => { + expect( + isSemanticFilterDowngrade("arr[*]://^a b//", "arr[*]:*a*", "logs"), + ).toBe(true); + expect( + isSemanticFilterDowngrade('"mykey"://^a b//', '"mykey":*a*', "logs"), + ).toBe(true); + expect( + isSemanticFilterDowngrade( + "tags[x, string]://^a b//", + 'tags[x, string]:"*a*"', "logs", ), ).toBe(true); diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index 6e487e385..3fb2a2d5f 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -29,17 +29,18 @@ const DEFAULT_MAX_VALUE_LENGTH = 200; const DEFAULT_MAX_ARRAY_ITEMS = 20; const REGEX_FILTER_VALUE_SOURCE = String.raw`\/\/(?!\/\/(?:[\t\n )]|$))[^\n]{1,1024}?\/\/(?=[\t\n )]|$)`; const REGEX_FILTER_VALUE_PATTERN = new RegExp(`^${REGEX_FILTER_VALUE_SOURCE}`); -const SEARCH_FILTER_KEY_SOURCE = String.raw`(^|\s)!?([A-Za-z_][A-Za-z0-9_.[\],-]*):`; -const REGEX_FILTER_KEY_SOURCE = String.raw`(^|[\s(])!?((?:tags|flags)\[[\w.:-]+(?: *, *(?:string|number|boolean|array))?\](?:\[\*\])?|"[\w.:-]+"(?:\[\*\])?|[A-Za-z_][A-Za-z0-9_.[\],-]*(?:\[\*\])?):`; +const SEARCH_FILTER_KEY_SOURCE = String.raw`(^|\s)!?(?[A-Za-z_][A-Za-z0-9_.[\],-]*):`; +const REGEX_FILTER_KEY_SOURCE = String.raw`(^|[\s()"])!?(?(?:tags|flags)\[[\w.:-]+(?: *, *(?:string|number|boolean|array))?\](?:\[\*\])?|"[\w.:-]+"(?:\[\*\])?|[A-Za-z_][A-Za-z0-9_.[\],-]*(?:\[\*\])?):`; const REGEX_FILTER_KEY_BEFORE_PATTERN = new RegExp( `${REGEX_FILTER_KEY_SOURCE}$`, ); +const REGEX_FILTER_KEY_SCAN_LIMIT = 256; const SENTRY_SEARCH_TOKEN_PATTERN = new RegExp( String.raw`${SEARCH_FILTER_KEY_SOURCE}(?=\S)(?!\/\/)`, "g", ); const SENTRY_SEARCH_TOKEN_WITH_REGEX_PATTERN = new RegExp( - String.raw`${SEARCH_FILTER_KEY_SOURCE}(?=\S)(?!\/\/)|${REGEX_FILTER_KEY_SOURCE}(?=${REGEX_FILTER_VALUE_SOURCE})`, + String.raw`${REGEX_FILTER_KEY_SOURCE}(?=${REGEX_FILTER_VALUE_SOURCE})|${SEARCH_FILTER_KEY_SOURCE}(?=\S)(?!\/\/)`, "g", ); const KNOWN_SENTRY_SEARCH_KEYS = new Set([ @@ -117,11 +118,11 @@ export function looksLikeSentrySearchSyntax( } for (const match of trimmedQuery.matchAll(searchTokenPattern(dataset))) { - if (match[4]) { + if (match.groups?.regexKey) { return true; } - const key = match[2]; + const key = match.groups?.key; if (!key) { continue; } @@ -150,10 +151,14 @@ export function readRegexFilterValue( query: string, index: number, ): string | undefined { - if ( - !query.startsWith("//", index) || - !REGEX_FILTER_KEY_BEFORE_PATTERN.test(query.slice(0, index)) - ) { + if (!query.startsWith("//", index) || query[index - 1] !== ":") { + return undefined; + } + // Callers probe every index, so only scan a bounded key-sized prefix; the + // NUL stands in for truncated text so `^` can't match mid-query. + const start = Math.max(0, index - REGEX_FILTER_KEY_SCAN_LIMIT); + const prefix = `${start > 0 ? "\0" : ""}${query.slice(start, index)}`; + if (!REGEX_FILTER_KEY_BEFORE_PATTERN.test(prefix)) { return undefined; } return REGEX_FILTER_VALUE_PATTERN.exec(query.slice(index))?.[0]; @@ -275,7 +280,7 @@ function searchFilterOccurrences( const masked = maskQuotedRegions(query); for (const match of masked.matchAll(searchTokenPattern(dataset))) { - const key = (match[2] ?? match[4])?.toLowerCase(); + const key = (match.groups?.regexKey ?? match.groups?.key)?.toLowerCase(); if (!key || match.index === undefined) { continue; } @@ -401,8 +406,13 @@ function isRegexFilterDowngrade( } const repairedFilters = searchFilterOccurrences(repairedQuery, dataset); + const maskedRepairedQuery = maskQuotedRegions(repairedQuery); return unmatchedStructuredFilters(originalRegexFilters, repairedFilters).some( (filter) => + new RegExp( + String.raw`(?:^|[\s()"])!?${escapeRegExp(filter.key)}:(?!\/\/)`, + "i", + ).test(maskedRepairedQuery) || repairedFilters.some( (repaired) => repaired.key === filter.key || From 954d14a63c6da7b4cec489a80c4b84363ae4fa14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 16:13:16 -0400 Subject: [PATCH 5/7] test(search): Format the regex helper tests Refs LOGS-1015 Co-Authored-By: Claude Opus 5.5 --- .../mcp-core/src/tools/support/search-events/utils.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mcp-core/src/tools/support/search-events/utils.test.ts b/packages/mcp-core/src/tools/support/search-events/utils.test.ts index a8e9678d6..b4d695726 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.test.ts @@ -325,9 +325,9 @@ describe("search query helpers", () => { ).toBe(true); expect(looksLikeSentrySearchSyntax('"mykey"://a b//', "logs")).toBe(true); expect(looksLikeSentrySearchSyntax("arr[*]://a b//", "logs")).toBe(true); - expect( - looksLikeSentrySearchSyntax('"Note"message://a b//', "logs"), - ).toBe(true); + expect(looksLikeSentrySearchSyntax('"Note"message://a b//', "logs")).toBe( + true, + ); expect(looksLikeSentrySearchSyntax("(level:error)", "logs")).toBe(false); }); From 821bccb62edafbd67a3fa6bfd3a7d686d8241e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Wed, 7 Oct 2026 12:20:21 -0400 Subject: [PATCH 6/7] fix(search): Don't read URLs as regex log filters A natural-language log query with a URL ending in `//` matched the key://pattern// shape, so it skipped the search agent and blocked valid rewrites as downgrades. Co-Authored-By: Claude Opus 5.5 --- .../tools/support/search-events/utils.test.ts | 26 +++++++++++++++++++ .../src/tools/support/search-events/utils.ts | 5 +++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/mcp-core/src/tools/support/search-events/utils.test.ts b/packages/mcp-core/src/tools/support/search-events/utils.test.ts index b4d695726..a7035b6ab 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.test.ts @@ -312,6 +312,32 @@ describe("search query helpers", () => { ).toBe(false); }); + it("does not treat URLs as regex filters when they end in a regex terminator", () => { + const queries = [ + "errors from https://example.com/a// in checkout", + "requests to (http://example.com//)", + "uploads to s3://bucket and https://example.com//", + ]; + + const results = queries.map((query) => + looksLikeSentrySearchSyntax(query, "logs"), + ); + + expect(results).toEqual([false, false, false]); + }); + + it("allows rewriting natural language log queries that contain URLs", () => { + const query = "errors from https://example.com/a// in checkout"; + + const downgrade = isSemanticFilterDowngrade( + query, + 'message:"*example.com/a*"', + "logs", + ); + + expect(downgrade).toBe(false); + }); + it("should detect regex filters inside parentheses and on Sentry's other key shapes", () => { expect(looksLikeSentrySearchSyntax("(message://a b//)", "logs")).toBe(true); expect( diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index 3fb2a2d5f..92437dd7f 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -30,7 +30,10 @@ const DEFAULT_MAX_ARRAY_ITEMS = 20; const REGEX_FILTER_VALUE_SOURCE = String.raw`\/\/(?!\/\/(?:[\t\n )]|$))[^\n]{1,1024}?\/\/(?=[\t\n )]|$)`; const REGEX_FILTER_VALUE_PATTERN = new RegExp(`^${REGEX_FILTER_VALUE_SOURCE}`); const SEARCH_FILTER_KEY_SOURCE = String.raw`(^|\s)!?(?[A-Za-z_][A-Za-z0-9_.[\],-]*):`; -const REGEX_FILTER_KEY_SOURCE = String.raw`(^|[\s()"])!?(?(?:tags|flags)\[[\w.:-]+(?: *, *(?:string|number|boolean|array))?\](?:\[\*\])?|"[\w.:-]+"(?:\[\*\])?|[A-Za-z_][A-Za-z0-9_.[\],-]*(?:\[\*\])?):`; +// Sentry would parse `https://host/path// ` as a regex filter too, but no +// attribute is named after a URL scheme, so treat those as plain text. +const URL_SCHEME_KEY_SOURCE = String.raw`(?:https?|wss?|s?ftp|file|s3|gs):`; +const REGEX_FILTER_KEY_SOURCE = String.raw`(^|[\s()"])!?(?(?:tags|flags)\[[\w.:-]+(?: *, *(?:string|number|boolean|array))?\](?:\[\*\])?|"[\w.:-]+"(?:\[\*\])?|(?!${URL_SCHEME_KEY_SOURCE})[A-Za-z_][A-Za-z0-9_.[\],-]*(?:\[\*\])?):`; const REGEX_FILTER_KEY_BEFORE_PATTERN = new RegExp( `${REGEX_FILTER_KEY_SOURCE}$`, ); From 7c9abfff9b019432890daea507c2fb1b455f3486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Wed, 7 Oct 2026 12:41:27 -0400 Subject: [PATCH 7/7] fix(search): Ignore URL schemes in any case when reading regex filters Co-Authored-By: Claude Opus 5.5 --- .../mcp-core/src/tools/support/search-events/utils.test.ts | 3 ++- packages/mcp-core/src/tools/support/search-events/utils.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/mcp-core/src/tools/support/search-events/utils.test.ts b/packages/mcp-core/src/tools/support/search-events/utils.test.ts index a7035b6ab..e0438e834 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.test.ts @@ -317,13 +317,14 @@ describe("search query helpers", () => { "errors from https://example.com/a// in checkout", "requests to (http://example.com//)", "uploads to s3://bucket and https://example.com//", + "errors from HTTP://example.com/a// in checkout", ]; const results = queries.map((query) => looksLikeSentrySearchSyntax(query, "logs"), ); - expect(results).toEqual([false, false, false]); + expect(results).toEqual([false, false, false, false]); }); it("allows rewriting natural language log queries that contain URLs", () => { diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index 92437dd7f..7d0b4bc02 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -32,7 +32,11 @@ const REGEX_FILTER_VALUE_PATTERN = new RegExp(`^${REGEX_FILTER_VALUE_SOURCE}`); const SEARCH_FILTER_KEY_SOURCE = String.raw`(^|\s)!?(?[A-Za-z_][A-Za-z0-9_.[\],-]*):`; // Sentry would parse `https://host/path// ` as a regex filter too, but no // attribute is named after a URL scheme, so treat those as plain text. -const URL_SCHEME_KEY_SOURCE = String.raw`(?:https?|wss?|s?ftp|file|s3|gs):`; +const URL_SCHEME_KEY_SOURCE = + String.raw`(?:https?|wss?|s?ftp|file|s3|gs):`.replace( + /[a-z]/g, + (letter) => `[${letter}${letter.toUpperCase()}]`, + ); const REGEX_FILTER_KEY_SOURCE = String.raw`(^|[\s()"])!?(?(?:tags|flags)\[[\w.:-]+(?: *, *(?:string|number|boolean|array))?\](?:\[\*\])?|"[\w.:-]+"(?:\[\*\])?|(?!${URL_SCHEME_KEY_SOURCE})[A-Za-z_][A-Za-z0-9_.[\],-]*(?:\[\*\])?):`; const REGEX_FILTER_KEY_BEFORE_PATTERN = new RegExp( `${REGEX_FILTER_KEY_SOURCE}$`,