Skip to content
Open
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
39 changes: 39 additions & 0 deletions apps/cli-docs/src/fragments/commands/explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,45 @@ readable axes. The CLI never averages buckets to fit an Explore chart. Use
`SENTRY_NO_GRAPHICS=1` or the graphics default setting to keep table output.
Graphics do not change `--json` or `--fields`.

### Transformations

Repeat `--transform 'axis:stage'` to fill gaps or smooth a time series. The axis
is the zero-based position in the `--field` list. Stages for each axis run in
the order you supply them. Duplicate aggregates remain independent axes.

Without `--field`, axis `0` refers to `count()`. With `--metric`, axis `0`
refers to the resolved metric aggregate. Transformations require `--time-series`.
Each axis supports at most one `fill` and one `smooth` stage, in either order.
Fill modes are `zero`, `locf`, and `linear`. Smoothing supports `smooth(sma,window)`,
`smooth(ema,window[,factor])`, and `smooth(asap[,max_window])`.
Sentry validates modes and arguments and applies the transformations.

The public CLI SDK accepts the same ordered inputs.

```typescript
await sdk.explore({
target: "my-org/",
dataset: "spans",
timeSeries: "5m",
field: ["p95(span.duration)", "p95(span.duration)"],
transform: ["0:fill(linear)", "0:smooth(sma,3)", "1:smooth(asap)"],
});
```

For transformed requests, each returned series includes `axisIndex` and
`requestedTransformations`. An untouched axis has an empty list of requested
transformations.
Bucket `filled` and `smoothed` flags report Sentry's provenance. Human output
shows each axis, its transformations, and counts of filled and smoothed buckets.
Tables also retain per-bucket provenance. JSON preserves null values, units,
the executed interval, and all returned metadata. `--fields` projects buckets
and retains the series identity and requested transformations.

If Sentry rejects a transformation, the command reports the server's error. If the
response lacks transformation provenance, the command fails without displaying
unverified values. This includes disabled or unsupported transformations and
empty transformed series, where the current response cannot prove execution.

## Metrics

Use `--metric` (`-m`) to query metrics by name. The CLI auto-resolves the metric's type and unit.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Query aggregate event data (Explore)
- `-e, --environment <value>... - Environment filter (repeatable, comma-separated)`
- `-n, --limit <value> - Number of table rows (1-1000, default: 25)`
- `-b, --time-series <value> - Query time series (default: auto; optional bucket duration, e.g., 15m)`
- `--transform <value>... - Ordered per-axis stage, e.g., 0:fill(linear) (repeatable; requires --time-series)`
- `-t, --period <value> - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01" - (default: "24h")`
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
- `-c, --cursor <value> - Navigate pages: "next", "prev", "first" (or raw cursor string)`
Expand Down Expand Up @@ -77,6 +78,9 @@ sentry explore my-org/cli --dataset spans -F "p95(span.duration)" --time-series
# A metric over time using its discovered type and unit
sentry explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg -b -t 1h

# Compare independent transformations for duplicate latency axes
sentry explore my-org/cli --dataset spans -F 'p95(span.duration)' -F 'p95(span.duration)' -b 5m -t 24h --transform '0:fill(linear)' --transform '0:smooth(sma,3)' --transform '1:smooth(ema,3,0.5)'

