From f440401c242e68c0b9da7b7f887b792925e75776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 10:56:51 -0400 Subject: [PATCH 1/5] fix(cli): Keep regex log queries intact in --query Parse `key://pattern//` (and `!key://pattern//`) as a single opaque filter, ending at the first `//` followed by whitespace, `)`, or end of query, so sanitizeQuery no longer rewrites commas, brackets, `OR`/`AND`, or repeated whitespace inside a pattern. Document regex search in the syntax reference and the CLI skill docs for logs. Refs LOGS-1031 Co-Authored-By: Claude Opus 5.5 --- .../src/content/docs/agent-guidance.md | 4 + apps/cli-docs/src/fragments/commands/log.md | 3 + .../sentry-cli/skills/sentry-cli/SKILL.md | 4 + .../skills/sentry-cli/references/log.md | 3 + packages/cli/script/generate-parser.ts | 8 ++ packages/cli/script/search-query.pegjs | 14 +++- packages/cli/src/lib/search-query.ts | 40 +++++---- .../test/lib/search-query.property.test.ts | 21 +++++ packages/cli/test/lib/search-query.test.ts | 84 +++++++++++++++++++ 9 files changed, 163 insertions(+), 18 deletions(-) diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index d9b4eb20e..02d63d4a9 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -116,6 +116,9 @@ sentry log list --follow # Filter logs by severity sentry log list --query "severity:error" + +# Match a regular expression on a string attribute (logs only) +sentry log list --query 'message://^Timeout after \d+ms//' ``` ### Capture Events Locally (Spotlight) @@ -327,6 +330,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value - **Specifying org/project when not needed**: Auto-detection resolves org/project from `.sentryclirc` config files, DSNs, env vars, and directory names. Let it work first — only add `/` if the CLI says it can't detect the target or detects the wrong one. - **Manually discovering the project before running a command**: Don't list the orgs you belong to, then list every project in each, to match the local checkout to a project — the CLI already does exactly this resolution internally on each command. Skip the fan-out and run the command directly; correct the target afterwards only if the output shows the wrong org/project. - **Confusing `--query` syntax**: The `--query` flag uses Sentry search syntax (e.g., `is:unresolved`, `assigned:me`), not free text search. +- **Quoting a log regex**: Log search supports regular expressions as `key://pattern//` (RE2, case-sensitive, `(?i)` to ignore case, `!key://pattern//` to negate). Don't wrap it in quotes — `message:"//...//"` is a literal match. Prefer `*wildcards*` for plain substrings. - **Not using `--web`**: View commands support `-w`/`--web` to open the resource in the browser — useful for sharing links. - **Fetching API schemas instead of using the CLI**: Prefer `sentry schema` to browse the API and `sentry api` to make requests — the CLI handles authentication and endpoint resolution, so there's rarely a need to download OpenAPI specs separately. - **Fetching Sentry docs externally**: Use `sentry docs "your question"` to query Sentry's documentation from the CLI — this returns concise answers with source links, without needing to fetch or parse documentation pages. diff --git a/apps/cli-docs/src/fragments/commands/log.md b/apps/cli-docs/src/fragments/commands/log.md index 0159faa3f..cf297daa3 100644 --- a/apps/cli-docs/src/fragments/commands/log.md +++ b/apps/cli-docs/src/fragments/commands/log.md @@ -27,6 +27,9 @@ sentry log list -q 'severity:error' # Filter by message content sentry log list -q 'database' +# Match a regular expression (key://pattern//) +sentry log list -q 'message://^Timeout after \d+ms//' + # Limit results sentry log list --limit 50 ``` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 30fceeb72..bf6a8418a 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -128,6 +128,9 @@ sentry log list --follow # Filter logs by severity sentry log list --query "severity:error" + +# Match a regular expression on a string attribute (logs only) +sentry log list --query 'message://^Timeout after \d+ms//' ``` #### Capture Events Locally (Spotlight) @@ -339,6 +342,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value - **Specifying org/project when not needed**: Auto-detection resolves org/project from `.sentryclirc` config files, DSNs, env vars, and directory names. Let it work first — only add `/` if the CLI says it can't detect the target or detects the wrong one. - **Manually discovering the project before running a command**: Don't list the orgs you belong to, then list every project in each, to match the local checkout to a project — the CLI already does exactly this resolution internally on each command. Skip the fan-out and run the command directly; correct the target afterwards only if the output shows the wrong org/project. - **Confusing `--query` syntax**: The `--query` flag uses Sentry search syntax (e.g., `is:unresolved`, `assigned:me`), not free text search. +- **Quoting a log regex**: Log search supports regular expressions as `key://pattern//` (RE2, case-sensitive, `(?i)` to ignore case, `!key://pattern//` to negate). Don't wrap it in quotes — `message:"//...//"` is a literal match. Prefer `*wildcards*` for plain substrings. - **Not using `--web`**: View commands support `-w`/`--web` to open the resource in the browser — useful for sharing links. - **Fetching API schemas instead of using the CLI**: Prefer `sentry schema` to browse the API and `sentry api` to make requests — the CLI handles authentication and endpoint resolution, so there's rarely a need to download OpenAPI specs separately. - **Fetching Sentry docs externally**: Use `sentry docs "your question"` to query Sentry's documentation from the CLI — this returns concise answers with source links, without needing to fetch or parse documentation pages. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md index aeb34e043..e5beb8a5c 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -46,6 +46,9 @@ sentry log list -q 'severity:error' # Filter by message content sentry log list -q 'database' +# Match a regular expression (key://pattern//) +sentry log list -q 'message://^Timeout after \d+ms//' + # Limit results sentry log list --limit 50 diff --git a/packages/cli/script/generate-parser.ts b/packages/cli/script/generate-parser.ts index 557c977c0..b68e1603c 100644 --- a/packages/cli/script/generate-parser.ts +++ b/packages/cli/script/generate-parser.ts @@ -63,6 +63,13 @@ export type TextInFilter = { values: string[]; }; +export type RegexFilter = { + type: "regex_filter"; + negated: boolean; + key: string; + value: string; +}; + /** A comparison filter: key:>value, key:<=value */ export type ComparisonFilter = { type: "comparison_filter"; @@ -96,6 +103,7 @@ export type ParenGroup = { export type SearchNode = | TextFilter | TextInFilter + | RegexFilter | ComparisonFilter | FreeText | BooleanOp diff --git a/packages/cli/script/search-query.pegjs b/packages/cli/script/search-query.pegjs index ed46794bd..9b6ac3d96 100644 --- a/packages/cli/script/search-query.pegjs +++ b/packages/cli/script/search-query.pegjs @@ -8,7 +8,7 @@ // - No TokenConverter or predicate-based filter type classification // - No aggregate key/function support (not needed for rewriting) // - No date/duration/size/percentage format parsing (classified as comparison) -// - Structural output only: text | text_in | comparison | free_text | boolean_op | paren_group +// - Structural output only: text | text_in | regex | comparison | free_text | boolean_op | paren_group search = _ head:term tail:(_ term)* _ { @@ -45,10 +45,13 @@ term_no_paren // Filters: key:value patterns // --------------------------------------------------------------------------- -// Order matters: try in-list first (starts with [), then comparison -// (starts with operator), then plain text (catch-all). +// Order matters: try regex first (starts with //), then in-list (starts +// with [), then comparison (starts with operator), then plain text (catch-all). filter - = negation:"!"? key:filter_key ":" value:in_list { + = negation:"!"? key:filter_key ":" value:regex_value { + return { type: "regex_filter", negated: !!negation, key: key, value: value }; + } + / negation:"!"? key:filter_key ":" value:in_list { return { type: "text_in_filter", negated: !!negation, key: key, values: value }; } / negation:"!"? key:filter_key ":" op:comparison_op value:filter_value { @@ -77,6 +80,9 @@ quoted_value plain_value = chars:[^ \t\n()\[\],]+ { return chars.join(""); } +regex_value + = $("//" (!("//" end_of_value) [^\n])+ "//" &end_of_value) + // In-list: [val1,val2,"val 3"] in_list = "[" _ head:in_list_item tail:(_ "," _ in_list_item)* _ "]" { diff --git a/packages/cli/src/lib/search-query.ts b/packages/cli/src/lib/search-query.ts index 6441df35f..26ff89ec3 100644 --- a/packages/cli/src/lib/search-query.ts +++ b/packages/cli/src/lib/search-query.ts @@ -63,6 +63,10 @@ function serializeNode(node: SearchNode): string { const prefix = node.negated ? "!" : ""; return `${prefix}${node.key}:[${node.values.join(",")}]`; } + case "regex_filter": { + const prefix = node.negated ? "!" : ""; + return `${prefix}${node.key}:${node.value}`; + } case "comparison_filter": { const prefix = node.negated ? "!" : ""; return `${prefix}${node.key}:${node.op}${node.value}`; @@ -460,6 +464,7 @@ function handleOr( " - is: or has: qualifiers (not supported with in-list)\n" + " - Negated qualifiers (!key:val1 OR !key:val2)\n" + " - Comparison values (age:>24h OR age:>7d)\n" + + " - Regex filters (use alternation instead: key://(a|b)//)\n" + " - Parenthesized groups\n\n" + "Alternatives:\n" + ' - Write in-list syntax directly: --query "key:[val1,val2]"\n' + @@ -497,6 +502,8 @@ export const SEARCH_SYNTAX_REFERENCE = { comparison: [">=", "<=", ">", "<", "=", "!="], wildcard: "* in values (e.g., message:*timeout*)", inList: "key:[val1,val2] — matches any value in the list", + regex: + "key://pattern// (logs only, string attributes) — RE2, unanchored, case-sensitive ((?i) to ignore case), max 64 chars, never quoted; ends at the first // followed by whitespace, ) or end of query; negate with !key://pattern//", }, filterTypes: [ "text (key:value)", @@ -556,12 +563,15 @@ const PROJECT_NUMERIC_RE = /(^|\s)(!?)project:(\d+)(?=\s|$)/gi; const PROJECT_NUMERIC_LIST_RE = /(^|\s)(!?)project:\[(\d+(?:\s*,\s*\d+)*)\]/gi; /** - * Pattern that splits a query into alternating unquoted / quoted segments. + * Pattern that splits a query into alternating unquoted / preserved segments. * - * Matches double-quoted strings (including escaped quotes inside them). - * Between matches is unquoted text that can be safely normalized. + * Matches double-quoted strings (including escaped quotes inside them) and + * `key://pattern//` regex values, which end at the first `//` followed by + * whitespace, `)`, or end of query. Between matches is unquoted text that + * can be safely normalized. */ -const QUOTED_SEGMENT_RE = /"(?:[^"\\]|\\.)*"/g; +const PRESERVED_SEGMENT_RE = + /"(?:[^"\\]|\\.)*"|(?<=(?:^|[\s(])!?[\w.[\]-]+:)\/\/(?:(?!\/\/(?:[\t\n )]|$))[^\n])+\/\/(?=[\t\n )]|$)/g; /** * Rewrite `project:` / `project:[digits,…]` to `project_id`. @@ -587,8 +597,9 @@ function rewriteNumericProjectFilters(query: string): string { * transform that fixes a common agent/user mistake. The pipeline is ordered * from most common to least common pattern. * - * Quoted regions (`"..."`) are preserved verbatim — only unquoted text is - * normalized. This prevents `message:"error [500,] found"` from being + * Quoted regions (`"..."`) and regex values (`key://pattern//`) are + * preserved verbatim — only the remaining text is normalized. This prevents + * `message:"error [500,] found"` and `message://[a-z,]+//` from being * corrupted. * * Returns the original query unchanged if no repairs were applicable. @@ -613,15 +624,16 @@ function normalizeQuery(query: string): string { /** * Apply a transform function only to the unquoted segments of a query. * - * Splits the query at double-quoted boundaries, applies `fn` to each - * unquoted segment, and re-assembles with the quoted segments untouched. + * Splits the query at double-quoted and regex-value boundaries, applies `fn` + * to each unquoted segment, and re-assembles with the preserved segments + * untouched. */ function transformUnquoted( query: string, fn: (unquoted: string) => string ): string { - // Fast path: no quotes → transform the whole string - if (!query.includes('"')) { + // Fast path: no quotes or regex values → transform the whole string + if (!(query.includes('"') || query.includes("//"))) { return fn(query); } @@ -629,18 +641,18 @@ function transformUnquoted( let lastIndex = 0; // Reset the regex state for each call (global regex) - QUOTED_SEGMENT_RE.lastIndex = 0; - let match = QUOTED_SEGMENT_RE.exec(query); + PRESERVED_SEGMENT_RE.lastIndex = 0; + let match = PRESERVED_SEGMENT_RE.exec(query); while (match !== null) { // Unquoted segment before this quoted match if (match.index > lastIndex) { parts.push(fn(query.slice(lastIndex, match.index))); } - // Quoted segment — preserved as-is + // Quoted or regex segment — preserved as-is parts.push(match[0]); lastIndex = match.index + match[0].length; - match = QUOTED_SEGMENT_RE.exec(query); + match = PRESERVED_SEGMENT_RE.exec(query); } // Trailing unquoted segment after last quote diff --git a/packages/cli/test/lib/search-query.property.test.ts b/packages/cli/test/lib/search-query.property.test.ts index 9b9c87632..21284213d 100644 --- a/packages/cli/test/lib/search-query.property.test.ts +++ b/packages/cli/test/lib/search-query.property.test.ts @@ -7,6 +7,7 @@ * - Different-key OR always throws * - AND stripping preserves all non-AND tokens * - Safe queries pass through unchanged + * - Regex filter values survive rewriting byte-for-byte */ import { constantFrom, assert as fcAssert, property, tuple } from "fast-check"; @@ -68,6 +69,17 @@ const safeTermArb = constantFrom( "android" ); +const regexFilterArb = constantFrom( + "message://[a-z,]+//", + "message://timeout OR refused//", + "message://read AND write//", + "message://a b//", + "!message://^GET \\d+ms//", + "message://(ConnectionReset|ReadTimeout)Error//", + "message://codes [401,403,)//", + "url://https://x//" +); + describe("property: sanitizeQuery", () => { test("same-key qualifier OR rewrites to valid in-list", () => { fcAssert( @@ -167,6 +179,15 @@ describe("property: sanitizeQuery", () => { ); }); + test("regex filters survive AND stripping byte-for-byte", () => { + fcAssert( + property(safeTermArb, regexFilterArb, (term, regex) => { + expect(sanitizeQuery(`${term} AND ${regex}`)).toBe(`${term} ${regex}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + test("safe queries pass through unchanged", () => { fcAssert( property(safeTermArb, (term) => { diff --git a/packages/cli/test/lib/search-query.test.ts b/packages/cli/test/lib/search-query.test.ts index 6bf9d7abf..afb78c608 100644 --- a/packages/cli/test/lib/search-query.test.ts +++ b/packages/cli/test/lib/search-query.test.ts @@ -395,6 +395,84 @@ describe("sanitizeQuery: edge cases", () => { }); }); +describe("sanitizeQuery: regex filters", () => { + test("passes through a character class containing a comma", () => { + expect(sanitizeQuery("message://[a-z,]+//")).toBe("message://[a-z,]+//"); + }); + + test("passes through a malformed-looking list inside a pattern", () => { + expect(sanitizeQuery("message://codes [401,403,)//")).toBe( + "message://codes [401,403,)//" + ); + }); + + test("does not treat OR inside a pattern as a boolean operator", () => { + expect(sanitizeQuery("message://timeout OR refused//")).toBe( + "message://timeout OR refused//" + ); + }); + + test("does not treat AND inside a pattern as a boolean operator", () => { + expect(sanitizeQuery("message://read AND write//")).toBe( + "message://read AND write//" + ); + }); + + test("preserves repeated whitespace inside a pattern", () => { + expect(sanitizeQuery("message://a b//")).toBe("message://a b//"); + }); + + test("passes through a negated regex filter", () => { + expect(sanitizeQuery("!message://^GET \\d+ms//")).toBe( + "!message://^GET \\d+ms//" + ); + }); + + test("passes through parentheses inside a pattern", () => { + expect( + sanitizeQuery("message://(ConnectionReset|ReadTimeout)Error//") + ).toBe("message://(ConnectionReset|ReadTimeout)Error//"); + }); + + test("passes through a double quote inside a pattern", () => { + expect(sanitizeQuery('message://say "hi"//')).toBe('message://say "hi"//'); + }); + + test("ends the pattern at the first // followed by whitespace", () => { + expect(sanitizeQuery("url://https://x// AND message://a OR b//")).toBe( + "url://https://x// message://a OR b//" + ); + }); + + test("strips AND outside a pattern but keeps the pattern intact", () => { + expect(sanitizeQuery("severity:error AND message://a OR [b,]//")).toBe( + "severity:error message://a OR [b,]//" + ); + }); + + test("rewrites OR outside a pattern but keeps the pattern intact", () => { + expect( + sanitizeQuery( + "severity:error OR severity:warning message://(x|y) z// !user.email://^a,b//" + ) + ).toBe( + "severity:[error,warning] message://(x|y) z// !user.email://^a,b//" + ); + }); + + test("does not rewrite numeric project: inside a pattern", () => { + expect(sanitizeQuery("message://project:123//")).toBe( + "message://project:123//" + ); + }); + + test("throws for OR between regex filters", () => { + expect(() => sanitizeQuery("message://a// OR message://b//")).toThrow( + ValidationError + ); + }); +}); + describe("normalizeQuery: pre-parse text normalization", () => { describe("mismatched brackets", () => { test("fixes wrong closing delimiter ) → ]", () => { @@ -480,6 +558,12 @@ describe("normalizeQuery: pre-parse text normalization", () => { ); }); + test("does not modify bracket content inside a regex value", () => { + expect(normalizeQuery("message://[a-z,]+// level:[a,]")).toBe( + "message://[a-z,]+// level:[a]" + ); + }); + test("handles escaped quotes inside quoted strings", () => { // The input has backslash-escaped quotes inside the quoted value: // message:"say \"hello [500,]\"" level:[a,] From 95975845dc94b16cebec36f3cdcaca1eb70b9d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 12:38:07 -0400 Subject: [PATCH 2/5] fix(cli): Keep regex values intact on typed, quoted, and array keys Sentry accepts tags[k,string], tags[k:sub], flags[...], quoted, and [*] keys before a regex value, so --query now preserves regexes on those keys instead of normalizing or rewriting inside the pattern. Refs LOGS-1031 Co-Authored-By: Claude Opus 5.5 --- packages/cli/script/search-query.pegjs | 19 +++++++++++- packages/cli/src/lib/search-query.ts | 7 +++-- packages/cli/test/lib/search-query.test.ts | 36 ++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/cli/script/search-query.pegjs b/packages/cli/script/search-query.pegjs index 9b6ac3d96..532eb4c10 100644 --- a/packages/cli/script/search-query.pegjs +++ b/packages/cli/script/search-query.pegjs @@ -48,7 +48,7 @@ term_no_paren // Order matters: try regex first (starts with //), then in-list (starts // with [), then comparison (starts with operator), then plain text (catch-all). filter - = negation:"!"? key:filter_key ":" value:regex_value { + = negation:"!"? key:regex_filter_key ":" value:regex_value { return { type: "regex_filter", negated: !!negation, key: key, value: value }; } / negation:"!"? key:filter_key ":" value:in_list { @@ -65,6 +65,23 @@ filter filter_key = chars:[a-zA-Z0-9_.\[\]-]+ { return chars.join(""); } +// Upstream accepts more key shapes before a regex value than filter_key does. +// Keep in sync with REGEX_FILTER_KEY_SOURCE in src/lib/search-query.ts. +regex_filter_key + = $(("tags" / "flags") "[" escaped_key (" "* "," " "* attribute_type)? "]" array_includes_suffix?) + / $('"' escaped_key '"' array_includes_suffix?) + / $([a-zA-Z0-9_.-]+ array_includes_suffix) + / filter_key + +escaped_key + = [a-zA-Z0-9_.:-]+ + +attribute_type + = "string" / "number" / "boolean" / "array" + +array_includes_suffix + = "[*]" + // --------------------------------------------------------------------------- // Values // --------------------------------------------------------------------------- diff --git a/packages/cli/src/lib/search-query.ts b/packages/cli/src/lib/search-query.ts index 26ff89ec3..54123b940 100644 --- a/packages/cli/src/lib/search-query.ts +++ b/packages/cli/src/lib/search-query.ts @@ -570,8 +570,11 @@ const PROJECT_NUMERIC_LIST_RE = /(^|\s)(!?)project:\[(\d+(?:\s*,\s*\d+)*)\]/gi; * whitespace, `)`, or end of query. Between matches is unquoted text that * can be safely normalized. */ -const PRESERVED_SEGMENT_RE = - /"(?:[^"\\]|\\.)*"|(?<=(?:^|[\s(])!?[\w.[\]-]+:)\/\/(?:(?!\/\/(?:[\t\n )]|$))[^\n])+\/\/(?=[\t\n )]|$)/g; +const REGEX_FILTER_KEY_SOURCE = String.raw`(?:(?:tags|flags)\[[\w.:-]+(?: *, *(?:string|number|boolean|array))?\]|"[\w.:-]+"|[\w.[\]-]+)(?:\[\*\])?`; +const PRESERVED_SEGMENT_RE = new RegExp( + String.raw`"(?:[^"\\]|\\.)*"|(?<=(?:^|[\s(])!?${REGEX_FILTER_KEY_SOURCE}:)\/\/(?:(?!\/\/(?:[\t\n )]|$))[^\n])+\/\/(?=[\t\n )]|$)`, + "g" +); /** * Rewrite `project:` / `project:[digits,…]` to `project_id`. diff --git a/packages/cli/test/lib/search-query.test.ts b/packages/cli/test/lib/search-query.test.ts index afb78c608..ec3bc2918 100644 --- a/packages/cli/test/lib/search-query.test.ts +++ b/packages/cli/test/lib/search-query.test.ts @@ -466,6 +466,42 @@ describe("sanitizeQuery: regex filters", () => { ); }); + test("passes through a regex on a typed tag key", () => { + expect(sanitizeQuery("tags[foo,string]://[a-z,]+ AND b//")).toBe( + "tags[foo,string]://[a-z,]+ AND b//" + ); + }); + + test("passes through a regex on a typed tag key with spaces", () => { + expect(sanitizeQuery("tags[foo, string]://a OR [b,]//")).toBe( + "tags[foo, string]://a OR [b,]//" + ); + }); + + test("passes through a regex on a tag key containing a colon", () => { + expect(sanitizeQuery("tags[sentry:user]://^id [0-9,]+//")).toBe( + "tags[sentry:user]://^id [0-9,]+//" + ); + }); + + test("passes through a regex on a typed flag key", () => { + expect(sanitizeQuery("flags[beta,string]://^on OR off$//")).toBe( + "flags[beta,string]://^on OR off$//" + ); + }); + + test("passes through a regex on a quoted key", () => { + expect(sanitizeQuery('"my.key"://a OR [b,]//')).toBe( + '"my.key"://a OR [b,]//' + ); + }); + + test("passes through a regex on array keys", () => { + expect( + sanitizeQuery("arr[*]://a OR [b,]// tags[k,array][*]://a OR b//") + ).toBe("arr[*]://a OR [b,]// tags[k,array][*]://a OR b//"); + }); + test("throws for OR between regex filters", () => { expect(() => sanitizeQuery("message://a// OR message://b//")).toThrow( ValidationError From 44f29854d91811c5ad8aab87b98e119fa4716ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 15:09:48 -0400 Subject: [PATCH 3/5] test(cli): Make the regex query tests fail without the fix Every regex test now includes something the old sanitizer rewrites, and the comments the regex change made stale or redundant are gone. Refs LOGS-1031 Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/lib/search-query.ts | 4 ---- packages/cli/test/lib/search-query.test.ts | 26 +++++++++++++--------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/lib/search-query.ts b/packages/cli/src/lib/search-query.ts index 54123b940..902a65991 100644 --- a/packages/cli/src/lib/search-query.ts +++ b/packages/cli/src/lib/search-query.ts @@ -635,7 +635,6 @@ function transformUnquoted( query: string, fn: (unquoted: string) => string ): string { - // Fast path: no quotes or regex values → transform the whole string if (!(query.includes('"') || query.includes("//"))) { return fn(query); } @@ -648,17 +647,14 @@ function transformUnquoted( let match = PRESERVED_SEGMENT_RE.exec(query); while (match !== null) { - // Unquoted segment before this quoted match if (match.index > lastIndex) { parts.push(fn(query.slice(lastIndex, match.index))); } - // Quoted or regex segment — preserved as-is parts.push(match[0]); lastIndex = match.index + match[0].length; match = PRESERVED_SEGMENT_RE.exec(query); } - // Trailing unquoted segment after last quote if (lastIndex < query.length) { parts.push(fn(query.slice(lastIndex))); } diff --git a/packages/cli/test/lib/search-query.test.ts b/packages/cli/test/lib/search-query.test.ts index ec3bc2918..3d577f6ca 100644 --- a/packages/cli/test/lib/search-query.test.ts +++ b/packages/cli/test/lib/search-query.test.ts @@ -401,8 +401,8 @@ describe("sanitizeQuery: regex filters", () => { }); test("passes through a malformed-looking list inside a pattern", () => { - expect(sanitizeQuery("message://codes [401,403,)//")).toBe( - "message://codes [401,403,)//" + expect(sanitizeQuery("message://codes [401,403,) seen//")).toBe( + "message://codes [401,403,) seen//" ); }); @@ -419,23 +419,27 @@ describe("sanitizeQuery: regex filters", () => { }); test("preserves repeated whitespace inside a pattern", () => { - expect(sanitizeQuery("message://a b//")).toBe("message://a b//"); + expect(sanitizeQuery("message://a AND b//")).toBe( + "message://a AND b//" + ); }); test("passes through a negated regex filter", () => { - expect(sanitizeQuery("!message://^GET \\d+ms//")).toBe( - "!message://^GET \\d+ms//" + expect(sanitizeQuery("!message://^GET OR POST \\d+ms//")).toBe( + "!message://^GET OR POST \\d+ms//" ); }); test("passes through parentheses inside a pattern", () => { - expect( - sanitizeQuery("message://(ConnectionReset|ReadTimeout)Error//") - ).toBe("message://(ConnectionReset|ReadTimeout)Error//"); + expect(sanitizeQuery("message://(reset|timeout) [0-9,]+//")).toBe( + "message://(reset|timeout) [0-9,]+//" + ); }); test("passes through a double quote inside a pattern", () => { - expect(sanitizeQuery('message://say "hi"//')).toBe('message://say "hi"//'); + expect(sanitizeQuery('message://say "hi" [a,]//')).toBe( + 'message://say "hi" [a,]//' + ); }); test("ends the pattern at the first // followed by whitespace", () => { @@ -461,8 +465,8 @@ describe("sanitizeQuery: regex filters", () => { }); test("does not rewrite numeric project: inside a pattern", () => { - expect(sanitizeQuery("message://project:123//")).toBe( - "message://project:123//" + expect(sanitizeQuery("message://in project:123 now//")).toBe( + "message://in project:123 now//" ); }); From c7078a9d0b4b3a9753bfa059fb501107811bb6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 15:27:12 -0400 Subject: [PATCH 4/5] fix(cli): Keep regex values intact after a closing paren or quote Upstream allows a filter to follow ) or a quote with no space, so the preserved-segment check now accepts those too. Refs LOGS-1031 Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/lib/search-query.ts | 4 ++-- packages/cli/test/lib/search-query.test.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/search-query.ts b/packages/cli/src/lib/search-query.ts index 902a65991..96be48dbc 100644 --- a/packages/cli/src/lib/search-query.ts +++ b/packages/cli/src/lib/search-query.ts @@ -503,7 +503,7 @@ export const SEARCH_SYNTAX_REFERENCE = { wildcard: "* in values (e.g., message:*timeout*)", inList: "key:[val1,val2] — matches any value in the list", regex: - "key://pattern// (logs only, string attributes) — RE2, unanchored, case-sensitive ((?i) to ignore case), max 64 chars, never quoted; ends at the first // followed by whitespace, ) or end of query; negate with !key://pattern//", + "key://pattern// (logs only, string and array attributes) — RE2, unanchored, case-sensitive ((?i) to ignore case), max 64 chars, never quoted; ends at the first // followed by whitespace, ) or end of query; negate with !key://pattern//", }, filterTypes: [ "text (key:value)", @@ -572,7 +572,7 @@ const PROJECT_NUMERIC_LIST_RE = /(^|\s)(!?)project:\[(\d+(?:\s*,\s*\d+)*)\]/gi; */ const REGEX_FILTER_KEY_SOURCE = String.raw`(?:(?:tags|flags)\[[\w.:-]+(?: *, *(?:string|number|boolean|array))?\]|"[\w.:-]+"|[\w.[\]-]+)(?:\[\*\])?`; const PRESERVED_SEGMENT_RE = new RegExp( - String.raw`"(?:[^"\\]|\\.)*"|(?<=(?:^|[\s(])!?${REGEX_FILTER_KEY_SOURCE}:)\/\/(?:(?!\/\/(?:[\t\n )]|$))[^\n])+\/\/(?=[\t\n )]|$)`, + String.raw`"(?:[^"\\]|\\.)*"|(?<=(?:^|[\s()"])!?${REGEX_FILTER_KEY_SOURCE}:)\/\/(?:(?!\/\/(?:[\t\n )]|$))[^\n])+\/\/(?=[\t\n )]|$)`, "g" ); diff --git a/packages/cli/test/lib/search-query.test.ts b/packages/cli/test/lib/search-query.test.ts index 3d577f6ca..7f889022b 100644 --- a/packages/cli/test/lib/search-query.test.ts +++ b/packages/cli/test/lib/search-query.test.ts @@ -506,6 +506,12 @@ describe("sanitizeQuery: regex filters", () => { ).toBe("arr[*]://a OR [b,]// tags[k,array][*]://a OR b//"); }); + test("passes through a regex right after a closing paren or quote", () => { + expect( + sanitizeQuery('(level:x)message://[a,]// "foo"message://[b,]//') + ).toBe('(level:x)message://[a,]// "foo"message://[b,]//'); + }); + test("throws for OR between regex filters", () => { expect(() => sanitizeQuery("message://a// OR message://b//")).toThrow( ValidationError From 714c712be0e5bc5d55a8a01fbc3392e792b8bc9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 5 Oct 2026 15:35:20 -0400 Subject: [PATCH 5/5] fix(cli): Say log regex works on string attributes only Array regex needs the tags[name,array][*] key form, so listing arrays without it would point agents at a regex that silently matches nothing. Refs LOGS-1031 Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/lib/search-query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/search-query.ts b/packages/cli/src/lib/search-query.ts index 96be48dbc..5788d92d0 100644 --- a/packages/cli/src/lib/search-query.ts +++ b/packages/cli/src/lib/search-query.ts @@ -503,7 +503,7 @@ export const SEARCH_SYNTAX_REFERENCE = { wildcard: "* in values (e.g., message:*timeout*)", inList: "key:[val1,val2] — matches any value in the list", regex: - "key://pattern// (logs only, string and array attributes) — RE2, unanchored, case-sensitive ((?i) to ignore case), max 64 chars, never quoted; ends at the first // followed by whitespace, ) or end of query; negate with !key://pattern//", + "key://pattern// (logs only, string attributes) — RE2, unanchored, case-sensitive ((?i) to ignore case), max 64 chars, never quoted; ends at the first // followed by whitespace, ) or end of query; negate with !key://pattern//", }, filterTypes: [ "text (key:value)",