Skip to content
Draft
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
14 changes: 11 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,19 @@ 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 transformation lists for duplicate
aggregates.
Query flags edit the first query and retain the remaining queries.

`dashboard view` and `dashboard widget edit` list each query's saved
transformations by aggregate position, in the order Sentry applies them. JSON
output keeps the saved `transformations` map, keyed by zero-based aggregate
position. Explicitly saved empty transformation lists appear as `none`.
Servers that omit transformation settings show an unavailable notice for each
query. Widgets whose queries report no saved transformations show no extra output.

## Query Shorthand

The `--query` flag supports shorthand for aggregate functions:
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/commands/dashboard/widget/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,16 @@ export const editCommand = buildCommand({
);
const url = buildDashboardUrl(orgSlug, dashboardId);

// The request omits saved transformations so Sentry keeps them; report
// the saved widget so its output includes them.
const saved =
replacement.id === undefined
? undefined
: updated.widgets?.find((widget) => widget.id === replacement.id);

yield new CommandOutput({
dashboard: updated,
widget: replacement,
widget: saved ?? replacement,
url,
} satisfies DashboardWidgetChangeOutput);
return { hint: `Dashboard: ${url}` };
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/lib/formatters/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { COLORS, muted, terminalLink } from "./colors.js";
import { renderMarkdown } from "./markdown.js";
import type { HumanRenderer } from "./output.js";
import { isPlainOutput } from "./plain-detect.js";
import { formatDashboardSavedTransformations } from "./saved-transformations.js";
import { renderDashboardAsSixel } from "./sixel-dashboard.js";
import { downsample, sparkline } from "./sparkline.js";

Expand Down Expand Up @@ -2018,6 +2019,11 @@ export function formatDashboardWithData(data: DashboardViewData): string {
lines.push(...renderGrid(data.widgets, termWidth));
}

const transformations = formatDashboardSavedTransformations(data.widgets);
if (transformations) {
lines.push("", renderMarkdown(transformations));
}

lines.push("");
return lines.join("\n");
}
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/lib/formatters/human.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
mdTableHeader,
renderMarkdown,
} from "./markdown.js";
import { formatSavedTransformationLines } from "./saved-transformations.js";
import { sparkline } from "./sparkline.js";
import { colorizeSql, isDbSpanOp } from "./sql.js";
import { type Column, writeTable } from "./table.js";
Expand Down Expand Up @@ -2509,6 +2510,11 @@ function formatWidgetLayoutLine(widget: DashboardWidget): string {
return `Layout: position (${x},${y}), size ${w}×${h}`;
}

function formatWidgetTransformationLines(widget: DashboardWidget): string[] {
const lines = formatSavedTransformationLines(widget.queries);
return lines.length > 0 ? ["", "Saved transformations:", ...lines] : [];
}

/**
* Format a widget edit result for human-readable output.
*/
Expand All @@ -2522,6 +2528,7 @@ export function formatWidgetEdited(
if (layoutLine) {
lines.push("", layoutLine);
}
lines.push(...formatWidgetTransformationLines(result.widget));
lines.push("", `URL: ${result.url}`);
return renderMarkdown(lines.join("\n"));
}
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/lib/formatters/saved-transformations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { listSavedAggregateTransformations } from "@sentry/toolkit-core/dashboard-transformations";
import { text } from "@sentry/toolkit-core/markdown";
import type { DashboardWidgetQuery } from "../../types/dashboard.js";

/**
* Markdown bullets for every saved aggregate position, including empty
* transformation lists. Reports unavailable settings when the server omits them.
* Empty when every query reports no saved transformations.
*/
export function formatSavedTransformationLines(
queries: readonly DashboardWidgetQuery[] | undefined,
): string[] {
return (queries ?? []).flatMap((query, queryIndex) => {
const transformations = listSavedAggregateTransformations(
query.aggregates ?? [],
query.transformations,
);
if (transformations === null) {
return [
`- Query ${queryIndex + 1} · unavailable (this Sentry server does not report transformations)`,
];
}
return (transformations ?? []).map(
(entry) => `- Query ${queryIndex + 1} · ${text(entry.description)}`,
);
});
}

