diff --git a/apps/cli-docs/src/fragments/commands/dashboard.md b/apps/cli-docs/src/fragments/commands/dashboard.md index 4841ed7c7..3727fbc5b 100644 --- a/apps/cli-docs/src/fragments/commands/dashboard.md +++ b/apps/cli-docs/src/fragments/commands/dashboard.md @@ -15,11 +15,27 @@ Use `sentry dashboard revisions` to find a revision number before running ## Saved transformations -Widget add, edit, and delete commands retain saved transformation pipelines on -untouched widgets. Title, layout, and filter edits preserve pipeline stage order -and aggregate positions, including separate pipelines for duplicate aggregates. +Widget add, edit, and delete commands retain saved transformations on +untouched widgets. Title, layout, and filter edits preserve transformation order +and aggregate positions, including separate transformations for duplicate aggregates. Query flags edit the first query and retain the remaining queries. +`dashboard view` executes saved transformations for span, log, and trace-metric chart +widgets. It prints exact values and provenance in full tables below the dashboard +grid. `null` marks a gap. The tables retain each query, axis, group, unit, comparison +value, and returned bucket flag. JSON uses a `transformed_timeseries` result with +one complete API response per saved query. Its timestamps and executed intervals +are in milliseconds. + +Saved widget intervals take priority over automatic chart sizing. Use +`sentry dashboard view my-org/123 --interval 5m` to override them. Output records +the requested interval and each series' executed interval. Terminal resizing does +not change an explicit interval. Empty results have no executed interval. + +Transformed tables, big numbers, and unsupported datasets report an unsupported +result. An unavailable transformation feature or a server that does not confirm +execution produces an explicit result instead of displaying raw values. + ## Query Shorthand The `--query` flag supports shorthand for aggregate functions: diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 993b6dd11..5968f9dbf 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -83,6 +83,7 @@ View a dashboard - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` - `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01"` +- `--interval - Bucket interval, overriding saved widget intervals (e.g., 5m)` - `--renderer - Graphics renderer (falls back to auto when unavailable) - (default: "auto")` - `--no-graphics-cap - Use the terminal-native graphics width (may fall back to ASCII for very large dashboards)` diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index 514f68951..1da196270 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -9,6 +9,7 @@ import type { SentryContext } from "../../context.js"; import { getDashboard, queryAllWidgets } from "../../lib/api-client.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; +import { parseTimeSeriesInterval } from "../../lib/explore-timeseries.js"; import { textParser } from "../../lib/completion-target.js"; import type { DashboardViewData } from "../../lib/formatters/dashboard.js"; import { createDashboardViewRenderer } from "../../lib/formatters/dashboard.js"; @@ -50,6 +51,7 @@ type ViewFlags = { readonly web: boolean; readonly fresh: boolean; readonly refresh?: number; + readonly interval?: string; readonly period?: TimeRange; readonly renderer: GraphicsRendererPreference; readonly "no-graphics-cap": boolean; @@ -240,6 +242,12 @@ export const viewCommand = buildCommand({ brief: PERIOD_BRIEF, optional: true, }, + interval: { + kind: "parsed", + parse: textParser(parseTimeSeriesInterval), + brief: "Bucket interval, overriding saved widget intervals (e.g., 5m)", + optional: true, + }, renderer: { kind: "enum", values: ["auto", "kitty", "sixel"], @@ -326,7 +334,7 @@ export const viewCommand = buildCommand({ regionUrl, orgSlug, dashboard, - { ...widgetTimeOpts, periodSeconds }, + { ...widgetTimeOpts, periodSeconds, interval: flags.interval }, ); // Build output data before clearing so clear→render is instantaneous @@ -359,6 +367,7 @@ export const viewCommand = buildCommand({ queryAllWidgets(regionUrl, orgSlug, dashboard, { ...widgetTimeOpts, periodSeconds, + interval: flags.interval, }), ); diff --git a/packages/cli/src/lib/api/dashboards.ts b/packages/cli/src/lib/api/dashboards.ts index 7925ed891..06a007dca 100644 --- a/packages/cli/src/lib/api/dashboards.ts +++ b/packages/cli/src/lib/api/dashboards.ts @@ -39,11 +39,12 @@ import { type TextResult, TIMESERIES_DISPLAY_TYPES, type TimeseriesResult, + type TransformedTimeseriesResult, type WidgetDataResult, } from "../../types/dashboard.js"; import type { EventsDataset } from "../datasets.js"; import { reportCliError } from "../error-reporting.js"; -import { ApiError, stringifyUnknown } from "../errors.js"; +import { ApiError, stringifyUnknown, ValidationError } from "../errors.js"; import { getSdkClientConfig } from "../sentry-sdk-client.js"; @@ -56,6 +57,7 @@ import { unwrapValidatedResult, validateResponse, } from "./infrastructure.js"; +import { getOrganization } from "./organizations.js"; /** * List dashboards in an organization with cursor-based pagination. @@ -250,6 +252,7 @@ export async function restoreDashboardRevision( /** Options for querying widget data */ type WidgetQueryOptions = { + interval?: string; /** Override the dashboard's time period (e.g., "24h", "7d") */ period?: string; /** Absolute start datetime (ISO-8601). Mutually exclusive with period. */ @@ -322,6 +325,9 @@ export function computeOptimalInterval( widget: DashboardWidget, periodSeconds?: number, ): string | undefined { + if (widget.interval) { + return widget.interval; + } const totalSeconds = periodSeconds ?? (statsPeriod ? periodToSeconds(statsPeriod) : undefined); if (!totalSeconds) { @@ -505,6 +511,7 @@ function buildTopEventsParams( */ /** Common params for widget query functions */ type WidgetQueryParams = { + getTransformationSupport: () => Promise; regionUrl: string; orgSlug: string; widget: DashboardWidget; @@ -519,14 +526,15 @@ type WidgetQueryParams = { options?: WidgetQueryOptions; }; -type EventsTimeseriesQuery = ListOrganizationEventsTimeseriesData["query"]; +type EventsTimeseriesQuery = ListOrganizationEventsTimeseriesData["query"] & + Partial>; /** Widget query params once the widget's type has resolved to a dataset. */ type DatasetWidgetQueryParams = WidgetQueryParams & { dataset: EventsDataset }; async function queryWidgetTimeseries( params: DatasetWidgetQueryParams, -): Promise { +): Promise { const { regionUrl, orgSlug, @@ -539,28 +547,51 @@ async function queryWidgetTimeseries( options = {}, } = params; const allSeries: TimeseriesResult["series"] = []; + const transformed = hasTransformations(widget); + const queries: TransformedTimeseriesResult["queries"] = []; + const interval = + options.interval ?? + computeOptimalInterval(statsPeriod, widget, periodSeconds); - for (const query of widget.queries ?? []) { + for (const [queryIndex, query] of (widget.queries ?? []).entries()) { const aggregates = query.aggregates ?? []; const columns = query.columns ?? []; + const reqParams: EventsTimeseriesQuery = { + yAxis: aggregates, + query: query.conditions || undefined, + dataset, + statsPeriod, + start, + end, + interval, + environment: options.environment, + project: options.project, + ...(columns.length > 0 && + buildTopEventsParams(widget, columns, aggregates, query.orderby)), + }; + + for (const [index, transformations] of Object.entries( + query.transformations ?? {}, + )) { + if ( + !/^(0|[1-9][0-9]*)$/.test(index) || + Number(index) >= aggregates.length + ) { + throw new ValidationError( + "Saved transformations must reference an existing yAxis position", + "transformations", + ); + } + if (transformations.length > 0) { + reqParams[`transformations[${Number(index)}]`] = transformations; + } + } + const result = await listOrganizationEventsTimeseries({ ...getSdkClientConfig(regionUrl), path: { organization_id_or_slug: orgSlug }, - query: { - yAxis: aggregates, - query: query.conditions || undefined, - dataset, - statsPeriod, - start, - end, - interval: computeOptimalInterval(statsPeriod, widget, periodSeconds), - environment: options.environment, - project: options.project, - // Group-by columns enable topEvents mode - ...(columns.length > 0 && - buildTopEventsParams(widget, columns, aggregates, query.orderby)), - }, + query: reqParams, }); const data = unwrapValidatedResult( result, @@ -569,11 +600,71 @@ async function queryWidgetTimeseries( `/organizations/${orgSlug}/events-timeseries/`, ); - const parsed = parseEventsTimeseriesResponse(data, aggregates); - allSeries.push(...parsed.series); + if (transformed) { + validateTransformedResponse( + data, + aggregates, + query.transformations ?? {}, + ); + queries.push({ + queryIndex, + requestedInterval: interval, + yAxes: aggregates, + transformations: query.transformations ?? {}, + response: data, + }); + } else { + const parsed = parseEventsTimeseriesResponse(data, aggregates); + allSeries.push(...parsed.series); + } } - return { type: "timeseries", series: allSeries }; + return transformed + ? { type: "transformed_timeseries", queries } + : { type: "timeseries", series: allSeries }; +} + +function hasTransformations(widget: DashboardWidget): boolean { + return (widget.queries ?? []).some((query) => + Object.values(query.transformations ?? {}).some( + (transformations) => transformations.length > 0, + ), + ); +} + +function validateTransformedResponse( + response: EventsTimeseriesResponse, + axes: string[], + transformations: Record, +): void { + if (axes.length === 0 || response.timeSeries.length % axes.length !== 0) { + throw new ValidationError( + "The server returned an incompatible transformed axis layout", + "transformations", + ); + } + for (const [index, series] of response.timeSeries.entries()) { + const axisIndex = index % axes.length; + if (series.yAxis !== axes[axisIndex]) { + throw new ValidationError( + "The server returned an incompatible transformed axis order", + "transformations", + ); + } + if ( + (transformations[String(axisIndex)]?.length ?? 0) > 0 && + series.values.some( + (bucket) => + typeof bucket.filled !== "boolean" || + typeof bucket.smoothed !== "boolean", + ) + ) { + throw new ValidationError( + "The server did not confirm execution of the saved transformations. The feature may be unavailable on this server.", + "transformations", + ); + } + } } /** @@ -657,6 +748,17 @@ async function queryWidgetData( params: WidgetQueryParams, ): Promise { const { widget } = params; + const transformed = hasTransformations(widget); + if ( + transformed && + (!["spans", "logs", "tracemetrics"].includes(widget.widgetType ?? "") || + !TIMESERIES_DISPLAY_TYPES.has(widget.displayType ?? "")) + ) { + return { + type: "unsupported", + reason: `Saved transformations are not supported for ${widget.widgetType} ${widget.displayType} widgets`, + }; + } // Text widgets carry markdown in `description`, no API query needed if (widget.displayType === "text") { @@ -684,6 +786,13 @@ async function queryWidgetData( } try { + if (transformed && !(await params.getTransformationSupport())) { + return { + type: "unsupported", + reason: + "Saved transformations require the explore-interpolation-and-smoothing feature, which is unavailable for this organization", + }; + } // Chart types → timeseries if (TIMESERIES_DISPLAY_TYPES.has(displayType)) { return await queryWidgetTimeseries({ ...params, dataset }); @@ -799,6 +908,14 @@ export async function queryAllWidgets( }; const results = new Map(); + let transformationSupport: Promise | undefined; + const getTransformationSupport = () => + (transformationSupport ??= getOrganization(orgSlug).then( + (organization) => + organization.features?.includes( + "explore-interpolation-and-smoothing", + ) ?? false, + )); if (widgets.length === 0) { return results; } @@ -810,6 +927,7 @@ export async function queryAllWidgets( const batchResults = await Promise.allSettled( batch.map((widget) => queryWidgetData({ + getTransformationSupport, regionUrl, orgSlug, widget, diff --git a/packages/cli/src/lib/formatters/dashboard-transformations.ts b/packages/cli/src/lib/formatters/dashboard-transformations.ts new file mode 100644 index 000000000..3d88ec504 --- /dev/null +++ b/packages/cli/src/lib/formatters/dashboard-transformations.ts @@ -0,0 +1,66 @@ +import { tableCell, text } from "@sentry/toolkit-core/markdown"; +import type { TransformedTimeseriesResult } from "../../types/dashboard.js"; +import { renderMarkdown } from "./markdown.js"; +import { formatTable } from "./table.js"; + +export function formatDashboardTransformations( + title: string, + data: TransformedTimeseriesResult, +): string { + const sections = [ + renderMarkdown(`**${text(title)}**`), + "Exact transformed values. null marks a gap.", + ]; + for (const query of data.queries) { + sections.push( + `Query ${query.queryIndex + 1}. Requested interval: ${query.requestedInterval ?? "auto"}.`, + ); + if (query.response.meta) { + sections.push(JSON.stringify(query.response.meta)); + } + if (query.response.timeSeries.length === 0) { + sections.push("No time series returned. Executed interval unavailable."); + } + for (const [index, series] of query.response.timeSeries.entries()) { + const axisIndex = index % query.yAxes.length; + const group = series.meta.isOther + ? "Other" + : JSON.stringify(series.groupBy ?? []); + sections.push( + renderMarkdown( + `Query ${query.queryIndex + 1}, axis ${axisIndex + 1}. ${text(series.yAxis)}. Group: ${text(group)}.`, + ), + ); + sections.push( + `Transformations: ${JSON.stringify(query.transformations[String(axisIndex)] ?? [])}`, + ); + sections.push( + `Executed interval: ${series.meta.interval} ms. Unit: ${series.meta.valueUnit ?? "none"}.`, + ); + sections.push(JSON.stringify(series.meta)); + if (series.values.length === 0) { + sections.push("No time buckets returned."); + continue; + } + const fields = [ + ...new Set(series.values.flatMap((bucket) => Object.keys(bucket))), + ]; + sections.push( + formatTable( + series.values, + fields.map((field) => ({ + header: tableCell(field === "timestamp" ? "TIME (UTC)" : field), + value: (bucket) => + tableCell( + field === "timestamp" + ? new Date(bucket.timestamp).toISOString() + : (JSON.stringify(bucket[field]) ?? ""), + ), + shrinkable: false, + })), + ), + ); + } + } + return sections.join("\n\n"); +} diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index d6a0d210f..f7988b8e0 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -32,6 +32,7 @@ import { } from "../sixel.js"; import { SERIES_PALETTE } from "./chart-core.js"; import { COLORS, muted, terminalLink } from "./colors.js"; +import { formatDashboardTransformations } from "./dashboard-transformations.js"; import { renderMarkdown } from "./markdown.js"; import type { HumanRenderer } from "./output.js"; import { isPlainOutput } from "./plain-detect.js"; @@ -1691,6 +1692,8 @@ function renderContentLines(opts: { const { data } = widget; switch (data.type) { + case "transformed_timeseries": + return ["Full transformed data follows below."]; case "timeseries": { if (widget.displayType === "heatmap") { return renderHeatmapContent(data, { innerWidth, contentHeight }); @@ -2018,6 +2021,12 @@ export function formatDashboardWithData(data: DashboardViewData): string { lines.push(...renderGrid(data.widgets, termWidth)); } + for (const widget of data.widgets) { + if (widget.data.type === "transformed_timeseries") { + lines.push("", formatDashboardTransformations(widget.title, widget.data)); + } + } + lines.push(""); return lines.join("\n"); } diff --git a/packages/cli/src/types/dashboard.ts b/packages/cli/src/types/dashboard.ts index d5a3d66c6..39c03eab5 100644 --- a/packages/cli/src/types/dashboard.ts +++ b/packages/cli/src/types/dashboard.ts @@ -813,7 +813,7 @@ function cleanQuery( widgetId: string | undefined, ): DashboardWidgetQuery { return { - // Sentry preserves omitted pipelines by ID even when the feature is disabled. + // Sentry preserves omitted transformations by ID even when the feature is disabled. ...(widgetId !== undefined && q.id !== undefined ? { id: q.id } : q.transformations !== undefined @@ -831,8 +831,8 @@ function cleanQuery( /** * Prepare a widget for PUT while retaining existing widget and query identities. - * Existing queries omit pipelines so Sentry preserves their stored configuration. - * New queries include pipelines verbatim, keyed by aggregate position. + * Existing queries omit transformations so Sentry preserves their stored configuration. + * New queries include transformations verbatim, keyed by aggregate position. * * @param widget - Widget object from GET response * @returns Widget safe for PUT (only API-accepted fields) @@ -906,7 +906,7 @@ const sdkTimeseriesRow = sdkTimeseriesSeries.values.item.entries; * Built from the `@sentry/api` schema. `timestamp` is epoch milliseconds and * `value` is null for empty buckets. */ -const EventsTimeseriesRowSchema = object({ +const EventsTimeseriesRowSchema = looseObject({ ...sdkTimeseriesRow, // The spec says `number | null`, but the errors dataset returns a string // for string-typed aggregates (for example `max()` over a measurement no @@ -921,8 +921,9 @@ const EventsTimeseriesRowSchema = object({ * (`topEvents > 0`) return one series per group per `yAxis`, with the * group identified by `groupBy` and the catch-all group by `meta.isOther`. */ -const EventsTimeseriesSeriesSchema = object({ +const EventsTimeseriesSeriesSchema = looseObject({ ...sdkTimeseriesSeries, + meta: looseObject(sdkTimeseriesSeries.meta.entries), values: array(EventsTimeseriesRowSchema), // The spec marks `groupBy` optional only, but the "Other" series sends // `groupBy: null`. @@ -934,8 +935,18 @@ export type EventsTimeseriesSeries = InferOutput< >; /** Top-level response from the events-timeseries API. */ -export const EventsTimeseriesResponseSchema = object({ +export const EventsTimeseriesResponseSchema = looseObject({ ...sdkTimeseriesResponse, + meta: optional( + looseObject({ + ...sdkTimeseriesResponse.meta.wrapped.entries, + ingestion: optional( + looseObject( + sdkTimeseriesResponse.meta.wrapped.entries.ingestion.wrapped.entries, + ), + ), + }), + ), timeSeries: array(EventsTimeseriesSeriesSchema), }); @@ -986,6 +997,20 @@ export const TimeseriesResultSchema = object({ ), }); +/** Saved transformed queries retain API timestamps and intervals in milliseconds. */ +export const TransformedTimeseriesResultSchema = object({ + type: resultKind("transformed_timeseries"), + queries: array( + object({ + queryIndex: number(), + requestedInterval: optional(string()), + yAxes: array(string()), + transformations: record(string(), array(string())), + response: EventsTimeseriesResponseSchema, + }), + ), +}); + /** Tabular data for table and top_n widgets */ export const TableResultSchema = object({ type: resultKind("table"), @@ -1044,6 +1069,7 @@ export const ErrorResultSchema = object({ export const WidgetDataResultSchema = pipe( variant("type", [ TimeseriesResultSchema, + TransformedTimeseriesResultSchema, TableResultSchema, ScalarResultSchema, TextResultSchema, @@ -1054,6 +1080,9 @@ export const WidgetDataResultSchema = pipe( ); export type TimeseriesResult = InferOutput; +export type TransformedTimeseriesResult = InferOutput< + typeof TransformedTimeseriesResultSchema +>; export type TableResult = InferOutput; export type ScalarResult = InferOutput; export type TextResult = InferOutput; diff --git a/packages/cli/test/e2e/dashboard.test.ts b/packages/cli/test/e2e/dashboard.test.ts new file mode 100644 index 000000000..d1dbeac60 --- /dev/null +++ b/packages/cli/test/e2e/dashboard.test.ts @@ -0,0 +1,184 @@ +import { expect, test } from "vitest"; +import { createE2EContext } from "../fixture.js"; +import organizationFixture from "../fixtures/organization.json"; +import { useTestConfigDir } from "../helpers.js"; +import { TEST_ORG, TEST_TOKEN } from "../mocks/routes.js"; +import { createMockServer } from "../mocks/server.js"; + +const getConfigDir = useTestConfigDir("e2e-dashboard-transformations-"); +const start = Date.UTC(2026, 0, 1); +const transformations = { + "0": ["fill(locf)"], + "1": ["smooth(sma,3)", "fill(zero)"], +}; +const query = { + aggregates: ["count()", "count()"], + columns: ["span.op"], + conditions: "span.duration:>0", + orderby: "-count()", + transformations, +}; +const response = { + responseVersion: "additive-field", + meta: { + dataset: "spans", + start, + end: start + 300_000, + ingestion: { status: "healthy", delaySeconds: 30, source: "measured" }, + retention: "full", + }, + timeSeries: ["http.server", "db", null].flatMap((group, groupIndex) => + [0, 1].map((axisIndex) => ({ + yAxis: "count()", + groupBy: group === null ? null : [{ key: "span.op", value: group }], + meta: { + valueType: "number", + valueUnit: "millisecond", + interval: 300_000, + order: groupIndex, + isOther: group === null, + dataScanned: "partial", + additiveMetadata: true, + }, + values: [ + { + timestamp: start, + value: axisIndex === 0 ? null : -1.25, + incomplete: true, + incompleteReason: "INGESTION_PENDING", + comparisonValue: null, + filled: false, + smoothed: axisIndex === 1, + comparisonFilled: false, + comparisonSmoothed: true, + sampleCount: 2, + sampleRate: 0.5, + confidence: "low", + additiveProvenance: "retained", + }, + { + timestamp: start + 300_000, + value: 0, + incomplete: false, + comparisonValue: 2.75, + filled: true, + smoothed: false, + comparisonFilled: true, + comparisonSmoothed: false, + }, + ], + })), + ), +}; + +test("dashboard view executes saved transformations and retains full results in JSON and terminal tables", async () => { + const requests: URL[] = []; + const server = createMockServer( + [ + { + method: "GET", + path: `/api/0/organizations/${TEST_ORG}/`, + response: { + ...organizationFixture, + features: ["explore-interpolation-and-smoothing"], + }, + }, + { + method: "GET", + path: `/api/0/organizations/${TEST_ORG}/dashboards/42/`, + response: { + id: "42", + title: "Derived dashboard", + period: "24h", + projects: [1], + environment: ["production"], + widgets: [ + { + title: "Saved transformations", + displayType: "line", + widgetType: "spans", + interval: "5m", + layout: { x: 0, y: 0, w: 1, h: 1 }, + queries: [query], + }, + ], + }, + }, + { + method: "GET", + path: `/api/0/organizations/${TEST_ORG}/events-timeseries/`, + response: (request: Request) => { + requests.push(new URL(request.url)); + return { body: response }; + }, + }, + ], + { validTokens: [TEST_TOKEN] }, + ); + await server.start(); + try { + const ctx = createE2EContext(getConfigDir(), server.url); + await ctx.setAuthToken(TEST_TOKEN); + const args = ["dashboard", "view", `${TEST_ORG}/42`]; + const json = await ctx.run([...args, "--json"]); + expect(json.exitCode, json.stderr + json.stdout).toBe(0); + expect(JSON.parse(json.stdout).widgets[0].data).toEqual({ + type: "transformed_timeseries", + queries: [ + { + queryIndex: 0, + requestedInterval: "5m", + yAxes: ["count()", "count()"], + transformations, + response, + }, + ], + }); + expect(requests[0]!.searchParams.getAll("yAxis")).toEqual([ + "count()", + "count()", + ]); + expect(requests[0]!.searchParams.getAll("transformations[0]")).toEqual([ + "fill(locf)", + ]); + expect(requests[0]!.searchParams.getAll("transformations[1]")).toEqual([ + "smooth(sma,3)", + "fill(zero)", + ]); + expect(requests[0]!.searchParams.get("interval")).toBe("5m"); + expect(requests[0]!.searchParams.get("environment")).toBe("production"); + expect(requests[0]!.searchParams.get("project")).toBe("1"); + + const human = await ctx.run(args); + expect(human.exitCode, human.stderr + human.stdout).toBe(0); + for (const value of [ + "Transformations:", + "Query 1, axis 1", + "Query 1, axis 2", + "http.server", + "db", + "Other", + "null", + "-1.25", + "2.75", + "millisecond", + "300000", + "comparisonSmoothed", + "INGESTION_PENDING", + ]) { + expect(human.stdout).toContain(value); + } + const overridden = await ctx.run([...args, "--interval", "2m", "--json"]); + expect(overridden.exitCode, overridden.stderr + overridden.stdout).toBe(0); + expect(requests.at(-1)!.searchParams.get("interval")).toBe("2m"); + expect(JSON.parse(overridden.stdout).widgets[0].data.queries[0]).toEqual({ + queryIndex: 0, + requestedInterval: "2m", + yAxes: ["count()", "count()"], + transformations, + response, + }); + } finally { + server.stop(); + } +}); diff --git a/packages/cli/test/lib/api/dashboards.test.ts b/packages/cli/test/lib/api/dashboards.test.ts index d755a0610..ab66d0d7a 100644 --- a/packages/cli/test/lib/api/dashboards.test.ts +++ b/packages/cli/test/lib/api/dashboards.test.ts @@ -24,12 +24,14 @@ const { listOrganizationEventsMock, listOrganizationEventsTimeseriesMock, reportCliErrorMock, + getOrganizationMock, restoreOrganizationDashboardRevisionMock, } = vi.hoisted(() => ({ listOrganizationDashboardRevisionsMock: vi.fn(), listOrganizationEventsMock: vi.fn(), listOrganizationEventsTimeseriesMock: vi.fn(), reportCliErrorMock: vi.fn(), + getOrganizationMock: vi.fn(), restoreOrganizationDashboardRevisionMock: vi.fn(), })); @@ -45,6 +47,10 @@ vi.mock("@sentry/api", async (importOriginal) => { }; }); +vi.mock("../../../src/lib/api/organizations.js", () => ({ + getOrganization: getOrganizationMock, +})); + vi.mock("../../../src/lib/error-reporting.js", () => ({ reportCliError: reportCliErrorMock, })); @@ -815,3 +821,259 @@ describe("queryAllWidgets timeseries", () => { expect(results.get(0)).toMatchObject({ type: "error" }); }); }); + +describe("saved dashboard transformations", () => { + const region = "https://us.sentry.io"; + const transformations = { "0": ["smooth(sma,3)"] }; + const series = { + yAxis: "count()", + meta: { valueType: "integer", valueUnit: null, interval: 120_000 }, + values: [ + { + timestamp: 1_700_000_000_000, + value: null, + incomplete: false, + filled: false, + smoothed: false, + }, + ], + }; + + function widget(overrides: Partial = {}): DashboardWidget { + return { + title: "Derived", + widgetType: "spans", + displayType: "line", + interval: "2m", + queries: [{ aggregates: ["count()"], transformations }], + ...overrides, + }; + } + + beforeEach(() => { + listOrganizationEventsTimeseriesMock.mockReset(); + getOrganizationMock.mockReset(); + getOrganizationMock.mockResolvedValue({ + features: ["explore-interpolation-and-smoothing"], + }); + listOrganizationEventsTimeseriesMock.mockResolvedValue({ + response: new Response(), + data: { timeSeries: [series] }, + }); + }); + + test.each(["spans", "logs", "tracemetrics"])( + "preserves every saved query for %s with one capability lookup", + async (widgetType) => { + const aggregate = + widgetType === "tracemetrics" + ? "avg(value,latency,distribution,millisecond)" + : "count()"; + const response = { timeSeries: [{ ...series, yAxis: aggregate }] }; + listOrganizationEventsTimeseriesMock.mockResolvedValue({ + response: new Response(), + data: response, + }); + const queries = [ + { aggregates: [aggregate], transformations }, + { aggregates: [aggregate] }, + ]; + const dashboard = makeDashboard([ + widget({ widgetType, queries }), + widget({ widgetType, queries }), + ]); + const result = await queryAllWidgets(region, "org", dashboard); + expect(result.get(0)).toEqual({ + type: "transformed_timeseries", + queries: [ + { + queryIndex: 0, + requestedInterval: "2m", + yAxes: [aggregate], + transformations, + response, + }, + { + queryIndex: 1, + requestedInterval: "2m", + yAxes: [aggregate], + transformations: {}, + response, + }, + ], + }); + expect(result.get(1)).toEqual(result.get(0)); + expect(getOrganizationMock).toHaveBeenCalledTimes(1); + expect( + listOrganizationEventsTimeseriesMock.mock.calls[0]?.[0].query, + ).toMatchObject({ + dataset: widgetType, + "transformations[0]": ["smooth(sma,3)"], + }); + }, + ); + + test("keeps saved and requested intervals independent of terminal size", async () => { + const savedColumns = process.stdout.columns; + try { + for (const width of [40, 240]) { + process.stdout.columns = width; + expect(computeOptimalInterval("24h", widget())).toBe("2m"); + const result = await queryAllWidgets( + region, + "org", + makeDashboard([widget()]), + { + interval: "5m", + start: "2026-01-01T00:00:00Z", + end: "2026-01-01T01:00:00Z", + periodSeconds: 3600, + }, + ); + expect(result.get(0)).toMatchObject({ + type: "transformed_timeseries", + queries: [ + { + requestedInterval: "5m", + response: { timeSeries: [{ meta: { interval: 120_000 } }] }, + }, + ], + }); + expect( + listOrganizationEventsTimeseriesMock.mock.calls.at(-1)?.[0].query, + ).toMatchObject({ + interval: "5m", + statsPeriod: undefined, + start: "2026-01-01T00:00:00Z", + end: "2026-01-01T01:00:00Z", + }); + } + } finally { + process.stdout.columns = savedColumns; + } + }); + + test.each([ + ["spans", "table"], + ["logs", "big_number"], + ["error-events", "line"], + ["spans", "text"], + ["spans", "wheel"], + ])( + "reports transformed %s %s widgets without requesting raw data", + async (widgetType, displayType) => { + const result = await queryAllWidgets( + region, + "org", + makeDashboard([widget({ widgetType, displayType })]), + ); + expect(result.get(0)).toEqual({ + type: "unsupported", + reason: `Saved transformations are not supported for ${widgetType} ${displayType} widgets`, + }); + expect(listOrganizationEventsTimeseriesMock).not.toHaveBeenCalled(); + }, + ); + + test("reports unavailable transformations while rendering an ordinary sibling", async () => { + getOrganizationMock.mockResolvedValue({ features: [] }); + const result = await queryAllWidgets( + region, + "org", + makeDashboard([ + widget(), + { title: "Notes", displayType: "text", description: "Still works" }, + ]), + ); + expect(result.get(0)).toEqual({ + type: "unsupported", + reason: + "Saved transformations require the explore-interpolation-and-smoothing feature, which is unavailable for this organization", + }); + expect(result.get(1)).toEqual({ type: "text", content: "Still works" }); + expect(listOrganizationEventsTimeseriesMock).not.toHaveBeenCalled(); + }); + + test("retains empty results without inventing an executed interval", async () => { + listOrganizationEventsTimeseriesMock.mockResolvedValue({ + response: new Response(), + data: { timeSeries: [] }, + }); + const result = await queryAllWidgets( + region, + "org", + makeDashboard([widget()]), + ); + expect(result.get(0)).toEqual({ + type: "transformed_timeseries", + queries: [ + { + queryIndex: 0, + requestedInterval: "2m", + yAxes: ["count()"], + transformations, + response: { timeSeries: [] }, + }, + ], + }); + }); + + test("reports a server that silently ignores saved transformations", async () => { + listOrganizationEventsTimeseriesMock.mockResolvedValue({ + response: new Response(), + data: { + timeSeries: [ + { + ...series, + values: [ + { timestamp: 1_700_000_000_000, value: 3, incomplete: false }, + ], + }, + ], + }, + }); + const result = await queryAllWidgets( + region, + "org", + makeDashboard([widget()]), + ); + expect(result.get(0)).toMatchObject({ + type: "error", + message: expect.stringContaining("did not confirm execution"), + }); + }); + + test("rejects misaligned response axes and invalid saved axis positions", async () => { + listOrganizationEventsTimeseriesMock.mockResolvedValue({ + response: new Response(), + data: { timeSeries: [{ ...series, yAxis: "sum(span.duration)" }] }, + }); + const mismatched = await queryAllWidgets( + region, + "org", + makeDashboard([widget()]), + ); + expect(mismatched.get(0)).toMatchObject({ + type: "error", + message: expect.stringContaining("incompatible transformed axis order"), + }); + const invalid = await queryAllWidgets( + region, + "org", + makeDashboard([ + widget({ + queries: [ + { + aggregates: ["count()"], + transformations: { "1": ["fill(zero)"] }, + }, + ], + }), + ]), + ); + expect(invalid.get(0)).toMatchObject({ + type: "error", + message: expect.stringContaining("existing yAxis position"), + }); + }); +}); diff --git a/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts index 3cd013cc7..8ef783b0d 100644 --- a/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts +++ b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts @@ -116,6 +116,80 @@ describe("dashboard sixel integration", () => { expect(output).toContain('"1;1;320;144'); }); + test("keeps complete transformed tables outside the graphics canvas and widget height", () => { + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [ + makeWidget({ + title: "Derived", + layout: { x: 0, y: 0, w: 1, h: 1 }, + data: { + type: "transformed_timeseries", + queries: [ + { + queryIndex: 0, + requestedInterval: "5m", + yAxes: ["avg(span.duration)", "avg(span.duration)"], + transformations: { + "0": ["fill(locf)"], + "1": ["smooth(sma,3)"], + }, + response: { + timeSeries: [0, 1].map((axis) => ({ + yAxis: "avg(span.duration)", + groupBy: [{ key: "span.op", value: "http.server" }], + meta: { + interval: 300_000, + valueType: "duration", + valueUnit: "millisecond", + }, + values: [ + { + timestamp: 1_700_000_000_000, + value: null, + incomplete: false, + filled: false, + smoothed: false, + }, + { + timestamp: 1_700_000_300_000, + value: -1.25 - axis, + comparisonValue: 2.75, + incomplete: false, + filled: true, + smoothed: true, + }, + ], + })), + }, + }, + ], + }, + }), + ], + }), + ); + expect(output).toContain(`${ESC}P`); + const appendix = output.slice(output.lastIndexOf(`${ESC}\\`) + 2); + for (const value of [ + "Query 1, axis 1", + "Query 1, axis 2", + "http.server", + "null", + "-1.25", + "-2.25", + "2.75", + "filled", + "smoothed", + "false", + "true", + "millisecond", + "300000", + ]) { + expect(appendix).toContain(value); + } + }); + test("logs the selected sixel graphics renderer and its detected capabilities", () => { const debugSpy = vi.spyOn(logger, "debug");