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
22 changes: 19 additions & 3 deletions apps/cli-docs/src/fragments/commands/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ View a dashboard
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
- `-r, --refresh <value> - Auto-refresh interval in seconds (default: 60, min: 10)`
- `-t, --period <value> - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01"`
- `--interval <value> - Bucket interval, overriding saved widget intervals (e.g., 5m)`
- `--renderer <value> - 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)`

Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/commands/dashboard/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -359,6 +367,7 @@ export const viewCommand = buildCommand({
queryAllWidgets(regionUrl, orgSlug, dashboard, {
...widgetTimeOpts,
periodSeconds,
interval: flags.interval,
}),
);

Expand Down
160 changes: 139 additions & 21 deletions packages/cli/src/lib/api/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -56,6 +57,7 @@ import {
unwrapValidatedResult,
validateResponse,
} from "./infrastructure.js";
import { getOrganization } from "./organizations.js";

/**
* List dashboards in an organization with cursor-based pagination.
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -505,6 +511,7 @@ function buildTopEventsParams(
*/
/** Common params for widget query functions */
type WidgetQueryParams = {
getTransformationSupport: () => Promise<boolean>;
regionUrl: string;
orgSlug: string;
widget: DashboardWidget;
Expand All @@ -519,14 +526,15 @@ type WidgetQueryParams = {
options?: WidgetQueryOptions;
};

type EventsTimeseriesQuery = ListOrganizationEventsTimeseriesData["query"];
type EventsTimeseriesQuery = ListOrganizationEventsTimeseriesData["query"] &
Partial<Record<`transformations[${number}]`, string[]>>;

/** Widget query params once the widget's type has resolved to a dataset. */
type DatasetWidgetQueryParams = WidgetQueryParams & { dataset: EventsDataset };

async function queryWidgetTimeseries(
params: DatasetWidgetQueryParams,
): Promise<TimeseriesResult> {
): Promise<TimeseriesResult | TransformedTimeseriesResult> {
const {
regionUrl,
orgSlug,
Expand All @@ -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,
Expand All @@ -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<string, string[]>,
): 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",
);
}
}
Comment thread
nsdeschenes marked this conversation as resolved.
Resolved
}

/**
Expand Down Expand Up @@ -657,6 +748,17 @@ async function queryWidgetData(
params: WidgetQueryParams,
): Promise<WidgetDataResult> {
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") {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -799,6 +908,14 @@ export async function queryAllWidgets(
};

const results = new Map<number, WidgetDataResult>();
let transformationSupport: Promise<boolean> | undefined;
const getTransformationSupport = () =>
(transformationSupport ??= getOrganization(orgSlug).then(
(organization) =>
organization.features?.includes(
"explore-interpolation-and-smoothing",
) ?? false,
));
if (widgets.length === 0) {
return results;
}
Expand All @@ -810,6 +927,7 @@ export async function queryAllWidgets(
const batchResults = await Promise.allSettled(
batch.map((widget) =>
queryWidgetData({
getTransformationSupport,
regionUrl,
orgSlug,
widget,
Expand Down
Loading
Loading