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}`; +}