From 5f8bb6976f51068ca22cf490a961ed056740d560 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:38:00 +0000 Subject: [PATCH] fix(cli): Treat Events search syntax 400s as --query mistakes The Events endpoint returns event_search.py parser failures unwrapped ("Invalid quote at ...", "Parse error at ...", '"is:" queries are not supported ...', "timestamp: Invalid date: ..."). Only the issue-search wrapper "Error parsing search query" was recognized, so a typo in a user's explore/span/log/trace --query surfaced as a raw 400 and was reported to Sentry as a CLI bug (CLI-10). Recognize the grammar-level parser messages too, so toSearchQueryError turns them into a query ValidationError when the user supplied --query. Co-Authored-By: Miguel Betegon --- packages/cli/src/lib/errors.ts | 41 +++++++++++++++++----- packages/cli/test/commands/explore.test.ts | 41 ++++++++++++++++++++++ packages/cli/test/lib/errors.test.ts | 25 +++++++++++++ 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 909286f91..e00f2ef64 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -862,28 +862,53 @@ export function getExitCode(error: unknown): number { } /** - * Sentry's search-query parser emits a 400 whose detail begins with this phrase - * when the user's `--query` cannot be parsed (e.g. `is:403`, `lastSeen:>=-24h`, - * an empty `status:`). The server is telling us the user-supplied query is - * malformed — a user input error, not the CLI constructing a bad request. + * Issue search wraps search-query parser failures in this phrase + * (e.g. `is:403`, `lastSeen:>=-24h`, an empty `status:`). The server is telling + * us the user-supplied query is malformed — a user input error, not the CLI + * constructing a bad request. */ const SEARCH_QUERY_PARSE_MARKER = "Error parsing search query"; +/** + * The Events endpoint (explore, spans, logs, traces) returns the same parser + * failures unwrapped, as raised by `sentry/api/event_search.py`. Only + * grammar-level messages are listed: they describe text that could not be + * parsed, which the CLI's own `key:value` scope terms never produce. + */ +const EVENTS_SEARCH_PARSE_ERRORS: readonly RegExp[] = [ + /^Parse error at '/, + /^Invalid quote at '/, + /^Search terms cannot end with an escape character/, + /^Unexpected escape character: /, + /^Lists should not have empty values/, + /^Empty string after '/, + /^"is:" queries are not supported in this search/, + /^Invalid value for "is" search/, + /^"in" syntax invalid for "is" search/, + /^Boolean statements containing "OR" or "AND" are not supported/, + /^Invalid format for "has" search/, + /^\S+: Invalid (?:date|boolean|number): /, +]; + /** * Whether an {@link ApiError} is a Sentry search-query parse failure (HTTP 400 * whose detail reports an unparseable query). The CLI preserves the server's * detail when re-wrapping list errors (`enrichIssueListError` in - * `commands/issue/list.ts` prepends the original detail), so a substring match - * is robust to that wrapping. + * `commands/issue/list.ts` prepends the original detail), so the issue-search + * marker is matched as a substring. * * Shared by the three 4xx classifiers so they agree on these 400s: * `classifySilenced` (Sentry capture), {@link isUserError} (upgrade nudge), and * `isUserApiError` (span attribution). */ export function isSearchQueryParseError(error: ApiError): boolean { + const detail = error.detail; + if (error.status !== 400 || !detail) { + return false; + } return ( - error.status === 400 && - (error.detail?.includes(SEARCH_QUERY_PARSE_MARKER) ?? false) + detail.includes(SEARCH_QUERY_PARSE_MARKER) || + EVENTS_SEARCH_PARSE_ERRORS.some((pattern) => pattern.test(detail)) ); } diff --git a/packages/cli/test/commands/explore.test.ts b/packages/cli/test/commands/explore.test.ts index a742a83f1..e4c887c92 100644 --- a/packages/cli/test/commands/explore.test.ts +++ b/packages/cli/test/commands/explore.test.ts @@ -1348,6 +1348,47 @@ describe("sentry explore", () => { }); }); + describe("search syntax errors", () => { + const detail = + "Invalid quote at '*\"POST': quotes must enclose text or be escaped."; + + test("reports an unparseable --query as a query ValidationError", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + queryEventsSpy.mockRejectedValue( + new ApiError("Bad request", 400, detail), + ); + const { context } = createContext(); + + const error = await func + .call( + context, + { + ...DEFAULT_FLAGS, + dataset: "spans", + query: 'span.description:*"POST', + }, + "test-org/", + ) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ValidationError); + expect(error).toMatchObject({ field: "query" }); + expect((error as Error).message).toContain(detail); + expect((error as Error).message).toContain("Check your --query syntax"); + }); + + test("keeps the ApiError when the CLI built the query", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const original = new ApiError("Bad request", 400, detail); + queryEventsSpy.mockRejectedValue(original); + const { context } = createContext(); + + await expect( + func.call(context, { ...DEFAULT_FLAGS, dataset: "spans" }, "test-org/"), + ).rejects.toBe(original); + }); + }); + describe("metrics dataset validation", () => { test("rejects standard aggregates on metrics dataset", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org" }); diff --git a/packages/cli/test/lib/errors.test.ts b/packages/cli/test/lib/errors.test.ts index 6806cc158..4d481cd69 100644 --- a/packages/cli/test/lib/errors.test.ts +++ b/packages/cli/test/lib/errors.test.ts @@ -624,6 +624,31 @@ describe("isSearchQueryParseError", () => { ); }); + test.each([ + "Parse error at '\n774ed' (column 12). This is commonly caused by unmatched parentheses. Enclose any text in double quotes.", + "Invalid quote at '*\"POST': quotes must enclose text or be escaped.", + "Search terms cannot end with an escape character", + "Empty string after 'release:'", + '"is:" queries are not supported in this search.', + "Invalid value for \"is\" search, valid values are ['archived', 'unresolved']", + "timestamp: Invalid date: >-15m. Expected +/-duration (e.g. +1h) or ISO 8601-like (e.g. 2026-11-03T10:00:38.16).", + ])("true for an unwrapped Events search parser detail: %s", (detail) => { + expect(isSearchQueryParseError(new ApiError("bad", 400, detail))).toBe( + true, + ); + }); + + test.each([ + "orderby must also be in the selected columns or groupby", + "Invalid query. Project(s) web do not exist or are not actively selected.", + "p75: column argument invalid: title is not a numeric column", + "Unknown function p10", + ])("false for a non-parser Events 400: %s", (detail) => { + expect(isSearchQueryParseError(new ApiError("bad", 400, detail))).toBe( + false, + ); + }); + test("false for a 400 without a query parse detail", () => { expect( isSearchQueryParseError(new ApiError("bad", 400, "Other failure")),