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. 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/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/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/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: { 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?:"); });