From e36fb321a4ffd07e3b0dc94e22c6f81e6c68da78 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/5] 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 44b41ac87..4d56c16f0 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -209,7 +209,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"] }, { @@ -304,7 +304,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"] }, { @@ -470,7 +470,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 9fa64538c..eb0fef4a2 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -7554,7 +7554,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 7de937048..a7b64c933 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; @@ -3977,6 +4068,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 3879ccf45..26f27cbb8 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 74ffd2b68..ec85f689b 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 81dada092842b3139bce52f70262f9b5acbfe69b 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/5] 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 a7b64c933..885d81153 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 26f27cbb8..a1c248af9 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) && @@ -790,9 +794,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 ec85f689b..9794f0b17 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 b5765cf6390b3bdffb5a81d91b8856f3faf439f2 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/5] 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 9794f0b17..6d6fe91c3 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 fc883df88b0211db7813bcd3be82569ce4eada22 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/5] 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 6d6fe91c3..d312d4241 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 62d3af876d756468fb3d63f9f710614096f22dfc 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/5] 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); });