Skip to content
Closed
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
41 changes: 33 additions & 8 deletions packages/cli/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
);
}

Expand Down
41 changes: 41 additions & 0 deletions packages/cli/test/commands/explore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/test/lib/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
Loading