/**
* Markdown section describing saved transformations and unavailable settings,
* or an empty string when no widget has either.
*/
export function formatDashboardSavedTransformations(
widgets: readonly {
title: string;
queries?: readonly DashboardWidgetQuery[];
}[],
): string {
const sections = widgets.flatMap((widget) => {
const lines = formatSavedTransformationLines(widget.queries);
return lines.length > 0 ? ["", `**${text(widget.title)}**`, ...lines] : [];
});
return sections.length > 0
? ["### Saved transformations", ...sections].join("\n")
: "";
}
9 changes: 8 additions & 1 deletion packages/cli/src/types/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,14 @@ export const DashboardWidgetQuerySchema = looseObject({
conditions: optional(string()),
columns: optional(array(string())),
aggregates: optional(array(string())),
transformations: optional(record(string(), array(string()))),
transformations: optional(
pipe(
record(string(), array(string())),
description(
"Saved transformations in application order, keyed by zero-based aggregate position",
),
),
),
fieldAliases: optional(array(string())),
orderby: optional(string()),
fields: optional(array(string())),
Expand Down
80 changes: 78 additions & 2 deletions packages/cli/test/commands/dashboard/widget/preservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createMockServer } from "../../../mocks/server.js";

const savedDashboard: DashboardDetail = {
id: "123",
title: "Saved pipelines",
title: "Saved transformations",
projects: [1],
environment: ["production"],
period: "24h",
Expand Down Expand Up @@ -133,7 +133,7 @@ test.each([
["rename", ["edit", "--index", "0", "--new-title", "Renamed"]],
["move", ["edit", "--index", "0", "--row", "4"]],
["filter", ["edit", "--index", "0", "--where", "span.op:queue"]],
])("preserves saved pipelines after %s and reload", async (name, args) => {
])("preserves transformations after %s and reload", async (name, args) => {
const result = await ctx.run([
"dashboard",
"widget",
Expand Down Expand Up @@ -169,3 +169,79 @@ test.each([
: expected.widgets,
});
});

test("describes saved transformations after an edit", async () => {
const result = await ctx.run([
"dashboard",
"widget",
"edit",
"test-org/123",
"--index",
"0",
"--new-title",
"Renamed",
]);
expect(result.exitCode, result.stderr).toBe(0);

const lines = result.stdout
.split("\n")
.filter((line) => line.includes("Query"))
.map((line) => line.trim());
expect(lines).toEqual([
"• Query 1 · count() (aggregate 1): fill( linear ) → smooth( ema , 7 , 0.25 )",
"• Query 1 · count() (aggregate 2): smooth(asap,7) → fill(zero)",
"• Query 1 · p95(span.duration) (aggregate 3): none",
"• Query 2 · count() (aggregate 1): fill( linear )",
]);
});

test("reports unavailable transformation settings after an edit", async () => {
for (const query of stored.widgets![0]!.queries!) {
delete query.transformations;
}

const result = await ctx.run([
"dashboard",
"widget",
"edit",
"test-org/123",
"--index",
"0",
"--new-title",
"Renamed",
]);
expect(result.exitCode, result.stderr).toBe(0);
expect(
result.stdout
.split("\n")
.filter((line) => line.includes("Query"))
.map((line) => line.trim()),
).toEqual([
"• Query 1 · unavailable (this Sentry server does not report transformations)",
"• Query 2 · unavailable (this Sentry server does not report transformations)",
]);
});

test("reports the saved widget transformations in edit JSON", async () => {
const result = await ctx.run([
"dashboard",
"widget",
"edit",
"test-org/123",
"--index",
"0",
"--new-title",
"Renamed",
"--json",
]);
expect(result.exitCode, result.stderr).toBe(0);

const { widget } = JSON.parse(result.stdout);
expect(
widget.queries.map(
(query: { transformations?: unknown }) => query.transformations,
),
).toEqual(
savedDashboard.widgets![0]!.queries!.map((query) => query.transformations),
);
});
106 changes: 106 additions & 0 deletions packages/cli/test/lib/formatters/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,112 @@ describe("formatDashboardWithData", () => {
expect(output).toContain("Period: 24h");
});
});

describe("saved transformations", () => {
test("describes each aggregate position, keeping duplicates apart", () => {
const output = formatDashboardWithData(
makeDashboardData({
widgets: [
makeWidget({
title: "Transformed spans",
queries: [
{
aggregates: ["count()", "count()", "p95(span.duration)"],
transformations: {
"1": ["smooth(asap,7)", "fill(zero)"],
"0": ["fill( linear )", "smooth( ema , 7 , 0.25 )"],
"2": [],
},
},
{
aggregates: ["count()"],
transformations: { "0": ["fill(linear)"] },
},
],
}),
makeWidget({
title: "Plain widget",
queries: [{ transformations: {} }],
}),
],
}),
);
const section = output.slice(output.indexOf("Saved transformations"));
expect(section).toContain("Transformed spans");
expect(section).not.toContain("Plain widget");
const lines = section
.split("\n")
.filter((line) => line.includes("Query"))
.map((line) => line.trim());
expect(lines).toEqual([
"• Query 1 · count() (aggregate 1): fill( linear ) → smooth( ema , 7 , 0.25 )",
"• Query 1 · count() (aggregate 2): smooth(asap,7) → fill(zero)",
"• Query 1 · p95(span.duration) (aggregate 3): none",
"• Query 2 · count() (aggregate 1): fill(linear)",
]);
});

test("leaves output unchanged when no transformations are saved", () => {
const withoutSettings = formatDashboardWithData(
makeDashboardData({ widgets: [makeWidget()] }),
);
const withEmptySettings = formatDashboardWithData(
makeDashboardData({
widgets: [
makeWidget({
queries: [{ aggregates: ["count()"], transformations: {} }],
}),
],
}),
);
expect(withoutSettings).not.toContain("Saved transformations");
expect(withEmptySettings).toBe(withoutSettings);
});

test("reports unavailable settings separately from saved transformations", () => {
const output = formatDashboardWithData(
makeDashboardData({
widgets: [
makeWidget({
queries: [
{ aggregates: ["count()"] },
{
aggregates: ["count()"],
transformations: { "0": ["fill(linear)"] },
},
{ aggregates: ["count()"], transformations: {} },
],
}),
],
}),
);
expect(
output
.split("\n")
.filter((line) => line.includes("Query"))
.map((line) => line.trim()),
).toEqual([
"• Query 1 · unavailable (this Sentry server does not report transformations)",
"• Query 2 · count() (aggregate 1): fill(linear)",
]);
});

test("shows a saved empty transformation list when no other transformations exist", () => {
const output = formatDashboardWithData(
makeDashboardData({
widgets: [
makeWidget({
queries: [
{ aggregates: ["count()"], transformations: { "0": [] } },
],
}),
],
}),
);
expect(output).toContain("Saved transformations");
expect(output).toContain("Query 1 · count() (aggregate 1): none");
});
});
});

