Skip to content
Merged
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
12 changes: 12 additions & 0 deletions apps/cli-docs/src/fragments/commands/explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/commands/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
}),
};
}
Expand Down
96 changes: 96 additions & 0 deletions packages/cli/src/lib/explore-errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
216 changes: 215 additions & 1 deletion packages/cli/test/commands/explore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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" });
Expand Down
Loading