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..532eb4c10 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:regex_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 { @@ -62,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 // --------------------------------------------------------------------------- @@ -77,6 +97,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..5788d92d0 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,18 @@ 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 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`. @@ -587,8 +600,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 +627,15 @@ 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('"')) { + if (!(query.includes('"') || query.includes("//"))) { return fn(query); } @@ -629,21 +643,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 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 if (lastIndex < query.length) { parts.push(fn(query.slice(lastIndex))); } 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..7f889022b 100644 --- a/packages/cli/test/lib/search-query.test.ts +++ b/packages/cli/test/lib/search-query.test.ts @@ -395,6 +395,130 @@ 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,) seen//")).toBe( + "message://codes [401,403,) seen//" + ); + }); + + 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 AND b//")).toBe( + "message://a AND b//" + ); + }); + + test("passes through a negated regex filter", () => { + 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://(reset|timeout) [0-9,]+//")).toBe( + "message://(reset|timeout) [0-9,]+//" + ); + }); + + test("passes through a double quote inside a pattern", () => { + expect(sanitizeQuery('message://say "hi" [a,]//')).toBe( + 'message://say "hi" [a,]//' + ); + }); + + 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://in project:123 now//")).toBe( + "message://in project:123 now//" + ); + }); + + 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("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 + ); + }); +}); + describe("normalizeQuery: pre-parse text normalization", () => { describe("mismatched brackets", () => { test("fixes wrong closing delimiter ) → ]", () => { @@ -480,6 +604,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,]