# Sum a custom metric (e.g., LLM token usage) across an organization
sentry explore my-org/ -m llm.token_usage --dataset metrics --period 7d

Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/commands/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { toSearchQueryError, ValidationError } from "../lib/errors.js";
import { toExploreFieldError } from "../lib/explore-errors.js";
import {
parseTimeSeriesInterval,
parseTimeSeriesTransformations,
resolveTimeSeriesInterval,
} from "../lib/explore-timeseries.js";
import {
Expand Down Expand Up @@ -167,6 +168,7 @@ type ExploreFlags = {
readonly limit?: number;
/** Bucket duration or "auto"; omitted for tabular output. */
readonly "time-series"?: string;
readonly transform?: string[];
readonly cursor?: string;
readonly json: boolean;
readonly fresh: boolean;
Expand Down Expand Up @@ -536,6 +538,12 @@ function resolveEventsQuery(
/** Reject table-only flags and dimensions before a temporal query is sent. */
function validateTimeSeriesFlags(flags: ExploreFlags): void {
if (flags["time-series"] === undefined) {
if (flags.transform?.length) {
throw new ValidationError(
"--transform requires --time-series.",
"transform",
);
}
return;
}
for (const flag of ["cursor", "limit", "sort"] as const) {
Expand Down Expand Up @@ -701,6 +709,7 @@ export const exploreCommand = buildListCommand("explore", {
"Time series use one chart per aggregate on graphics-capable terminals, with table fallback. " +
"This is a single historical query, not a live stream. " +
"Grouping, --sort, --limit, and --cursor are table-only.\n\n" +
"Repeat --transform '0:fill(linear)' and --transform '0:smooth(sma,3)' to apply ordered stages to a zero-based axis.\n\n" +
"Datasets:\n" +
" errors Error events (default)\n" +
" spans Span data\n" +
Expand Down Expand Up @@ -765,6 +774,11 @@ export const exploreCommand = buildListCommand("explore", {
description: "A metric over time using its discovered type and unit",
run: "explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg -b -t 1h",
},
{
description:
"Compare independent transformations for duplicate latency axes",
run: "explore my-org/cli --dataset spans -F 'p95(span.duration)' -F 'p95(span.duration)' -b 5m -t 24h --transform '0:fill(linear)' --transform '0:smooth(sma,3)' --transform '1:smooth(ema,3,0.5)'",
},
{
description:
"Sum a custom metric (e.g., LLM token usage) across an organization",
Expand Down Expand Up @@ -879,6 +893,15 @@ export const exploreCommand = buildListCommand("explore", {
optional: true,
inferEmpty: true,
},
transform: {
kind: "parsed",
parse: textParser(String),
placeholder: "axis:stage",
brief:
"Ordered per-axis stage, e.g., 0:fill(linear) (repeatable; requires --time-series)",
optional: true,
variadic: true,
},
period: {
kind: "parsed" as const,
parse: parsePeriod,
Expand Down Expand Up @@ -971,6 +994,10 @@ export const exploreCommand = buildListCommand("explore", {
}

if (flags["time-series"] !== undefined) {
const transformations = parseTimeSeriesTransformations(
flags.transform,
fieldList.length,
);
const resolved = resolveEventsQuery(
dataset,
flags.query,
Expand All @@ -995,6 +1022,7 @@ export const exploreCommand = buildListCommand("explore", {
query: resolved.query,
projectId,
interval,
...(transformations ? { transformations } : {}),
...timeParams,
}).catch((error: unknown): never => {
const fieldError = toExploreFieldError(error, {
Expand Down
49 changes: 47 additions & 2 deletions packages/cli/src/lib/api/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
import type { EventsTableResponse } from "../../types/dashboard.js";
import { EventsTableResponseSchema } from "../../types/dashboard.js";
import type { EventsDataset } from "../datasets.js";
import { stringifyUnknown } from "../errors.js";
import { stringifyUnknown, ValidationError } from "../errors.js";
import type { TimeSeriesTransformations } from "../explore-timeseries.js";
import { resolveOrgRegion } from "../region.js";
import { getSdkClientConfig } from "../sentry-sdk-client.js";
import {
Expand Down Expand Up @@ -69,6 +70,8 @@ export type ExploreTimeSeriesQueryOptions = Pick<
> & {
/** Bucket width as a duration, such as `5m`. Must be valid for the requested range. */
interval: string;
/** Ordered transformations keyed by zero-based position in fields, including duplicate fields. */
transformations?: TimeSeriesTransformations;
};

type SdkTimeSeries = OrganizationEventsTimeseriesResponse["timeSeries"][number];
Expand All @@ -84,6 +87,10 @@ export type EventsTimeSeriesResponse = Omit<
> & {
/** One series per aggregate, including bucket accuracy and completeness metadata. */
timeSeries: (Omit<SdkTimeSeries, "values"> & {
/** Zero-based request position, included when transformations were requested. */
axisIndex?: number;
/** Requested stages in execution order, distinct from returned bucket provenance. */
requestedTransformations?: readonly string[];
/** Buckets retain API timestamps in milliseconds and null values for missing data. */
values: (Omit<SdkTimeSeries["values"][number], "value"> & {
/** Aggregate result, without coercion of strings or missing values. */
Expand All @@ -101,6 +108,10 @@ export async function queryEventsTimeSeries(
orgSlug: string,
options: ExploreTimeSeriesQueryOptions,
): Promise<EventsTimeSeriesResponse> {
const transformationParams: Record<string, readonly string[]> = {};
for (const [index, stages] of Object.entries(options.transformations ?? {})) {
transformationParams[`transformations[${index}]`] = stages;
}
const result = await listOrganizationEventsTimeseries({
...getOrgSdkConfig(),
path: { organization_id_or_slug: orgSlug },
Expand All @@ -117,12 +128,46 @@ export async function queryEventsTimeSeries(
: (options.statsPeriod ?? "24h"),
start: options.start,
end: options.end,
...transformationParams,
},
});
return unwrapResult<EventsTimeSeriesResponse>(
const data = unwrapResult<EventsTimeSeriesResponse>(
result,
"Failed to query events time series",
);
if (Object.keys(transformationParams).length > 0) {
const axesMatch =
data.timeSeries.length === options.fields.length &&
data.timeSeries.every(
(series, index) => series.yAxis === options.fields[index],
);
for (const index of Object.keys(options.transformations ?? {})) {
const series = data.timeSeries[Number(index)];
if (
!axesMatch ||
!series?.values.length ||
series.values.some(
(bucket) =>
typeof bucket.filled !== "boolean" ||
typeof bucket.smoothed !== "boolean",
)
) {
throw new ValidationError(
`Sentry could not verify transformations for axis ${index}. The organization or dataset may not support them, or no buckets were returned. No unverified values were displayed.`,
"transform",
);
}
}
return {
...data,
timeSeries: data.timeSeries.map((series, axisIndex) => ({
...series,
axisIndex,
requestedTransformations: options.transformations?.[axisIndex] ?? [],
})),
};
}
return data;
}

/**
Expand Down
54 changes: 54 additions & 0 deletions packages/cli/src/lib/explore-timeseries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* by Sentry's spans, logs, and metrics datasets.
*/

import { parseFunction } from "@sentry/toolkit-core/events-fields";
import { ValidationError } from "./errors.js";
import { type TimeRangeApiParams, timeRangeToSeconds } from "./time-range.js";

Expand Down Expand Up @@ -40,6 +41,59 @@ const DEFAULT_INTERVALS: readonly [number, keyof typeof INTERVAL_SECONDS][] = [
/** Sentry's ungrouped timeseries query budget; includes boundary buckets. */
const MAX_BUCKETS = 10_100;

/** Ordered transformation expressions keyed by zero-based request axis position. */
export type TimeSeriesTransformations = Readonly<
Record<number, readonly string[]>
>;

/** Parse CLI and SDK axis:stage inputs without merging duplicate aggregate axes. */
export function parseTimeSeriesTransformations(
values: readonly string[] | undefined,
axisCount: number,
): TimeSeriesTransformations | undefined {
if (!values?.length) {
return;
}
const transformations: Record<number, string[]> = {};
for (const value of values) {
const separator = value.indexOf(":");
const axis = value.slice(0, separator);
const stage = value.slice(separator + 1).trim();
const index = Number(axis);
if (
separator < 1 ||
!/^(0|[1-9]\d*)$/.test(axis) ||
!Number.isSafeInteger(index) ||
index >= axisCount
) {
throw new ValidationError(
`Invalid transformation axis in "${value}". Use a zero-based --field position from 0 to ${axisCount - 1}, followed by a colon and a stage.`,
"transform",
);
}
const parsed = parseFunction(stage);
if (!parsed || (parsed.name !== "fill" && parsed.name !== "smooth")) {
throw new ValidationError(
`Invalid transformation "${stage}". Use fill(mode) or smooth(mode,...), such as fill(linear) or smooth(sma,3).`,
"transform",
);
}
const axisTransformations = (transformations[index] ??= []);
if (
axisTransformations.some(
(existing) => parseFunction(existing)?.name === parsed.name,
)
) {
throw new ValidationError(
`Axis ${index} allows only one ${parsed.name} stage. Each axis may have one fill and one smooth transformation in either order.`,
"transform",
);
}
axisTransformations.push(stage);
}
return transformations;
}

/** Validate an explicit bucket duration without silently changing it. */
export function parseTimeSeriesInterval(
value: string,
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/lib/formatters/explore-timeseries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ function formatSeries(series: TimeSeries): string {
const chart =
series.values.length > 0 ? formatExploreTimeSeriesChart(series) : undefined;
const details: string[] = [];
if (series.axisIndex !== undefined) {
details.push(`Axis ${series.axisIndex}`);
details.push(
`Transformations: ${text(series.requestedTransformations?.join(" -> ") || "none")}`,
);
}
if (series.values.some((bucket) => bucket.filled !== undefined)) {
details.push(
`Filled buckets: ${series.values.filter((bucket) => bucket.filled).length}`,
);
}
if (series.values.some((bucket) => bucket.smoothed !== undefined)) {
details.push(
`Smoothed buckets: ${series.values.filter((bucket) => bucket.smoothed).length}`,
);
}
// Every admitted Explore chart has room for its single fitted legend label.
// Keep the original expression when its fitted label omits more than syntax.
if (!chart || formatLegendLabel(series.yAxis).abbreviated) {
Expand Down Expand Up @@ -110,6 +126,10 @@ function formatBuckets(series: TimeSeries, buckets: Bucket[]): string {
time: formatUtcTimestamp(bucket.timestamp),
value: bucket.value,
status: formatBucketStatus(bucket),
filled: bucket.filled,
smoothed: bucket.smoothed,
comparisonFilled: bucket.comparisonFilled,
comparisonSmoothed: bucket.comparisonSmoothed,
}));
const columns = buildMetaColumns(
["time", "value"],
Expand All @@ -129,6 +149,16 @@ function formatBuckets(series: TimeSeries, buckets: Bucket[]): string {
if (buckets.some((bucket) => bucket.incomplete)) {
columns.push(...buildMetaColumns(["status"]));
}
for (const field of [
"filled",
"smoothed",
"comparisonFilled",
"comparisonSmoothed",
] as const) {
if (buckets.some((bucket) => bucket[field] !== undefined)) {
columns.push(...buildMetaColumns([field]));
}
}

return formatTable(rows, columns);
}
Expand Down
Loading
Loading