From 0794eb6eaa8af19e9a9d083ecac0be2cd61f1e2d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:40:25 +0000 Subject: [PATCH 1/2] feat(dashboards): Describe saved widget transformations List each widget query's saved transformations by aggregate position in the CLI and MCP dashboard inspection output. Duplicate aggregates stay separate, and widgets without saved transformations keep their output. - MCP get_dashboard_details returns per-aggregate entries with the verbatim ordered transformations and a description. The field is omitted when none are saved and null when the server does not report transformations. - CLI dashboard view and dashboard widget edit describe the saved transformations. widget edit now reports the saved widget from Sentry's response, so its JSON keeps the transformations the request omits. Refs EXP-1417 Co-Authored-By: Nicholas Deschenes --- .../src/fragments/commands/dashboard.md | 5 + .../cli/src/commands/dashboard/widget/edit.ts | 9 +- packages/cli/src/lib/formatters/dashboard.ts | 6 + packages/cli/src/lib/formatters/human.ts | 8 ++ .../lib/formatters/saved-transformations.ts | 42 +++++++ packages/cli/src/types/dashboard.ts | 9 +- .../dashboard/widget/preservation.test.ts | 48 ++++++++ .../cli/test/lib/formatters/dashboard.test.ts | 62 ++++++++++ packages/mcp-core/src/api-client/schema.ts | 2 + packages/mcp-core/src/skillDefinitions.json | 2 +- packages/mcp-core/src/toolDefinitions.json | 45 ++++++- .../catalog/get-dashboard-details.test.ts | 115 ++++++++++++++++++ .../tools/catalog/get-dashboard-details.ts | 1 + .../src/tools/support/dashboards/index.ts | 36 ++++++ .../src/fixtures/dashboard-details.json | 2 + packages/toolkit-core/package.json | 4 + .../src/dashboard-transformations.test.ts | 64 ++++++++++ .../src/dashboard-transformations.ts | 83 +++++++++++++ 18 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/lib/formatters/saved-transformations.ts create mode 100644 packages/toolkit-core/src/dashboard-transformations.test.ts create mode 100644 packages/toolkit-core/src/dashboard-transformations.ts diff --git a/apps/cli-docs/src/fragments/commands/dashboard.md b/apps/cli-docs/src/fragments/commands/dashboard.md index 4841ed7c7..f2a497958 100644 --- a/apps/cli-docs/src/fragments/commands/dashboard.md +++ b/apps/cli-docs/src/fragments/commands/dashboard.md @@ -20,6 +20,11 @@ untouched widgets. Title, layout, and filter edits preserve pipeline stage order and aggregate positions, including separate pipelines 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. Widgets without 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..fd1aac8a0 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,12 @@ function formatWidgetLayoutLine(widget: DashboardWidget): string { return `Layout: position (${x},${y}), size ${w}×${h}`; } +/** Saved transformations section for a widget, or nothing when none are saved. */ +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 +2529,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..efa882e7f --- /dev/null +++ b/packages/cli/src/lib/formatters/saved-transformations.ts @@ -0,0 +1,42 @@ +import { listSavedAggregateTransformations } from "@sentry/toolkit-core/dashboard-transformations"; +import { text } from "@sentry/toolkit-core/markdown"; +import type { DashboardWidgetQuery } from "../../types/dashboard.js"; + +/** + * Markdown bullets describing a widget's saved transformations, one per + * aggregate position that has any. Empty when none are saved, so widgets + * without transformations keep their existing output. + */ +export function formatSavedTransformationLines( + queries: readonly DashboardWidgetQuery[] | undefined, +): string[] { + return (queries ?? []).flatMap((query, queryIndex) => + ( + listSavedAggregateTransformations( + query.aggregates ?? [], + query.transformations, + ) ?? [] + ) + .filter((entry) => entry.transformations.length > 0) + .map((entry) => `- Query ${queryIndex + 1} · ${text(entry.description)}`), + ); +} + +/** + * Markdown section describing every widget's saved transformations, or an + * empty string when no widget has any. + */ +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..7429ffb6a 100644 --- a/packages/cli/test/commands/dashboard/widget/preservation.test.ts +++ b/packages/cli/test/commands/dashboard/widget/preservation.test.ts @@ -169,3 +169,51 @@ 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 2 · count() (aggregate 1): fill( linear )", + ]); +}); + +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..49492675c 100644 --- a/packages/cli/test/lib/formatters/dashboard.test.ts +++ b/packages/cli/test/lib/formatters/dashboard.test.ts @@ -1487,6 +1487,68 @@ 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: [{}] }), + ], + }), + ); + const section = output.slice(output.indexOf("Saved transformations")); + expect(section).toContain("Transformed spans"); + expect(section).not.toContain("Plain widget"); + expect(section).not.toContain("aggregate 3"); + 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 2 · count() (aggregate 1): fill(linear)", + ]); + }); + + test("leaves output unchanged when no transformations are saved", () => { + const queries = [{ aggregates: ["count()"] }]; + const withoutSettings = formatDashboardWithData( + makeDashboardData({ widgets: [makeWidget({ queries })] }), + ); + const withEmptySettings = formatDashboardWithData( + makeDashboardData({ + widgets: [ + makeWidget({ + queries: [ + { aggregates: ["count()"], transformations: { "0": [] } }, + ], + }), + ], + }), + ); + expect(withoutSettings).not.toContain("Saved transformations"); + expect(withEmptySettings).toBe(withoutSettings); + }); + }); }); // =========================================================================== 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}`; +} From 0052c1000abd160c0cc4f32d4b80b68b282fb1c8 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 9 Nov 2026 14:11:53 -0400 Subject: [PATCH 2/2] fix(cli): preserve saved transformation inspection states Report unavailable settings when a server omits transformations. Show explicitly saved empty transformation lists as none, and use transformation terminology consistently in docs and tests. Co-Authored-By: GPT-6.1 Sol --- .../src/fragments/commands/dashboard.md | 11 ++-- packages/cli/src/lib/formatters/human.ts | 1 - .../lib/formatters/saved-transformations.ts | 34 ++++++----- .../dashboard/widget/preservation.test.ts | 32 ++++++++++- .../cli/test/lib/formatters/dashboard.test.ts | 56 +++++++++++++++++-- 5 files changed, 106 insertions(+), 28 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/dashboard.md b/apps/cli-docs/src/fragments/commands/dashboard.md index f2a497958..96ad00673 100644 --- a/apps/cli-docs/src/fragments/commands/dashboard.md +++ b/apps/cli-docs/src/fragments/commands/dashboard.md @@ -15,15 +15,18 @@ 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. Widgets without saved transformations show no extra output. +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 diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index fd1aac8a0..7b05b7d48 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -2510,7 +2510,6 @@ function formatWidgetLayoutLine(widget: DashboardWidget): string { return `Layout: position (${x},${y}), size ${w}×${h}`; } -/** Saved transformations section for a widget, or nothing when none are saved. */ function formatWidgetTransformationLines(widget: DashboardWidget): string[] { const lines = formatSavedTransformationLines(widget.queries); return lines.length > 0 ? ["", "Saved transformations:", ...lines] : []; diff --git a/packages/cli/src/lib/formatters/saved-transformations.ts b/packages/cli/src/lib/formatters/saved-transformations.ts index efa882e7f..86665f16e 100644 --- a/packages/cli/src/lib/formatters/saved-transformations.ts +++ b/packages/cli/src/lib/formatters/saved-transformations.ts @@ -3,28 +3,32 @@ import { text } from "@sentry/toolkit-core/markdown"; import type { DashboardWidgetQuery } from "../../types/dashboard.js"; /** - * Markdown bullets describing a widget's saved transformations, one per - * aggregate position that has any. Empty when none are saved, so widgets - * without transformations keep their existing output. + * 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) => - ( - listSavedAggregateTransformations( - query.aggregates ?? [], - query.transformations, - ) ?? [] - ) - .filter((entry) => entry.transformations.length > 0) - .map((entry) => `- Query ${queryIndex + 1} · ${text(entry.description)}`), - ); + 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 every widget's saved transformations, or an - * empty string when no widget has any. + * Markdown section describing saved transformations and unavailable settings, + * or an empty string when no widget has either. */ export function formatDashboardSavedTransformations( widgets: readonly { diff --git a/packages/cli/test/commands/dashboard/widget/preservation.test.ts b/packages/cli/test/commands/dashboard/widget/preservation.test.ts index 7429ffb6a..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", @@ -190,10 +190,38 @@ test("describes saved transformations after an edit", async () => { 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", diff --git a/packages/cli/test/lib/formatters/dashboard.test.ts b/packages/cli/test/lib/formatters/dashboard.test.ts index 49492675c..36aa6ed36 100644 --- a/packages/cli/test/lib/formatters/dashboard.test.ts +++ b/packages/cli/test/lib/formatters/dashboard.test.ts @@ -1510,14 +1510,16 @@ describe("formatDashboardWithData", () => { }, ], }), - makeWidget({ title: "Plain widget", queries: [{}] }), + 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"); - expect(section).not.toContain("aggregate 3"); const lines = section .split("\n") .filter((line) => line.includes("Query")) @@ -1525,16 +1527,58 @@ describe("formatDashboardWithData", () => { 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 queries = [{ aggregates: ["count()"] }]; const withoutSettings = formatDashboardWithData( - makeDashboardData({ widgets: [makeWidget({ queries })] }), + 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({ @@ -1545,8 +1589,8 @@ describe("formatDashboardWithData", () => { ], }), ); - expect(withoutSettings).not.toContain("Saved transformations"); - expect(withEmptySettings).toBe(withoutSettings); + expect(output).toContain("Saved transformations"); + expect(output).toContain("Query 1 · count() (aggregate 1): none"); }); }); });