// ===========================================================================
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ export const DashboardWidgetQuerySchema = z
name: z.string(),
fields: z.array(z.string()),
aggregates: z.array(z.string()),
// Keyed by aggregate position; omitted by servers that predate the field.
transformations: z.record(z.string(), z.array(z.string())).optional(),
columns: z.array(z.string()),
fieldAliases: z.array(z.string()),
conditions: z.string(),
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-core/src/skillDefinitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
},
{
"name": "get_dashboard_details",
"description": "Get detailed information about a specific Sentry dashboard.\n\nUse this tool when you need to:\n- Inspect a dashboard's widgets and saved query definitions\n- View dashboard projects, environments, filters, layout, and widget IDs\n- Resolve a dashboard by exact title or numeric ID\n\n<examples>\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='12345')\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='Errors Overview')\n</examples>\n\n<hints>\n- Numeric dashboard IDs are resolved directly.\n- Title lookups require one exact case-insensitive match. Use `find_dashboards` first if uncertain.\n- This returns saved widget query definitions, not live widget data.\n</hints>",
"description": "Get detailed information about a specific Sentry dashboard.\n\nUse this tool when you need to:\n- Inspect a dashboard's widgets and saved query definitions\n- View dashboard projects, environments, filters, layout, and widget IDs\n- Resolve a dashboard by exact title or numeric ID\n\n<examples>\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='12345')\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='Errors Overview')\n</examples>\n\n<hints>\n- Numeric dashboard IDs are resolved directly.\n- Title lookups require one exact case-insensitive match. Use `find_dashboards` first if uncertain.\n- This returns saved widget query definitions, not live widget data.\n- Saved transformations are listed per aggregate position, in application order.\n</hints>",
"requiredScopes": ["org:read"]
},
{
Expand Down
Loading
Loading