diff --git a/apps/cli-docs/src/fragments/commands/dashboard.md b/apps/cli-docs/src/fragments/commands/dashboard.md
index 4841ed7c7..96ad00673 100644
--- a/apps/cli-docs/src/fragments/commands/dashboard.md
+++ b/apps/cli-docs/src/fragments/commands/dashboard.md
@@ -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:
diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts
index 4a5baf84a..70ff57a46 100644
--- a/packages/cli/src/commands/dashboard/widget/edit.ts
+++ b/packages/cli/src/commands/dashboard/widget/edit.ts
@@ -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}` };
diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts
index d6a0d210f..de6f84443 100644
--- a/packages/cli/src/lib/formatters/dashboard.ts
+++ b/packages/cli/src/lib/formatters/dashboard.ts
@@ -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";
@@ -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");
}
diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts
index 13f27bd0c..7b05b7d48 100644
--- a/packages/cli/src/lib/formatters/human.ts
+++ b/packages/cli/src/lib/formatters/human.ts
@@ -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";
@@ -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.
*/
@@ -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"));
}
diff --git a/packages/cli/src/lib/formatters/saved-transformations.ts b/packages/cli/src/lib/formatters/saved-transformations.ts
new file mode 100644
index 000000000..86665f16e
--- /dev/null
+++ b/packages/cli/src/lib/formatters/saved-transformations.ts
@@ -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")
+ : "";
+}
diff --git a/packages/cli/src/types/dashboard.ts b/packages/cli/src/types/dashboard.ts
index d5a3d66c6..331b23aaf 100644
--- a/packages/cli/src/types/dashboard.ts
+++ b/packages/cli/src/types/dashboard.ts
@@ -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())),
diff --git a/packages/cli/test/commands/dashboard/widget/preservation.test.ts b/packages/cli/test/commands/dashboard/widget/preservation.test.ts
index e57fc4270..17d649a44 100644
--- a/packages/cli/test/commands/dashboard/widget/preservation.test.ts
+++ b/packages/cli/test/commands/dashboard/widget/preservation.test.ts
@@ -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",
@@ -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",
@@ -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),
+ );
+});
diff --git a/packages/cli/test/lib/formatters/dashboard.test.ts b/packages/cli/test/lib/formatters/dashboard.test.ts
index 8ba81ebb7..36aa6ed36 100644
--- a/packages/cli/test/lib/formatters/dashboard.test.ts
+++ b/packages/cli/test/lib/formatters/dashboard.test.ts
@@ -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");
+ });
+ });
});
// ===========================================================================
diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts
index e2605bf25..f1948548b 100644
--- a/packages/mcp-core/src/api-client/schema.ts
+++ b/packages/mcp-core/src/api-client/schema.ts
@@ -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(),
diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json
index bf5f5e664..de31bd51c 100644
--- a/packages/mcp-core/src/skillDefinitions.json
+++ b/packages/mcp-core/src/skillDefinitions.json
@@ -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\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='12345')\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='Errors Overview')\n\n\n\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",
+ "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\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='12345')\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='Errors Overview')\n\n\n\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",
"requiredScopes": ["org:read"]
},
{
diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json
index 8876e1d16..5e769e532 100644
--- a/packages/mcp-core/src/toolDefinitions.json
+++ b/packages/mcp-core/src/toolDefinitions.json
@@ -5505,7 +5505,7 @@
{
"name": "get_dashboard_details",
"title": "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\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='12345')\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='Errors Overview')\n\n\n\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",
+ "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\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='12345')\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='Errors Overview')\n\n\n\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",
"inputSchema": {
"type": "object",
"properties": {
@@ -5718,6 +5718,49 @@
},
"orderby": {
"type": ["string", "null"]
+ },
+ "transformations": {
+ "description": "Saved ordered transformations per aggregate position. Duplicate aggregates are separate entries. Omitted when none are saved; null when this Sentry server does not report transformations.",
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "aggregateIndex": {
+ "type": "integer",
+ "minimum": -9007199254740991,
+ "maximum": 9007199254740991,
+ "description": "Zero-based position in `aggregates`."
+ },
+ "aggregate": {
+ "description": "Aggregate at that position; null when the query has none there.",
+ "type": ["string", "null"]
+ },
+ "transformations": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Saved transformations in application order."
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "aggregateIndex",
+ "aggregate",
+ "transformations",
+ "description"
+ ],
+ "additionalProperties": false
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
}
},
"required": [
diff --git a/packages/mcp-core/src/tools/catalog/get-dashboard-details.test.ts b/packages/mcp-core/src/tools/catalog/get-dashboard-details.test.ts
index 83cb3eb06..411beecf2 100644
--- a/packages/mcp-core/src/tools/catalog/get-dashboard-details.test.ts
+++ b/packages/mcp-core/src/tools/catalog/get-dashboard-details.test.ts
@@ -360,6 +360,121 @@ describe("get_dashboard_details", () => {
expect(getDashboard(result).widgets).toEqual([]);
});
+ function mockQuery(query: Record) {
+ const [widget] = dashboardDetailsFixture.widgets;
+ mswServer.use(
+ http.get(
+ "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/101/",
+ () =>
+ HttpResponse.json({
+ ...dashboardDetailsFixture,
+ widgets: [
+ {
+ ...widget,
+ widgetType: "spans",
+ queries: [{ ...widget.queries[0], ...query }],
+ },
+ ],
+ }),
+ ),
+ );
+ }
+
+ async function getFirstQuery() {
+ const dashboard = getDashboard(
+ await getDashboardDetails.handler(
+ {
+ organizationSlug: "sentry-mcp-evals",
+ regionUrl: null,
+ dashboardIdOrTitle: "101",
+ },
+ context,
+ ),
+ );
+ return dashboard.widgets[0]!.queries[0]!;
+ }
+
+ it("describes saved transformations by aggregate position", async () => {
+ mockQuery({
+ fields: ["count()", "count()", "p95(span.duration)"],
+ aggregates: ["count()", "count()", "p95(span.duration)"],
+ transformations: {
+ "2": [],
+ "1": ["smooth(asap,7)", "fill(zero)"],
+ "0": ["fill( linear )", "smooth( ema , 7 , 0.25 )"],
+ },
+ });
+
+ expect((await getFirstQuery()).transformations).toMatchInlineSnapshot(`
+ [
+ {
+ "aggregate": "count()",
+ "aggregateIndex": 0,
+ "description": "count() (aggregate 1): fill( linear ) → smooth( ema , 7 , 0.25 )",
+ "transformations": [
+ "fill( linear )",
+ "smooth( ema , 7 , 0.25 )",
+ ],
+ },
+ {
+ "aggregate": "count()",
+ "aggregateIndex": 1,
+ "description": "count() (aggregate 2): smooth(asap,7) → fill(zero)",
+ "transformations": [
+ "smooth(asap,7)",
+ "fill(zero)",
+ ],
+ },
+ {
+ "aggregate": "p95(span.duration)",
+ "aggregateIndex": 2,
+ "description": "p95(span.duration) (aggregate 3): none",
+ "transformations": [],
+ },
+ ]
+ `);
+ });
+
+ it("keeps saved positions that no longer match an aggregate", async () => {
+ mockQuery({
+ aggregates: ["count()"],
+ transformations: { "3": ["fill(linear)"] },
+ });
+
+ expect((await getFirstQuery()).transformations).toEqual([
+ {
+ aggregateIndex: 3,
+ aggregate: null,
+ transformations: ["fill(linear)"],
+ description: "aggregate 4 (no aggregate): fill(linear)",
+ },
+ ]);
+ });
+
+ it("omits transformations when none are saved", async () => {
+ mockQuery({ transformations: {} });
+
+ expect(await getFirstQuery()).not.toHaveProperty("transformations");
+ });
+
+ it("returns null transformations when the server does not report them", async () => {
+ const { transformations: _, ...query } =
+ dashboardDetailsFixture.widgets[0]!.queries[0]!;
+ const [widget] = dashboardDetailsFixture.widgets;
+ mswServer.use(
+ http.get(
+ "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/101/",
+ () =>
+ HttpResponse.json({
+ ...dashboardDetailsFixture,
+ widgets: [{ ...widget, queries: [query] }],
+ }),
+ ),
+ );
+
+ expect((await getFirstQuery()).transformations).toBeNull();
+ });
+
it("accepts widgets whose display type is null", async () => {
const [widget] = dashboardDetailsFixture.widgets;
mswServer.use(
diff --git a/packages/mcp-core/src/tools/catalog/get-dashboard-details.ts b/packages/mcp-core/src/tools/catalog/get-dashboard-details.ts
index 8c997e166..97c8a5c94 100644
--- a/packages/mcp-core/src/tools/catalog/get-dashboard-details.ts
+++ b/packages/mcp-core/src/tools/catalog/get-dashboard-details.ts
@@ -39,6 +39,7 @@ export default defineTool({
"- Numeric dashboard IDs are resolved directly.",
"- Title lookups require one exact case-insensitive match. Use `find_dashboards` first if uncertain.",
"- This returns saved widget query definitions, not live widget data.",
+ "- Saved transformations are listed per aggregate position, in application order.",
"",
].join("\n"),
inputSchema: {
diff --git a/packages/mcp-core/src/tools/support/dashboards/index.ts b/packages/mcp-core/src/tools/support/dashboards/index.ts
index 3a33b4f83..919cb53b7 100644
--- a/packages/mcp-core/src/tools/support/dashboards/index.ts
+++ b/packages/mcp-core/src/tools/support/dashboards/index.ts
@@ -1,3 +1,4 @@
+import { listSavedAggregateTransformations } from "@sentry/toolkit-core/dashboard-transformations";
import { z } from "zod";
import type {
Dashboard,
@@ -116,6 +117,27 @@ const dashboardWidgetSchema = z.object({
aggregates: z.array(z.string()),
columns: z.array(z.string()),
orderby: nullableString,
+ transformations: z
+ .array(
+ z.object({
+ aggregateIndex: z
+ .number()
+ .int()
+ .describe("Zero-based position in `aggregates`."),
+ aggregate: nullableString.describe(
+ "Aggregate at that position; null when the query has none there.",
+ ),
+ transformations: z
+ .array(z.string())
+ .describe("Saved transformations in application order."),
+ description: z.string(),
+ }),
+ )
+ .nullable()
+ .optional()
+ .describe(
+ "Saved ordered transformations per aggregate position. Duplicate aggregates are separate entries. Omitted when none are saved; null when this Sentry server does not report transformations.",
+ ),
}),
),
});
@@ -172,6 +194,19 @@ function readStringArray(value: unknown): string[] {
: [];
}
+function withTransformations(
+ query: DashboardWidget["queries"][number],
+): Pick<
+ DashboardDetails["widgets"][number]["queries"][number],
+ "transformations"
+> {
+ const transformations = listSavedAggregateTransformations(
+ query.aggregates,
+ query.transformations,
+ );
+ return transformations === undefined ? {} : { transformations };
+}
+
function toWidget(
widget: DashboardWidget,
): DashboardDetails["widgets"][number] {
@@ -200,6 +235,7 @@ function toWidget(
aggregates: query.aggregates,
columns: query.columns,
orderby: query.orderby || null,
+ ...withTransformations(query),
})),
};
}
diff --git a/packages/mcp-server-mocks/src/fixtures/dashboard-details.json b/packages/mcp-server-mocks/src/fixtures/dashboard-details.json
index 35892cb42..1793608b5 100644
--- a/packages/mcp-server-mocks/src/fixtures/dashboard-details.json
+++ b/packages/mcp-server-mocks/src/fixtures/dashboard-details.json
@@ -23,6 +23,7 @@
"name": "",
"fields": ["count()"],
"aggregates": ["count()"],
+ "transformations": {},
"columns": [],
"fieldAliases": [],
"conditions": "error.handled:true",
@@ -57,6 +58,7 @@
"name": "Top Issues",
"fields": ["issue", "count()"],
"aggregates": ["count()"],
+ "transformations": {},
"columns": ["issue"],
"fieldAliases": [],
"conditions": "is:unresolved",
diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json
index 9b8808053..575fbf9d8 100644
--- a/packages/toolkit-core/package.json
+++ b/packages/toolkit-core/package.json
@@ -7,6 +7,10 @@
"node": ">=22.13"
},
"exports": {
+ "./dashboard-transformations": {
+ "types": "./src/dashboard-transformations.ts",
+ "default": "./src/dashboard-transformations.ts"
+ },
"./events-fields": {
"types": "./src/events-fields.ts",
"default": "./src/events-fields.ts"
diff --git a/packages/toolkit-core/src/dashboard-transformations.test.ts b/packages/toolkit-core/src/dashboard-transformations.test.ts
new file mode 100644
index 000000000..7558e642a
--- /dev/null
+++ b/packages/toolkit-core/src/dashboard-transformations.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import { listSavedAggregateTransformations } from "./dashboard-transformations.js";
+
+describe("listSavedAggregateTransformations", () => {
+ it("orders saved positions and keeps duplicate aggregates apart", () => {
+ expect(
+ listSavedAggregateTransformations(["count()", "count()"], {
+ "1": ["smooth(asap,7)", "fill(zero)"],
+ "0": ["fill(linear)"],
+ }),
+ ).toEqual([
+ {
+ aggregateIndex: 0,
+ aggregate: "count()",
+ transformations: ["fill(linear)"],
+ description: "count() (aggregate 1): fill(linear)",
+ },
+ {
+ aggregateIndex: 1,
+ aggregate: "count()",
+ transformations: ["smooth(asap,7)", "fill(zero)"],
+ description: "count() (aggregate 2): smooth(asap,7) → fill(zero)",
+ },
+ ]);
+ });
+
+ it("orders positions numerically, not lexically", () => {
+ const aggregates = Array.from({ length: 11 }, (_, i) => `p${i}()`);
+ const entries = listSavedAggregateTransformations(aggregates, {
+ "10": ["fill(zero)"],
+ "2": ["fill(zero)"],
+ });
+ expect(entries?.map((entry) => entry.aggregateIndex)).toEqual([2, 10]);
+ });
+
+ it("keeps empty lists and positions without an aggregate", () => {
+ expect(
+ listSavedAggregateTransformations(["count()"], {
+ "0": [],
+ "1": ["fill(linear)"],
+ }),
+ ).toEqual([
+ {
+ aggregateIndex: 0,
+ aggregate: "count()",
+ transformations: [],
+ description: "count() (aggregate 1): none",
+ },
+ {
+ aggregateIndex: 1,
+ aggregate: null,
+ transformations: ["fill(linear)"],
+ description: "aggregate 2 (no aggregate): fill(linear)",
+ },
+ ]);
+ });
+
+ it("distinguishes unreported from unsaved transformations", () => {
+ expect(
+ listSavedAggregateTransformations(["count()"], undefined),
+ ).toBeNull();
+ expect(listSavedAggregateTransformations(["count()"], {})).toBeUndefined();
+ });
+});
diff --git a/packages/toolkit-core/src/dashboard-transformations.ts b/packages/toolkit-core/src/dashboard-transformations.ts
new file mode 100644
index 000000000..8f4bb683a
--- /dev/null
+++ b/packages/toolkit-core/src/dashboard-transformations.ts
@@ -0,0 +1,83 @@
+/**
+ * Saved dashboard widget transformations shared by the CLI and MCP.
+ *
+ * Sentry stores a widget query's transformations as ordered string lists
+ * keyed by the aggregate's position (`{"0": ["fill(linear)"]}`), and the
+ * dashboard details endpoint always returns the map (`{}` when none are
+ * saved). Servers that predate the field omit it. See
+ * `DashboardWidgetQueryResponse.transformations` in
+ * `src/sentry/api/serializers/models/dashboard.py` and
+ * `DashboardWidgetQuerySerializer.validate_transformations` in
+ * `src/sentry/api/serializers/rest_framework/dashboard.py`.
+ */
+
+/** Ordered transformations saved for one aggregate position. */
+export type SavedAggregateTransformations = {
+ /** Zero-based aggregate position; the key Sentry stores. */
+ aggregateIndex: number;
+ /** Aggregate at that position, or null when the query has none there. */
+ aggregate: string | null;
+ /** Saved transformations, verbatim and in application order. */
+ transformations: string[];
+ /** Human-readable description of the aggregate and its transformations. */
+ description: string;
+};
+
+// Sentry rejects any other key, so only canonical indexes can be saved.
+const AGGREGATE_INDEX = /^(0|[1-9]\d*)$/;
+
+/**
+ * Lists a query's saved transformations by aggregate position.
+ *
+ * Every saved position is kept, including empty lists and positions without
+ * an aggregate, so duplicate aggregates stay distinguishable by index.
+ *
+ * @returns Entries sorted by aggregate position; `null` when the server did
+ * not report transformations; `undefined` when none are saved.
+ */
+export function listSavedAggregateTransformations(
+ aggregates: readonly string[],
+ transformations: Readonly> | undefined,
+): SavedAggregateTransformations[] | null | undefined {
+ if (transformations === undefined) {
+ return null;
+ }
+ const entries = Object.entries(transformations)
+ .filter(([key]) => AGGREGATE_INDEX.test(key))
+ .map(([key, steps]) => {
+ const aggregateIndex = Number(key);
+ const aggregate = aggregates[aggregateIndex] ?? null;
+ return {
+ aggregateIndex,
+ aggregate,
+ transformations: [...steps],
+ description: describeAggregateTransformations(
+ aggregateIndex,
+ aggregate,
+ steps,
+ ),
+ };
+ })
+ .sort((a, b) => a.aggregateIndex - b.aggregateIndex);
+ return entries.length > 0 ? entries : undefined;
+}
+
+/**
+ * Describes one aggregate's saved transformations, for example
+ * `count() (aggregate 2): fill(linear) → smooth(ema,7,0.25)`.
+ * Positions are one-based here so they match what people count.
+ */
+function describeAggregateTransformations(
+ aggregateIndex: number,
+ aggregate: string | null,
+ transformations: readonly string[],
+): string {
+ const position = `aggregate ${aggregateIndex + 1}`;
+ const target =
+ aggregate === null
+ ? `${position} (no aggregate)`
+ : `${aggregate} (${position})`;
+ const steps =
+ transformations.length > 0 ? transformations.join(" → ") : "none";
+ return `${target}: ${steps}`;
+}