diff --git a/apps/cli-docs/src/fragments/commands/explore.md b/apps/cli-docs/src/fragments/commands/explore.md index 7dc9a995c..6dd07cda6 100644 --- a/apps/cli-docs/src/fragments/commands/explore.md +++ b/apps/cli-docs/src/fragments/commands/explore.md @@ -15,6 +15,18 @@ You can also use the raw tracemetrics format: `aggregation(value,metric_name,met | `logs` | Structured log entries | | `replays` | Session replay recordings | +## Rejected expressions + +Aggregate support depends on the selected dataset. When Sentry rejects a known +expression supplied through `--field` or `--sort`, Explore preserves the backend +detail and explains how to correct that expression. It does not rewrite or rerun +the query. + +Use `count()` to count spans. For the earliest or latest log time, use +`min(timestamp_precise)` or `max(timestamp_precise)`; their results are Unix +timestamps in nanoseconds, rather than formatted timestamps. A function accepted +by one dataset may be unsupported by another. + ## Target Patterns | Target | Behavior | diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index 073b621a9..b43280b52 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -27,6 +27,7 @@ import { resolveCursor, } from "../lib/db/pagination.js"; import { toSearchQueryError, ValidationError } from "../lib/errors.js"; +import { toExploreFieldError } from "../lib/explore-errors.js"; import { filterFields } from "../lib/formatters/json.js"; import { buildMetaColumns } from "../lib/formatters/meta-table.js"; import { CommandOutput } from "../lib/formatters/output.js"; @@ -577,6 +578,18 @@ function resolveDatasetConfig(params: { limit, cursor, ...timeRangeToApiParams(timeRange), + }).catch((error: unknown): never => { + throw toExploreFieldError(error, { + dataset: legacyTransactions + ? "spans" + : (API_TO_USER_DATASET.get(dataset) ?? dataset), + fields, + userFields: (flags.field ?? []).filter( + (field) => !flags.metric || !isAggregateFieldOrEquation(field), + ), + sort, + userSort: flags.sort, + }); }), }; } diff --git a/packages/cli/src/lib/explore-errors.ts b/packages/cli/src/lib/explore-errors.ts new file mode 100644 index 000000000..82b564332 --- /dev/null +++ b/packages/cli/src/lib/explore-errors.ts @@ -0,0 +1,96 @@ +/** + * Recovery hints for backend-rejected Explore field expressions. + * Only attribute a failure to a user flag when that expression reached the + * request; rejected defaults and generated metric expressions remain API errors. + */ + +import { parseFunction } from "@sentry/toolkit-core/events-fields"; +import { getSortField } from "@sentry/toolkit-core/events-sort"; +import { ApiError, validationError } from "./errors.js"; + +/** User-authored expressions and the request they contributed to. */ +type ExploreErrorContext = { + /** Effective request dataset, using its CLI name. */ + dataset: string; + /** Fields actually sent after resolving defaults, metrics, and sorting. */ + fields: readonly string[]; + /** Explicit user fields retained by the command, excluding generated fields. */ + userFields: readonly string[]; + /** Effective request sort, including any CLI-generated default. */ + sort: string | undefined; + /** Explicit --sort input, before resolving a default. */ + userSort: string | undefined; +}; + +/** Match narrow backend diagnostics without maintaining an aggregate catalog. */ +function recoveryHint( + detail: string, + expression: string, + dataset: string, +): string | undefined { + const parsed = parseFunction(expression); + if (!parsed) { + return; + } + + if ( + dataset === "spans" && + parsed.name === "count" && + parsed.arguments.length === 1 && + parsed.arguments[0] === "span" && + detail.startsWith("span is invalid for parameter 1 in count.") + ) { + return "Use count() to count spans; replace count(span) in --field and any matching --sort. The argument to count is an attribute, not the dataset name."; + } + + if ( + dataset === "logs" && + (parsed.name === "min" || parsed.name === "max") && + parsed.arguments.length === 1 && + parsed.arguments[0] === "timestamp" && + detail.startsWith(`timestamp is invalid for parameter 1 in ${parsed.name}.`) + ) { + return "Use min(timestamp_precise) / max(timestamp_precise) for the earliest / latest log time. These return Unix timestamps in nanoseconds, not formatted timestamps."; + } + + if (detail === `Unknown function ${parsed.name}`) { + return "Aggregate functions depend on the dataset. Check the function name and selected dataset; sentry explore --help includes usage examples."; + } +} + +/** + * Add a correction for a known rejected user expression, preserving the server + * detail. Unknown failures and errors caused by CLI-generated input are returned + * unchanged so they retain their API error classification and diagnostics. + */ +export function toExploreFieldError( + error: unknown, + context: ExploreErrorContext, +): unknown { + if (!(error instanceof ApiError) || error.status !== 400 || !error.detail) { + return error; + } + + const expressions = context.userFields + .filter((field) => context.fields.includes(field)) + .map((expression) => ({ expression, value: expression, flag: "field" })); + if (context.userSort?.trim() && context.userSort.trim() === context.sort) { + expressions.push({ + expression: getSortField(context.userSort.trim()), + value: context.userSort.trim(), + flag: "sort", + }); + } + + for (const { expression, value, flag } of expressions) { + const hint = recoveryHint(error.detail, expression, context.dataset); + if (hint) { + return validationError( + `Sentry rejected --${flag} "${value}" for dataset "${context.dataset}".\n\n${error.detail}`, + [hint], + flag, + ); + } + } + return error; +} diff --git a/packages/cli/test/commands/explore.test.ts b/packages/cli/test/commands/explore.test.ts index b6479abb4..cbfe37435 100644 --- a/packages/cli/test/commands/explore.test.ts +++ b/packages/cli/test/commands/explore.test.ts @@ -38,7 +38,13 @@ vi.mock("../../src/lib/db/pagination.js", async (importOriginal) => { // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as paginationDb from "../../src/lib/db/pagination.js"; -import { ContextError, ValidationError } from "../../src/lib/errors.js"; +import { classifySilenced } from "../../src/lib/error-reporting.js"; +import { + ApiError, + ContextError, + isUserError, + ValidationError, +} from "../../src/lib/errors.js"; import { DEFAULT_REPLAY_EXPLORE_FIELDS } from "../../src/lib/replay-search.js"; vi.mock("../../src/lib/resolve-target.js", async (importOriginal) => { @@ -802,6 +808,214 @@ describe("sentry explore", () => { }); }); + describe("backend expression recovery", () => { + test.each(["spans", "transaction", "transactions"])( + "explains how to count %s without retrying or hiding the failure", + async (dataset) => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "web" }); + const detail = + "span is invalid for parameter 1 in count. Its a string type field, but it must be one of these types: {'number', 'duration'}"; + queryEventsSpy.mockRejectedValue( + new ApiError("Bad request", 400, detail), + ); + const { context, getStdout } = createContext(); + + const error = await func + .call( + context, + { + ...DEFAULT_FLAGS, + dataset, + field: ["span.op", "count(span)"], + sort: "-count(span)", + }, + "test-org/web", + ) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ValidationError); + expect(error).toMatchObject({ field: "field" }); + expect((error as Error).message).toContain(detail); + expect((error as Error).message).toContain('--field "count(span)"'); + expect((error as Error).message).toContain('dataset "spans"'); + expect((error as Error).message).toContain( + "Use count() to count spans", + ); + expect(isUserError(error)).toBe(true); + expect(classifySilenced(error)).toBeNull(); + expect(queryEventsSpy).toHaveBeenCalledTimes(1); + expect(queryEventsSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + dataset: "spans", + fields: ["span.op", "count(span)"], + sort: "-count(span)", + }), + ); + expect(advancePaginationStateSpy).not.toHaveBeenCalled(); + expect(getStdout()).toBe(""); + }, + ); + + test.each(["min", "max"])( + "preserves earliest/latest intent and units for logs %s(timestamp)", + async (aggregate) => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const detail = `timestamp is invalid for parameter 1 in ${aggregate}. Its a string type field, but it must be one of these types: {'number'}`; + queryEventsSpy.mockRejectedValue( + new ApiError("Bad request", 400, detail), + ); + const { context } = createContext(); + + const error = await func + .call( + context, + { + ...DEFAULT_FLAGS, + dataset: "logs", + field: ["severity", `${aggregate}(timestamp)`], + }, + "test-org/", + ) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ValidationError); + expect((error as Error).message).toContain(detail); + expect((error as Error).message).toContain( + `${aggregate}(timestamp_precise)`, + ); + expect((error as Error).message).toContain( + "Unix timestamps in nanoseconds", + ); + }, + ); + + test("identifies an unsupported function supplied only through --sort", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + queryEventsSpy.mockRejectedValue( + new ApiError("Bad request", 400, "Unknown function p10"), + ); + const { context } = createContext(); + + const error = await func + .call( + context, + { + ...DEFAULT_FLAGS, + dataset: "spans", + field: ["span.op", "count()"], + sort: "-p10(span.duration)", + }, + "test-org/", + ) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ValidationError); + expect(error).toMatchObject({ field: "sort" }); + expect((error as Error).message).toContain( + '--sort "-p10(span.duration)"', + ); + expect((error as Error).message).toContain("Unknown function p10"); + expect((error as Error).message).toContain("sentry explore --help"); + expect(queryEventsSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: ["span.op", "count()", "p10(span.duration)"], + sort: "-p10(span.duration)", + }), + ); + }); + + test("identifies an unsupported function supplied through --field", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + queryEventsSpy.mockRejectedValue( + new ApiError("Bad request", 400, "Unknown function p10"), + ); + const { context } = createContext(); + + const error = await func + .call( + context, + { ...DEFAULT_FLAGS, dataset: "logs", field: ["p10(duration)"] }, + "test-org/", + ) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ValidationError); + expect((error as Error).message).toContain('--field "p10(duration)"'); + expect((error as Error).message).toContain('dataset "logs"'); + expect((error as Error).message).not.toContain("attribute list"); + }); + + test.each([ + ["default fields", {}, "Unknown function count"], + [ + "default fields with a user query", + { query: "level:error" }, + "Unknown function count", + ], + [ + "an unrelated user field", + { field: ["title"] }, + "Unknown function count", + ], + [ + "a query expression", + { query: "p10(duration):>0" }, + "Unknown function p10", + ], + [ + "a spans backend without timestamp aggregates", + { dataset: "spans", field: ["min(timestamp)"] }, + "timestamp is invalid for parameter 1 in min. Its a string type field", + ], + [ + "an unknown rejection", + { field: ["count()"] }, + "Invalid dataset configuration", + ], + [ + "a generated metric expression", + { + dataset: "tracemetrics", + metric: "llm.token_usage", + field: ["sum(value,llm.token_usage,distribution,none)"], + }, + "Unknown function sum", + ], + ])("preserves the API error for %s", async (_label, flags, detail) => { + 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, ...flags }, "test-org/"), + ).rejects.toBe(original); + expect(isUserError(original)).toBe(false); + expect(classifySilenced(original)).toBeNull(); + }); + + test("preserves non-400 errors even when their detail mentions a user field", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const original = new ApiError( + "Server failure", + 500, + "Unknown function p10", + ); + queryEventsSpy.mockRejectedValue(original); + const { context } = createContext(); + + await expect( + func.call( + context, + { ...DEFAULT_FLAGS, dataset: "spans", field: ["p10(span.duration)"] }, + "test-org/", + ), + ).rejects.toBe(original); + }); + }); + describe("metrics dataset validation", () => { test("rejects standard aggregates on metrics dataset", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org" });