Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/cli-docs/src/content/docs/agent-guidance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 `<org>/<project>` 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.
Expand Down
3 changes: 3 additions & 0 deletions apps/cli-docs/src/fragments/commands/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 `<org>/<project>` 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/script/generate-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -96,6 +103,7 @@ export type ParenGroup = {
export type SearchNode =
| TextFilter
| TextInFilter
| RegexFilter
| ComparisonFilter
| FreeText
| BooleanOp
Expand Down
31 changes: 27 additions & 4 deletions packages/cli/script/search-query.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)* _ {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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)* _ "]" {
Expand Down
43 changes: 27 additions & 16 deletions packages/cli/src/lib/search-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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' +
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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:<digits>` / `project:[digits,…]` to `project_id`.
Expand All @@ -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.
Expand All @@ -613,37 +627,34 @@ 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);
}

const parts: string[] = [];
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)));
}
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/test/lib/search-query.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading