From 3202993563a7f496ff549e490fd1dc94b4015234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 10:55:16 -0400 Subject: [PATCH 01/16] feat(search-events): Teach the logs query agent regex message search Add logs text-matching guidance to the embedded query-translation agent prompt so it uses `key://pattern//` regex filters when wildcards cannot express the request, and add regex few-shot examples for the logs dataset. Refs LOGS-1014 Co-Authored-By: Claude Code --- .../search-events-api-patterns.md | 1 + .../src/tools/support/search-events/config.ts | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/docs/contributing/search-events-api-patterns.md b/docs/contributing/search-events-api-patterns.md index b6299c234..b4e159e43 100644 --- a/docs/contributing/search-events-api-patterns.md +++ b/docs/contributing/search-events-api-patterns.md @@ -114,6 +114,7 @@ https://us.sentry.io/api/0/organizations/sentry/events/?dataset=spans&field=ai.m - Does NOT support timestamp filters in query (use `statsPeriod` instead) - Severity levels: fatal, error, warning, info, debug, trace - Common aggregate functions: `count()`, `epm()` +- Regex filters on string attributes: `message://timeout after \d+ms//` (RE2, unquoted, max 64 characters); prefer wildcards for plain substrings #### Metrics Dataset - Represents newer span metrics, including counters, gauges, and distributions diff --git a/packages/mcp-core/src/tools/support/search-events/config.ts b/packages/mcp-core/src/tools/support/search-events/config.ts index 27d4a42a4..38228b4e8 100644 --- a/packages/mcp-core/src/tools/support/search-events/config.ts +++ b/packages/mcp-core/src/tools/support/search-events/config.ts @@ -122,6 +122,17 @@ REPLAY SEARCH RULES: - If the user asks about replays they have viewed, prefer viewed_by_me:true - If the user asks about replay users and says "me", use whoami and translate to user.email: +LOGS TEXT MATCHING (LOGS DATASET ONLY): +- Plain word or phrase: use wildcards, e.g. message:"*database*". Do NOT use a regex when a substring match is enough +- Use a regex filter key://pattern// when wildcards cannot express the request: number shapes (\\d+), alternation (a|b), anchoring (^ starts with, $ ends with), character classes ([0-9a-f]), or structured values like IPs, UUIDs, and status codes + - Example: message://timeout after \\d+ms// + - Negate with a leading !: !message://job \\d+ completed// + - There is no list form; use alternation: message://(ConnectionReset|ReadTimeout)Error// +- NEVER quote a regex: message:"//...//" is a literal string match, not a regex. Spaces and parentheses inside the pattern are fine unquoted +- Regex uses RE2 syntax (no lookarounds or backreferences), matches anywhere unless anchored, and is case sensitive; prefix the pattern with (?i) to ignore case +- Patterns are limited to 64 characters (an escape like \\d counts as one). Write \\/\\/ to match a literal // +- Regex only works on string attributes, and only in the logs dataset; other datasets treat //...// as a literal value + MATHEMATICAL QUERY PATTERNS: When user asks mathematical questions like "how many X", "total Y used", "sum of Z": - Identify the appropriate dataset based on context @@ -720,6 +731,33 @@ export const DATASET_EXAMPLES: Record< sort: "-timestamp", }, }, + { + description: + "logs whose message reports a retry count like 'retry 3 of 5'", + output: { + query: "message://retry \\d+ of \\d+//", + fields: ["timestamp", "message", "severity", "trace"], + sort: "-timestamp", + }, + }, + { + description: + "error logs whose message starts with ConnectionReset or ReadTimeout", + output: { + query: "severity:error AND message://^(ConnectionReset|ReadTimeout)//", + fields: ["timestamp", "message", "severity", "trace"], + sort: "-timestamp", + }, + }, + { + description: + "logs excluding cache hit messages like 'cache hit for key user:42'", + output: { + query: "!message://^cache hit for key \\S+//", + fields: ["timestamp", "message", "severity", "trace"], + sort: "-timestamp", + }, + }, ], tracemetrics: [ { From 5623a6580a8bb48f2ce95aa1d9abfb2f2cf1f092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 12:41:22 -0400 Subject: [PATCH 02/16] fix(search-events): Keep the regex prompt from favoring regex over wildcards Teach prefix, suffix, and wildcard-list matching so plain alternatives and anchors don't reach for a regex, keep exact value sets as key:[a,b], and swap examples that overlapped the regex evals or needed no regex. Refs LOGS-1014 Co-Authored-By: Claude Opus 5.5 --- .../search-events-api-patterns.md | 2 +- .../src/tools/support/search-events/config.ts | 25 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/contributing/search-events-api-patterns.md b/docs/contributing/search-events-api-patterns.md index b4e159e43..5fe627927 100644 --- a/docs/contributing/search-events-api-patterns.md +++ b/docs/contributing/search-events-api-patterns.md @@ -114,7 +114,7 @@ https://us.sentry.io/api/0/organizations/sentry/events/?dataset=spans&field=ai.m - Does NOT support timestamp filters in query (use `statsPeriod` instead) - Severity levels: fatal, error, warning, info, debug, trace - Common aggregate functions: `count()`, `epm()` -- Regex filters on string attributes: `message://timeout after \d+ms//` (RE2, unquoted, max 64 characters); prefer wildcards for plain substrings +- Regex filters on string attributes: `message://timeout after \d+ms//` (RE2, unquoted, max 64 characters); prefer wildcards for plain substrings, prefixes, and suffixes #### Metrics Dataset - Represents newer span metrics, including counters, gauges, and distributions diff --git a/packages/mcp-core/src/tools/support/search-events/config.ts b/packages/mcp-core/src/tools/support/search-events/config.ts index 38228b4e8..659fc24f3 100644 --- a/packages/mcp-core/src/tools/support/search-events/config.ts +++ b/packages/mcp-core/src/tools/support/search-events/config.ts @@ -123,14 +123,17 @@ REPLAY SEARCH RULES: - If the user asks about replay users and says "me", use whoami and translate to user.email: LOGS TEXT MATCHING (LOGS DATASET ONLY): -- Plain word or phrase: use wildcards, e.g. message:"*database*". Do NOT use a regex when a substring match is enough -- Use a regex filter key://pattern// when wildcards cannot express the request: number shapes (\\d+), alternation (a|b), anchoring (^ starts with, $ ends with), character classes ([0-9a-f]), or structured values like IPs, UUIDs, and status codes - - Example: message://timeout after \\d+ms// - - Negate with a leading !: !message://job \\d+ completed// - - There is no list form; use alternation: message://(ConnectionReset|ReadTimeout)Error// -- NEVER quote a regex: message:"//...//" is a literal string match, not a regex. Spaces and parentheses inside the pattern are fine unquoted +- Plain word, phrase, prefix, or suffix: use wildcards. message:"*database*" contains, message:"database*" starts with, message:"*database" ends with. Do NOT use a regex when a wildcard is enough +- Any of several plain words or phrases: use a wildcard list, e.g. message:["*ConnectionReset*","*ReadTimeout*"] +- Use a regex filter key://pattern// when wildcards cannot express the request: number shapes (\\d+), character classes ([0-9a-f]), or structured values like IPs, UUIDs, and status codes. Use alternation (a|b) and anchors (^ or $) only alongside one of these + - Example: message://request took \\d+ms// + - Negate with a leading !: !message://worker \\d+ ready// + - There is no regex list form (key:[//a//,//b//] is a literal list); put alternatives inside one pattern: message://(upload|download) of \\d+ bytes failed// + - For an exact set of values, keep the list: severity:[error,fatal], not severity://error|fatal// +- NEVER quote a regex: message:"//...//" is a literal string match, not a regex +- The pattern ends at the first // followed by a space, ) or the end of the query. Spaces, parentheses, and a // followed by anything else (https?://host) are fine unquoted; write \\/\\/ for a literal // followed by a space or ) - Regex uses RE2 syntax (no lookarounds or backreferences), matches anywhere unless anchored, and is case sensitive; prefix the pattern with (?i) to ignore case -- Patterns are limited to 64 characters (an escape like \\d counts as one). Write \\/\\/ to match a literal // +- Patterns are limited to 64 characters (an escape like \\d counts as one) - Regex only works on string attributes, and only in the logs dataset; other datasets treat //...// as a literal value MATHEMATICAL QUERY PATTERNS: @@ -742,18 +745,18 @@ export const DATASET_EXAMPLES: Record< }, { description: - "error logs whose message starts with ConnectionReset or ReadTimeout", + "error logs whose message contains a hex trace id like 'trace=4bf92f3577b34da6a3ce929d0e0e4736'", output: { - query: "severity:error AND message://^(ConnectionReset|ReadTimeout)//", + query: "severity:error AND message://trace=[0-9a-f]{32}//", fields: ["timestamp", "message", "severity", "trace"], sort: "-timestamp", }, }, { description: - "logs excluding cache hit messages like 'cache hit for key user:42'", + "logs excluding heartbeat messages like 'heartbeat seq=1042'", output: { - query: "!message://^cache hit for key \\S+//", + query: "!message://^heartbeat seq=\\d+$//", fields: ["timestamp", "message", "severity", "trace"], sort: "-timestamp", }, From c6fc093c89ff829b3132d56cad5132219efeb9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 15:01:33 -0400 Subject: [PATCH 03/16] fix(search-events): Point the logs agent at attributes before message regex A value with its own attribute, like a response status, should filter that attribute rather than a regex over message text. Refs LOGS-1014 Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/tools/support/search-events/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mcp-core/src/tools/support/search-events/config.ts b/packages/mcp-core/src/tools/support/search-events/config.ts index 659fc24f3..c06f04d93 100644 --- a/packages/mcp-core/src/tools/support/search-events/config.ts +++ b/packages/mcp-core/src/tools/support/search-events/config.ts @@ -130,6 +130,7 @@ LOGS TEXT MATCHING (LOGS DATASET ONLY): - Negate with a leading !: !message://worker \\d+ ready// - There is no regex list form (key:[//a//,//b//] is a literal list); put alternatives inside one pattern: message://(upload|download) of \\d+ bytes failed// - For an exact set of values, keep the list: severity:[error,fatal], not severity://error|fatal// +- Regex on message is for text inside the message. When the user asks about a value that has its own attribute (e.g. response status), filter that attribute instead - NEVER quote a regex: message:"//...//" is a literal string match, not a regex - The pattern ends at the first // followed by a space, ) or the end of the query. Spaces, parentheses, and a // followed by anything else (https?://host) are fine unquoted; write \\/\\/ for a literal // followed by a space or ) - Regex uses RE2 syntax (no lookarounds or backreferences), matches anywhere unless anchored, and is case sensitive; prefix the pattern with (?i) to ignore case From 37ffbe21a96dbff06abc5b9b67f460882dd4981d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 15:22:36 -0400 Subject: [PATCH 04/16] fix(search-events): Teach regex for ignoring case and drop the trace-id example Wildcards are case sensitive, so ignoring case is a regex trigger; the trace id few-shot is replaced since trace has its own attribute, and array attributes are noted as regex-capable. Refs LOGS-1014 Co-Authored-By: Claude Opus 5.5 --- docs/contributing/search-events-api-patterns.md | 2 +- .../mcp-core/src/tools/support/search-events/config.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/contributing/search-events-api-patterns.md b/docs/contributing/search-events-api-patterns.md index 5fe627927..ab98803c5 100644 --- a/docs/contributing/search-events-api-patterns.md +++ b/docs/contributing/search-events-api-patterns.md @@ -114,7 +114,7 @@ https://us.sentry.io/api/0/organizations/sentry/events/?dataset=spans&field=ai.m - Does NOT support timestamp filters in query (use `statsPeriod` instead) - Severity levels: fatal, error, warning, info, debug, trace - Common aggregate functions: `count()`, `epm()` -- Regex filters on string attributes: `message://timeout after \d+ms//` (RE2, unquoted, max 64 characters); prefer wildcards for plain substrings, prefixes, and suffixes +- Regex filters on string and array attributes: `message://timeout after \d+ms//` (RE2, unquoted, max 64 characters); prefer wildcards for plain substrings, prefixes, and suffixes #### Metrics Dataset - Represents newer span metrics, including counters, gauges, and distributions diff --git a/packages/mcp-core/src/tools/support/search-events/config.ts b/packages/mcp-core/src/tools/support/search-events/config.ts index c06f04d93..6615c242d 100644 --- a/packages/mcp-core/src/tools/support/search-events/config.ts +++ b/packages/mcp-core/src/tools/support/search-events/config.ts @@ -125,7 +125,7 @@ REPLAY SEARCH RULES: LOGS TEXT MATCHING (LOGS DATASET ONLY): - Plain word, phrase, prefix, or suffix: use wildcards. message:"*database*" contains, message:"database*" starts with, message:"*database" ends with. Do NOT use a regex when a wildcard is enough - Any of several plain words or phrases: use a wildcard list, e.g. message:["*ConnectionReset*","*ReadTimeout*"] -- Use a regex filter key://pattern// when wildcards cannot express the request: number shapes (\\d+), character classes ([0-9a-f]), or structured values like IPs, UUIDs, and status codes. Use alternation (a|b) and anchors (^ or $) only alongside one of these +- Use a regex filter key://pattern// when wildcards cannot express the request: number shapes (\\d+), character classes ([0-9a-f]), structured values like IPs, UUIDs, and status codes, or ignoring case (wildcards are case sensitive). Use alternation (a|b) and anchors (^ or $) only alongside one of these - Example: message://request took \\d+ms// - Negate with a leading !: !message://worker \\d+ ready// - There is no regex list form (key:[//a//,//b//] is a literal list); put alternatives inside one pattern: message://(upload|download) of \\d+ bytes failed// @@ -135,7 +135,7 @@ LOGS TEXT MATCHING (LOGS DATASET ONLY): - The pattern ends at the first // followed by a space, ) or the end of the query. Spaces, parentheses, and a // followed by anything else (https?://host) are fine unquoted; write \\/\\/ for a literal // followed by a space or ) - Regex uses RE2 syntax (no lookarounds or backreferences), matches anywhere unless anchored, and is case sensitive; prefix the pattern with (?i) to ignore case - Patterns are limited to 64 characters (an escape like \\d counts as one) -- Regex only works on string attributes, and only in the logs dataset; other datasets treat //...// as a literal value +- Regex only works on string and array attributes, and only in the logs dataset; other datasets treat //...// as a literal value MATHEMATICAL QUERY PATTERNS: When user asks mathematical questions like "how many X", "total Y used", "sum of Z": @@ -746,9 +746,9 @@ export const DATASET_EXAMPLES: Record< }, { description: - "error logs whose message contains a hex trace id like 'trace=4bf92f3577b34da6a3ce929d0e0e4736'", + "error logs whose message contains a hex error code like 'err=0x8007000e'", output: { - query: "severity:error AND message://trace=[0-9a-f]{32}//", + query: "severity:error AND message://err=0x[0-9a-f]+//", fields: ["timestamp", "message", "severity", "trace"], sort: "-timestamp", }, From ef38959f6b93c857072138658433cf5cf11d1f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 15:35:13 -0400 Subject: [PATCH 05/16] fix(search-events): Say regex works on string attributes only Array regex needs the tags[name,array][*] key form, so naming arrays without it invites a plain key regex that silently matches nothing. Refs LOGS-1014 Co-Authored-By: Claude Opus 5.5 --- docs/contributing/search-events-api-patterns.md | 2 +- packages/mcp-core/src/tools/support/search-events/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contributing/search-events-api-patterns.md b/docs/contributing/search-events-api-patterns.md index ab98803c5..5fe627927 100644 --- a/docs/contributing/search-events-api-patterns.md +++ b/docs/contributing/search-events-api-patterns.md @@ -114,7 +114,7 @@ https://us.sentry.io/api/0/organizations/sentry/events/?dataset=spans&field=ai.m - Does NOT support timestamp filters in query (use `statsPeriod` instead) - Severity levels: fatal, error, warning, info, debug, trace - Common aggregate functions: `count()`, `epm()` -- Regex filters on string and array attributes: `message://timeout after \d+ms//` (RE2, unquoted, max 64 characters); prefer wildcards for plain substrings, prefixes, and suffixes +- Regex filters on string attributes: `message://timeout after \d+ms//` (RE2, unquoted, max 64 characters); prefer wildcards for plain substrings, prefixes, and suffixes #### Metrics Dataset - Represents newer span metrics, including counters, gauges, and distributions diff --git a/packages/mcp-core/src/tools/support/search-events/config.ts b/packages/mcp-core/src/tools/support/search-events/config.ts index 6615c242d..e67923bf7 100644 --- a/packages/mcp-core/src/tools/support/search-events/config.ts +++ b/packages/mcp-core/src/tools/support/search-events/config.ts @@ -135,7 +135,7 @@ LOGS TEXT MATCHING (LOGS DATASET ONLY): - The pattern ends at the first // followed by a space, ) or the end of the query. Spaces, parentheses, and a // followed by anything else (https?://host) are fine unquoted; write \\/\\/ for a literal // followed by a space or ) - Regex uses RE2 syntax (no lookarounds or backreferences), matches anywhere unless anchored, and is case sensitive; prefix the pattern with (?i) to ignore case - Patterns are limited to 64 characters (an escape like \\d counts as one) -- Regex only works on string and array attributes, and only in the logs dataset; other datasets treat //...// as a literal value +- Regex only works on string attributes, and only in the logs dataset; other datasets treat //...// as a literal value MATHEMATICAL QUERY PATTERNS: When user asks mathematical questions like "how many X", "total Y used", "sum of Z": From 5138386af0c4de5f76d00abbd0341d3107b1b255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 15:38:22 -0400 Subject: [PATCH 06/16] fix(search-events): Ask for short regex patterns near the 64-character limit An unneeded \b or (?i) pushed a UUID pattern past Sentry's limit in an eval run, so the length rule now says to leave them out unless needed. Refs LOGS-1014 Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/tools/support/search-events/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-core/src/tools/support/search-events/config.ts b/packages/mcp-core/src/tools/support/search-events/config.ts index e67923bf7..be809795f 100644 --- a/packages/mcp-core/src/tools/support/search-events/config.ts +++ b/packages/mcp-core/src/tools/support/search-events/config.ts @@ -134,7 +134,7 @@ LOGS TEXT MATCHING (LOGS DATASET ONLY): - NEVER quote a regex: message:"//...//" is a literal string match, not a regex - The pattern ends at the first // followed by a space, ) or the end of the query. Spaces, parentheses, and a // followed by anything else (https?://host) are fine unquoted; write \\/\\/ for a literal // followed by a space or ) - Regex uses RE2 syntax (no lookarounds or backreferences), matches anywhere unless anchored, and is case sensitive; prefix the pattern with (?i) to ignore case -- Patterns are limited to 64 characters (an escape like \\d counts as one) +- Patterns are limited to 64 characters (an escape like \\d counts as one), so leave out \\b and (?i) unless the request needs them - Regex only works on string attributes, and only in the logs dataset; other datasets treat //...// as a literal value MATHEMATICAL QUERY PATTERNS: From b3ce40205dbeac6c2e6b5fbed96eb03fe7eca6f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 16:36:32 -0400 Subject: [PATCH 07/16] fix(search-events): Forbid regex filters outside the logs dataset The agent still wrote key://pattern// for errors in some eval runs, so the rule now names the other datasets and says to use wildcards there. Refs LOGS-1014 Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/tools/support/search-events/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-core/src/tools/support/search-events/config.ts b/packages/mcp-core/src/tools/support/search-events/config.ts index be809795f..b411aa0bb 100644 --- a/packages/mcp-core/src/tools/support/search-events/config.ts +++ b/packages/mcp-core/src/tools/support/search-events/config.ts @@ -135,7 +135,7 @@ LOGS TEXT MATCHING (LOGS DATASET ONLY): - The pattern ends at the first // followed by a space, ) or the end of the query. Spaces, parentheses, and a // followed by anything else (https?://host) are fine unquoted; write \\/\\/ for a literal // followed by a space or ) - Regex uses RE2 syntax (no lookarounds or backreferences), matches anywhere unless anchored, and is case sensitive; prefix the pattern with (?i) to ignore case - Patterns are limited to 64 characters (an escape like \\d counts as one), so leave out \\b and (?i) unless the request needs them -- Regex only works on string attributes, and only in the logs dataset; other datasets treat //...// as a literal value +- Regex only works on string attributes in the logs dataset. NEVER write key://pattern// for errors, spans, metrics, or any other dataset: they match //...// literally and return nothing. Approximate the shape with wildcards there, e.g. message:"*retry*failed*" MATHEMATICAL QUERY PATTERNS: When user asks mathematical questions like "how many X", "total Y used", "sum of Z": From ea918c5be75799c4dcda91e81168596bdbee8965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 17:03:56 -0400 Subject: [PATCH 08/16] fix(search-events): Prefer (?i) over mixed-case classes to fit the regex limit An eval run spelled a UUID with [0-9a-fA-F], landing at 75 characters, so the length rule now says to match either case with (?i) and a lowercase class, which fits a UUID in 64. Refs LOGS-1014 Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/tools/support/search-events/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-core/src/tools/support/search-events/config.ts b/packages/mcp-core/src/tools/support/search-events/config.ts index b411aa0bb..3c344263c 100644 --- a/packages/mcp-core/src/tools/support/search-events/config.ts +++ b/packages/mcp-core/src/tools/support/search-events/config.ts @@ -134,7 +134,7 @@ LOGS TEXT MATCHING (LOGS DATASET ONLY): - NEVER quote a regex: message:"//...//" is a literal string match, not a regex - The pattern ends at the first // followed by a space, ) or the end of the query. Spaces, parentheses, and a // followed by anything else (https?://host) are fine unquoted; write \\/\\/ for a literal // followed by a space or ) - Regex uses RE2 syntax (no lookarounds or backreferences), matches anywhere unless anchored, and is case sensitive; prefix the pattern with (?i) to ignore case -- Patterns are limited to 64 characters (an escape like \\d counts as one), so leave out \\b and (?i) unless the request needs them +- Patterns are limited to 64 characters (an escape like \\d counts as one), so keep them short: leave out \\b unless the request needs it, and match either case with (?i)[0-9a-f] rather than [0-9a-fA-F] - Regex only works on string attributes in the logs dataset. NEVER write key://pattern// for errors, spans, metrics, or any other dataset: they match //...// literally and return nothing. Approximate the shape with wildcards there, e.g. message:"*retry*failed*" MATHEMATICAL QUERY PATTERNS: From c2b44ae3c1db39f56b5fb3fb0fcf9258ba2976be 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 09/16] 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 2a8a8e62d..ad3b73447 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 76a29a173..e686c09ba 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -7832,7 +7832,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 ab030f013..82b54f767 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3260,6 +3260,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; @@ -4163,6 +4254,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 508dd4513..fbde27dde 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 4d1aaf4d391f9406fbd6b3fb40b3410a0e39260f 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 10/16] 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 82b54f767..33766f851 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3349,6 +3349,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 fbde27dde..49931d663 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 ef63fa33dc8e8512d8158bf020bb798529a20b16 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 11/16] 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 ceee205806532282027d56b895f88a6dd6a1625a 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 12/16] 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 b08ce67065320bd3d9c46189af5c64792c07056e 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 13/16] 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 4788b82f5f6fb803985ab10855258716b997686b 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 14/16] 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 d85afd6a9cbee0d302ba69858d6c397847877b81 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 15/16] 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}$`, From e6779a39841d2129b542877f640ef92516b638bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 13 Oct 2026 12:05:54 -0400 Subject: [PATCH 16/16] fix(search): Catch regex downgrades on every dataset and in any case Structured filters rewritten into key://pattern// were missed outside logs, and when the pattern's casing differed from the original value. Co-Authored-By: Claude Opus 5.5 --- .../tools/support/search-events/utils.test.ts | 38 +++++++++++-- .../src/tools/support/search-events/utils.ts | 56 +++++++++---------- 2 files changed, 60 insertions(+), 34 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 d23ccc086..23ee77616 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 @@ -599,14 +599,12 @@ describe("search query helpers", () => { isSemanticFilterDowngrade( "http.status_code:500", "message://status 500//", - "logs", ), ).toBe(true); expect( isSemanticFilterDowngrade( "user.id:42 severity:error", String.raw`severity:error message://(?i)user 42 failed//`, - "logs", ), ).toBe(true); @@ -614,13 +612,12 @@ describe("search query helpers", () => { isSemanticFilterDowngrade( "http.status_code:500", String.raw`http.status_code:500 message://took \d+ms//`, - "logs", ), ).toBe(false); // A key:value inside a regex is pattern text, not a kept structured filter. expect( - isSemanticFilterDowngrade("custom:foo", "message://custom:foo//", "logs"), + isSemanticFilterDowngrade("custom:foo", "message://custom:foo//"), ).toBe(true); // A quote inside a regex must not hide the filters after it. @@ -628,6 +625,39 @@ describe("search query helpers", () => { isSemanticFilterDowngrade( "custom:foo", String.raw`message://it's \d+// message:"*foo*"`, + ), + ).toBe(true); + }); + + it("detects regex downgrades of structured filters on datasets other than logs", () => { + expect( + isSemanticFilterDowngrade( + "http.status_code:500", + "message://status 500//", + "errors", + ), + ).toBe(true); + expect( + isSemanticFilterDowngrade( + "span.op:db", + "span.description://db query//", + "spans", + ), + ).toBe(false); + }); + + it("detects message regex downgrades when the value casing differs", () => { + expect( + isSemanticFilterDowngrade( + "transaction:CheckoutFlow", + "message://(?i)CheckoutFlow//", + "logs", + ), + ).toBe(true); + expect( + isSemanticFilterDowngrade( + "user.email:Foo@example.com", + "message://Foo@example.com// severity:error", "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 ddabc05e3..677c406c1 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -284,14 +284,13 @@ function readRawFilterValue( * boundaries. Values are normalized for comparison (strip wrapping quotes and * leading/trailing wildcards). */ -function searchFilterOccurrences( - query: string, - dataset?: SearchSyntaxDataset, -): SearchFilterOccurrence[] { +function searchFilterOccurrences(query: string): SearchFilterOccurrence[] { const occurrences: SearchFilterOccurrence[] = []; const masked = maskQuotedRegions(query); - for (const match of masked.matchAll(searchTokenPattern(dataset))) { + // Read key://pattern// on every dataset: outside logs it is a literal match, + // so a structured filter rewritten into one is still a downgrade. + for (const match of masked.matchAll(SENTRY_SEARCH_TOKEN_WITH_REGEX_PATTERN)) { const key = (match.groups?.regexKey ?? match.groups?.key)?.toLowerCase(); if (!key || match.index === undefined) { continue; @@ -326,20 +325,14 @@ function searchFilterOccurrences( return occurrences; } -function structuredFilterOccurrences( - query: string, - dataset?: SearchSyntaxDataset, -): SearchFilterOccurrence[] { - return searchFilterOccurrences(query, dataset).filter( +function structuredFilterOccurrences(query: string): SearchFilterOccurrence[] { + return searchFilterOccurrences(query).filter( (occurrence) => !FULL_TEXT_SEARCH_KEYS.has(occurrence.key), ); } -function fullTextFilterValues( - query: string, - dataset?: SearchSyntaxDataset, -): string[] { - return searchFilterOccurrences(query, dataset) +function fullTextFilterValues(query: string): string[] { + return searchFilterOccurrences(query) .filter((occurrence) => FULL_TEXT_SEARCH_KEYS.has(occurrence.key)) .map((occurrence) => occurrence.value); } @@ -373,7 +366,12 @@ function containsAsWholeToken(haystack: string, needle: string): boolean { } function isRelatedFilterValue(left: string, right: string): boolean { - return containsAsWholeToken(left, right) || containsAsWholeToken(right, left); + const lowerLeft = left.toLowerCase(); + const lowerRight = right.toLowerCase(); + return ( + containsAsWholeToken(lowerLeft, lowerRight) || + containsAsWholeToken(lowerRight, lowerLeft) + ); } /** @@ -407,17 +405,15 @@ function unmatchedStructuredFilters( function isRegexFilterDowngrade( originalQuery: string, repairedQuery: string, - dataset?: SearchSyntaxDataset, ): boolean { - const originalRegexFilters = searchFilterOccurrences( - originalQuery, - dataset, - ).filter((occurrence) => occurrence.regex); + const originalRegexFilters = searchFilterOccurrences(originalQuery).filter( + (occurrence) => occurrence.regex, + ); if (originalRegexFilters.length === 0) { return false; } - const repairedFilters = searchFilterOccurrences(repairedQuery, dataset); + const repairedFilters = searchFilterOccurrences(repairedQuery); const maskedRepairedQuery = maskQuotedRegions(repairedQuery); return unmatchedStructuredFilters(originalRegexFilters, repairedFilters).some( (filter) => @@ -429,10 +425,7 @@ function isRegexFilterDowngrade( (repaired) => repaired.key === filter.key || (FULL_TEXT_SEARCH_KEYS.has(repaired.key) && - isRelatedFilterValue( - filter.value.toLowerCase(), - repaired.value.toLowerCase(), - )), + isRelatedFilterValue(filter.value, repaired.value)), ), ); } @@ -456,16 +449,19 @@ export function isSemanticFilterDowngrade( return false; } - if (isRegexFilterDowngrade(originalQuery, repairedQuery, dataset)) { + if ( + dataset === "logs" && + isRegexFilterDowngrade(originalQuery, repairedQuery) + ) { return true; } - const originalFilters = structuredFilterOccurrences(originalQuery, dataset); + const originalFilters = structuredFilterOccurrences(originalQuery); if (originalFilters.length === 0) { return false; } - const repairedFilters = structuredFilterOccurrences(repairedQuery, dataset); + const repairedFilters = structuredFilterOccurrences(repairedQuery); const droppedFilters = unmatchedStructuredFilters( originalFilters, repairedFilters, @@ -474,7 +470,7 @@ export function isSemanticFilterDowngrade( return false; } - const repairedFullTextValues = fullTextFilterValues(repairedQuery, dataset); + const repairedFullTextValues = fullTextFilterValues(repairedQuery); if (repairedFullTextValues.length === 0) { // Renames keep values on non-full-text attributes and do not need this guard. return false;