From 5f23ead1959522184291d0e933034c23ce44b835 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 9 Nov 2026 10:47:02 -0400 Subject: [PATCH 1/3] feat(cli): serialize and verify Explore transformations Preserve ordered transformations by request axis position and require returned provenance before exposing transformed values. Keep duplicate aggregates independent and retain nulls, units, and the executed interval. Co-Authored-By: GPT-6.1 Sol --- packages/cli/src/lib/api/explore.ts | 49 +++++++++- packages/cli/src/lib/explore-timeseries.ts | 54 +++++++++++ packages/cli/test/lib/api/explore.test.ts | 108 +++++++++++++++++++++ 3 files changed, 209 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/api/explore.ts b/packages/cli/src/lib/api/explore.ts index 00759f4ea..1dbbde2fc 100644 --- a/packages/cli/src/lib/api/explore.ts +++ b/packages/cli/src/lib/api/explore.ts @@ -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 { @@ -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]; @@ -84,6 +87,10 @@ export type EventsTimeSeriesResponse = Omit< > & { /** One series per aggregate, including bucket accuracy and completeness metadata. */ timeSeries: (Omit & { + /** 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 & { /** Aggregate result, without coercion of strings or missing values. */ @@ -101,6 +108,10 @@ export async function queryEventsTimeSeries( orgSlug: string, options: ExploreTimeSeriesQueryOptions, ): Promise { + const transformationParams: Record = {}; + for (const [index, stages] of Object.entries(options.transformations ?? {})) { + transformationParams[`transformations[${index}]`] = stages; + } const result = await listOrganizationEventsTimeseries({ ...getOrgSdkConfig(), path: { organization_id_or_slug: orgSlug }, @@ -117,12 +128,46 @@ export async function queryEventsTimeSeries( : (options.statsPeriod ?? "24h"), start: options.start, end: options.end, + ...transformationParams, }, }); - return unwrapResult( + const data = unwrapResult( 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; } /** diff --git a/packages/cli/src/lib/explore-timeseries.ts b/packages/cli/src/lib/explore-timeseries.ts index a73b0ae20..c92d944c6 100644 --- a/packages/cli/src/lib/explore-timeseries.ts +++ b/packages/cli/src/lib/explore-timeseries.ts @@ -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"; @@ -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 +>; + +/** 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 = {}; + 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, diff --git a/packages/cli/test/lib/api/explore.test.ts b/packages/cli/test/lib/api/explore.test.ts index 837a014cb..274dfad49 100644 --- a/packages/cli/test/lib/api/explore.test.ts +++ b/packages/cli/test/lib/api/explore.test.ts @@ -388,6 +388,114 @@ describe("queryEventsTimeSeries", () => { }); } + test("accepts all-false provenance and keeps a raw duplicate axis independent", async () => { + const raw = { + yAxis: "count()", + meta: { interval: 300_000, valueType: "integer", valueUnit: null }, + values: [{ timestamp: 0, value: null, incomplete: false }], + }; + const transformed = { + ...raw, + values: [ + { + timestamp: 0, + value: null, + incomplete: false, + filled: false, + smoothed: false, + comparisonValue: null, + comparisonFilled: false, + comparisonSmoothed: false, + }, + ], + }; + mockResponse({ timeSeries: [raw, transformed] }); + const result = await queryEventsTimeSeries("timeseries-org", { + fields: ["count()", "count()"], + interval: "1m", + transformations: { 1: ["smooth(asap)"] }, + }); + expect(result).toEqual({ + timeSeries: [ + { ...raw, axisIndex: 0, requestedTransformations: [] }, + { + ...transformed, + axisIndex: 1, + requestedTransformations: ["smooth(asap)"], + }, + ], + }); + const params = new URL(requests[0]!.url).searchParams; + expect(params.getAll("yAxis")).toEqual(["count()", "count()"]); + expect(params.getAll("transformations[1]")).toEqual(["smooth(asap)"]); + expect(params.has("transformations[0]")).toBe(false); + }); + + test.each([ + { + name: "feature-disabled raw values", + yAxis: "count()", + values: [{ timestamp: 0, value: 42, incomplete: false }], + }, + { name: "empty transformed series", yAxis: "count()", values: [] }, + { + name: "mismatched axes", + yAxis: "avg(span.duration)", + values: [ + { + timestamp: 0, + value: 42, + incomplete: false, + filled: false, + smoothed: true, + }, + ], + }, + ])( + "rejects $name without falling back to a raw request", + async ({ yAxis, values }) => { + mockResponse({ + timeSeries: [ + { + yAxis, + values, + meta: { interval: 60_000, valueType: "integer", valueUnit: null }, + }, + ], + }); + await expect( + queryEventsTimeSeries("timeseries-org", { + fields: ["count()"], + interval: "1m", + transformations: { 0: ["fill(zero)"] }, + }), + ).rejects.toThrow("Sentry could not verify transformations for axis 0"); + expect(requests).toHaveLength(1); + }, + ); + + test("preserves backend transformation validation errors", async () => { + mockResponse( + { + detail: + "smooth(sma,0): window_size argument invalid: Window size must be positive.", + }, + 400, + ); + await expect( + queryEventsTimeSeries("timeseries-org", { + fields: ["count()"], + interval: "1m", + transformations: { 0: ["smooth(sma,0)"] }, + }), + ).rejects.toMatchObject({ + status: 400, + detail: + "smooth(sma,0): window_size argument invalid: Window size must be positive.", + }); + expect(requests).toHaveLength(1); + }); + test("queries the org region and preserves values, nulls, and complete metadata", async () => { const response = { meta: { From f4c5b0b8c11603a517e79b09b1a8336b7136ce86 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 9 Nov 2026 10:47:24 -0400 Subject: [PATCH 2/3] feat(cli): expose Explore transformations in commands and SDK Add repeatable indexed transformation inputs and show requested transformations with returned provenance. Cover command parsing, SDK invocation, and the real CLI-to-HTTP flow. Regenerate the command reference. Co-Authored-By: GPT-6.1 Sol --- .../skills/sentry-cli/references/explore.md | 4 + packages/cli/src/commands/explore.ts | 28 +++ .../src/lib/formatters/explore-timeseries.ts | 30 +++ packages/cli/test/commands/explore.test.ts | 77 ++++++++ packages/cli/test/e2e/explore.test.ts | 180 ++++++++++++++++++ packages/cli/test/lib/sdk-flag-types.test.ts | 1 + 6 files changed, 320 insertions(+) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md index 76a0ad4b4..97452baee 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md @@ -31,6 +31,7 @@ Query aggregate event data (Explore) - `-e, --environment ... - Environment filter (repeatable, comma-separated)` - `-n, --limit - Number of table rows (1-1000, default: 25)` - `-b, --time-series - Query time series (default: auto; optional bucket duration, e.g., 15m)` +- `--transform ... - Ordered per-axis stage, e.g., 0:fill(linear) (repeatable; requires --time-series)` - `-t, --period - 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 - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -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 diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index 430aafbdf..dfed42cd9 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -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 { @@ -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; @@ -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) { @@ -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" + @@ -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", @@ -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, @@ -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, @@ -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, { diff --git a/packages/cli/src/lib/formatters/explore-timeseries.ts b/packages/cli/src/lib/formatters/explore-timeseries.ts index 7e16bf877..0360550ee 100644 --- a/packages/cli/src/lib/formatters/explore-timeseries.ts +++ b/packages/cli/src/lib/formatters/explore-timeseries.ts @@ -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) { @@ -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"], @@ -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); } diff --git a/packages/cli/test/commands/explore.test.ts b/packages/cli/test/commands/explore.test.ts index fda9d44e4..97015b80c 100644 --- a/packages/cli/test/commands/explore.test.ts +++ b/packages/cli/test/commands/explore.test.ts @@ -776,6 +776,81 @@ describe("sentry explore", () => { }); describe("time series", () => { + test("accepts ordered indexed stages through the public SDK", async () => { + const sdk = createSentrySDK({ cwd: "/tmp/test-explore" }); + const result = await sdk.explore({ + target: "test-org/", + dataset: "spans", + timeSeries: "auto", + field: ["count()", "count()"], + transform: ["1:smooth(sma,3)", "0:fill(linear)", "1:fill(zero)"], + }); + + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: ["count()", "count()"], + transformations: { + 0: ["fill(linear)"], + 1: ["smooth(sma,3)", "fill(zero)"], + }, + }), + ); + expect(result).toEqual({ + ...MOCK_TIME_SERIES_RESPONSE, + dataset: "spans", + }); + }); + + test.each([ + { + args: ["--transform", "0:fill(linear)"], + error: "requires --time-series", + }, + { + args: ["-b", "--transform", "1:fill(linear)"], + error: "Invalid transformation axis", + }, + { + args: ["-b", "--transform", "00:fill(linear)"], + error: "Invalid transformation axis", + }, + { + args: ["-b", "--transform", "0:unknown()"], + error: "Invalid transformation", + }, + { + args: [ + "-b", + "--transform", + "0:fill(linear)", + "--transform", + "0:fill(zero)", + ], + error: "only one fill", + }, + ])( + "rejects invalid transformation flags $args", + async ({ args, error }) => { + const result = await runExploreArgs(["test-org/", ...args]); + expect(result.exitCode).not.toBe(0); + expect(result.getStderr()).toContain(error); + expect(result.getStdout()).toBe(""); + expect(queryEventsTimeSeriesSpy).not.toHaveBeenCalled(); + }, + ); + + test("validates explicit SDK stages even though SDK invocation bypasses flag parsers", async () => { + const sdk = createSentrySDK({ cwd: "/tmp/test-explore" }); + await expect( + sdk.explore({ + target: "test-org/", + timeSeries: "auto", + transform: ["9:fill(zero)"], + }), + ).rejects.toThrow("Invalid transformation axis"); + }); + test.each([ { name: "--time-series before another flag", @@ -904,6 +979,7 @@ describe("sentry explore", () => { metric: "cache.hit_rate", agg: "avg", period: parsePeriod("7d"), + transform: ["0:smooth(asap)"], }, "test-org/web", ); @@ -919,6 +995,7 @@ describe("sentry explore", () => { dataset: "tracemetrics", query: "project:web", statsPeriod: "7d", + transformations: { 0: ["smooth(asap)"] }, }), ); }); diff --git a/packages/cli/test/e2e/explore.test.ts b/packages/cli/test/e2e/explore.test.ts index 8eea09bad..99adba93e 100644 --- a/packages/cli/test/e2e/explore.test.ts +++ b/packages/cli/test/e2e/explore.test.ts @@ -142,3 +142,183 @@ test("combines -b buckets with -t period and preserves metadata when projecting server.stop(); } }); + +test("keeps duplicate axes and ordered transformations through HTTP and rejects silently ignored transformations", async () => { + const requests: URL[] = []; + const transformed = { + meta: response.meta, + timeSeries: [ + { + yAxis: "p95(span.duration)", + meta: { + valueType: "duration", + valueUnit: "millisecond", + interval: 120_000, + }, + values: [ + { + timestamp: start, + value: null, + incomplete: false, + filled: false, + smoothed: false, + }, + { + timestamp: start + 120_000, + value: 150, + incomplete: false, + filled: true, + smoothed: true, + }, + ], + }, + { + yAxis: "p95(span.duration)", + meta: { + valueType: "duration", + valueUnit: "millisecond", + interval: 120_000, + }, + values: [ + { + timestamp: start, + value: 0, + incomplete: false, + filled: true, + smoothed: false, + }, + { + timestamp: start + 120_000, + value: 300, + incomplete: false, + filled: false, + smoothed: true, + }, + ], + }, + ], + }; + let ignoreTransformations = false; + const server = createMockServer( + [ + { + method: "GET", + path: `/api/0/organizations/${TEST_ORG}/`, + response: organizationFixture, + }, + { + method: "GET", + path: `/api/0/organizations/${TEST_ORG}/events-timeseries/`, + response: (request: Request) => { + requests.push(new URL(request.url)); + return { body: ignoreTransformations ? response : transformed }; + }, + }, + ], + { validTokens: [TEST_TOKEN] }, + ); + await server.start(); + try { + const ctx = createE2EContext(getConfigDir(), server.url); + await ctx.setAuthToken(TEST_TOKEN); + const args = [ + "explore", + `${TEST_ORG}/`, + "--dataset", + "spans", + "-b", + "1m", + "-t", + "5m", + "-F", + "p95(span.duration)", + "-F", + "p95(span.duration)", + "--transform", + "0:fill(linear)", + "--transform", + "1:smooth(sma,3)", + "--transform", + "0:smooth(ema,3,0.5)", + "--transform", + "1:fill(zero)", + "--fresh", + ]; + const result = await ctx.run([...args, "--json"]); + expect(result.exitCode, result.stderr + result.stdout).toBe(0); + expect(requests).toHaveLength(1); + const params = requests[0]!.searchParams; + expect(params.getAll("yAxis")).toEqual([ + "p95(span.duration)", + "p95(span.duration)", + ]); + expect(params.getAll("transformations[0]")).toEqual([ + "fill(linear)", + "smooth(ema,3,0.5)", + ]); + expect(params.getAll("transformations[1]")).toEqual([ + "smooth(sma,3)", + "fill(zero)", + ]); + expect(params.get("interval")).toBe("1m"); + const expected = { + ...transformed, + dataset: "spans", + timeSeries: [ + { + ...transformed.timeSeries[0], + axisIndex: 0, + requestedTransformations: ["fill(linear)", "smooth(ema,3,0.5)"], + }, + { + ...transformed.timeSeries[1], + axisIndex: 1, + requestedTransformations: ["smooth(sma,3)", "fill(zero)"], + }, + ], + }; + expect(JSON.parse(result.stdout)).toEqual(expected); + + const projected = await ctx.run([ + ...args, + "--json", + "--fields", + "timestamp,value,filled,smoothed", + ]); + expect(projected.exitCode, projected.stderr + projected.stdout).toBe(0); + expect(JSON.parse(projected.stdout)).toEqual({ + ...expected, + timeSeries: expected.timeSeries.map((series) => ({ + ...series, + values: series.values!.map( + ({ timestamp, value, filled, smoothed }) => ({ + timestamp, + value, + filled, + smoothed, + }), + ), + })), + }); + + const human = await ctx.run(args); + expect(human.exitCode, human.stderr + human.stdout).toBe(0); + expect(human.stdout).toContain("Axis 0"); + expect(human.stdout).toContain("Axis 1"); + expect(human.stdout).toContain( + "Transformations: fill(linear) -> smooth(ema,3,0.5)", + ); + expect(human.stdout).toContain("Interval: 2m"); + expect(human.stdout).toContain("Filled"); + expect(human.stdout).toContain("Smoothed"); + + ignoreTransformations = true; + const unsupported = await ctx.run([...args, "--json"]); + expect(unsupported.exitCode).not.toBe(0); + expect(unsupported.stderr).toContain("could not verify"); + expect(unsupported.stdout).toBe(""); + expect(requests).toHaveLength(4); + } finally { + server.stop(); + } +}); diff --git a/packages/cli/test/lib/sdk-flag-types.test.ts b/packages/cli/test/lib/sdk-flag-types.test.ts index fafd5b9ab..8db05aaee 100644 --- a/packages/cli/test/lib/sdk-flag-types.test.ts +++ b/packages/cli/test/lib/sdk-flag-types.test.ts @@ -29,6 +29,7 @@ describe("generated SDK flag types", () => { const explore = paramsType("ExploreParams"); expect(explore).toContain("limit?: number;"); expect(explore).toContain("timeSeries?: string;"); + expect(explore).toContain("transform?: Array;"); expect(explore).not.toContain("interval?:"); }); From a0d7a9c6156bc16f7e228af7b80770c3b56fc864 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 9 Nov 2026 10:47:47 -0400 Subject: [PATCH 3/3] docs(cli): explain Explore time-series transformations Document ordered per-axis inputs, public SDK usage, provenance, and explicit failures when the response cannot prove transformation execution. Co-Authored-By: GPT-6.1 Sol --- .../src/fragments/commands/explore.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/cli-docs/src/fragments/commands/explore.md b/apps/cli-docs/src/fragments/commands/explore.md index 565bbaf2c..ecfd55821 100644 --- a/apps/cli-docs/src/fragments/commands/explore.md +++ b/apps/cli-docs/src/fragments/commands/explore.md @@ -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.