From d4c31decff9f21f70114f6a0eee7edc7be0bb39e Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Wed, 5 Aug 2026 17:08:11 +1000 Subject: [PATCH 1/6] feat(api): accept exemplar settings on API- and agent-authored tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enableExemplars and exemplarTraceSourceId are accepted on line and stacked-bar tiles through the external v2 API and the MCP dashboard tools. The fields have to survive three places, and originally only cleared the first: the input schema, convertToInternalTileConfig's pick list on the way in, and convertToExternalTileChartConfig on the way out. They validated on write and were dropped before persistence, so the overlay could only ever be enabled by a human in the chart editor — the round trip is what makes them usable by an agent. Also documented in the OpenAPI spec, which is hand-maintained JSDoc rather than generated from the zod schemas, so adding the schema fields alone left the published contract silent about them. The MCP describe strings follow the file's own convention for source ids and say to call clickstack_list_sources and pick a trace-kind source, and note that the flag only renders on an exemplar-eligible tile. Stacked on the overlay: these fields do nothing without it. --- .changeset/exemplars-agent-surface.md | 11 +++++++ packages/api/openapi.json | 20 ++++++++++++ .../api/src/mcp/tools/dashboards/schemas.ts | 32 +++++++++++++++++++ .../src/routers/external-api/v2/dashboards.ts | 28 ++++++++++++++++ .../external-api/v2/utils/dashboards.ts | 6 ++++ packages/api/src/utils/zod.ts | 5 +++ 6 files changed, 102 insertions(+) create mode 100644 .changeset/exemplars-agent-surface.md diff --git a/.changeset/exemplars-agent-surface.md b/.changeset/exemplars-agent-surface.md new file mode 100644 index 0000000000..0a501ddf2e --- /dev/null +++ b/.changeset/exemplars-agent-surface.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/api': minor +--- + +feat: accept exemplar settings on API- and agent-authored dashboard tiles + +`enableExemplars` and `exemplarTraceSourceId` are now accepted on line and +stacked-bar tiles through the external v2 API and the MCP dashboard tools, survive +the tile conversion in both directions, and are documented in the OpenAPI spec. +Previously they validated on write and were dropped before persistence, so the +overlay could only ever be switched on by a human in the chart editor. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index c2fbbb9aee..cedffac281 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -1648,6 +1648,16 @@ "minimum": 1, "description": "Maximum number of series rendered (top-N by value). Omit for no limit.", "example": 5 + }, + "enableExemplars": { + "type": "boolean", + "description": "Overlay exemplars: markers for individual trace-linked data points. Only renders when the tile is exemplar-eligible — a single non-ratio histogram metric series with no groupBy — and is accepted but inert otherwise.\n", + "default": false + }, + "exemplarTraceSourceId": { + "type": "string", + "description": "ID of the Trace source an exemplar marker links to. Defaults to the chart source's linked trace source when omitted.\n", + "example": "65f5e4a3b9e77c001a222222" } } }, @@ -1712,6 +1722,16 @@ "minimum": 1, "description": "Maximum number of series rendered (top-N by value). Omit for no limit.", "example": 5 + }, + "enableExemplars": { + "type": "boolean", + "description": "Overlay exemplars: markers for individual trace-linked data points. Only renders when the tile is exemplar-eligible — a single non-ratio histogram metric series with no groupBy — and is accepted but inert otherwise.\n", + "default": false + }, + "exemplarTraceSourceId": { + "type": "string", + "description": "ID of the Trace source an exemplar marker links to. Defaults to the chart source's linked trace source when omitted.\n", + "example": "65f5e4a3b9e77c001a222222" } } }, diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 75b85d89d9..62c79c7b74 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -543,6 +543,22 @@ const mcpLineTileSchema = mcpTileLayoutSchema.extend({ 'Scale the y-axis to the data range instead of starting at zero.', ), seriesLimit: seriesLimitSchema.describe(timeChartSeriesLimitDescription), + enableExemplars: z + .boolean() + .optional() + .describe( + 'Overlay exemplars: markers for individual trace-linked data points. ' + + 'Only renders on an exemplar-eligible tile — a single non-ratio ' + + 'histogram metric series with no groupBy — and is inert otherwise.', + ), + exemplarTraceSourceId: z + .string() + .optional() + .describe( + 'Trace source an exemplar marker links to. Must be a Trace source: use ' + + 'clickstack_list_sources and pick one whose kind is "trace". Defaults ' + + "to the chart source's linked trace source when omitted.", + ), }), }); @@ -561,6 +577,22 @@ const mcpBarTileSchema = mcpTileLayoutSchema.extend({ .optional() .describe(tileLevelNumberFormatDescription), seriesLimit: seriesLimitSchema.describe(timeChartSeriesLimitDescription), + enableExemplars: z + .boolean() + .optional() + .describe( + 'Overlay exemplars: markers for individual trace-linked data points. ' + + 'Only renders on an exemplar-eligible tile — a single non-ratio ' + + 'histogram metric series with no groupBy — and is inert otherwise.', + ), + exemplarTraceSourceId: z + .string() + .optional() + .describe( + 'Trace source an exemplar marker links to. Must be a Trace source: use ' + + 'clickstack_list_sources and pick one whose kind is "trace". Defaults ' + + "to the chart source's linked trace source when omitted.", + ), }), }); diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index f3d92bed45..a17274e4d9 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -634,6 +634,20 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * minimum: 1 * description: Maximum number of series rendered (top-N by value). Omit for no limit. * example: 5 + * enableExemplars: + * type: boolean + * description: > + * Overlay exemplars: markers for individual trace-linked data points. + * Only renders when the tile is exemplar-eligible — a single + * non-ratio histogram metric series with no groupBy — and is + * accepted but inert otherwise. + * default: false + * exemplarTraceSourceId: + * type: string + * description: > + * ID of the Trace source an exemplar marker links to. Defaults to + * the chart source's linked trace source when omitted. + * example: "65f5e4a3b9e77c001a222222" * * BarBuilderChartConfig: * type: object @@ -686,6 +700,20 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * minimum: 1 * description: Maximum number of series rendered (top-N by value). Omit for no limit. * example: 5 + * enableExemplars: + * type: boolean + * description: > + * Overlay exemplars: markers for individual trace-linked data points. + * Only renders when the tile is exemplar-eligible — a single + * non-ratio histogram metric series with no groupBy — and is + * accepted but inert otherwise. + * default: false + * exemplarTraceSourceId: + * type: string + * description: > + * ID of the Trace source an exemplar marker links to. Defaults to + * the chart source's linked trace source when omitted. + * example: "65f5e4a3b9e77c001a222222" * * TableBuilderChartConfig: * type: object diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 42522b0abe..5dbe58f6ac 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -300,6 +300,8 @@ const convertToExternalTileChartConfig = ( compareToPreviousPeriod: config.compareToPreviousPeriod, numberFormat: config.numberFormat, seriesLimit: config.seriesLimit ?? undefined, + enableExemplars: config.enableExemplars, + exemplarTraceSourceId: config.exemplarTraceSourceId, }; case DisplayType.StackedBar: return { @@ -317,6 +319,8 @@ const convertToExternalTileChartConfig = ( : [DEFAULT_SELECT_ITEM], numberFormat: config.numberFormat, seriesLimit: config.seriesLimit ?? undefined, + enableExemplars: config.enableExemplars, + exemplarTraceSourceId: config.exemplarTraceSourceId, }; case DisplayType.Number: return { @@ -694,6 +698,8 @@ export function convertToInternalTileConfig( 'alignDateRangeToGranularity', 'compareToPreviousPeriod', 'fitYAxisToData', + 'enableExemplars', + 'exemplarTraceSourceId', ]), displayType: externalConfig.displayType === 'stacked_bar' diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 756090b69c..739514a048 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -261,6 +261,11 @@ const externalDashboardTimeChartConfigSchema = z.object({ alignDateRangeToGranularity: z.boolean().optional(), fillNulls: z.boolean().optional(), numberFormat: NumberFormatSchema.optional(), + // Exemplar overlay (trace-linked markers). Rendering additionally requires an + // exemplar-eligible shape — single non-ratio histogram series, no group by — + // so setting this on an ineligible tile is inert rather than an error. + enableExemplars: z.boolean().optional(), + exemplarTraceSourceId: objectIdSchema.optional(), }); const externalDashboardLineChartConfigSchema = From 62f79a3e5fad2ce970a8f1a613b5a948bba12752 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 6 Aug 2026 07:46:57 +1000 Subject: [PATCH 2/6] fix(api): validate the exemplar trace source, not just its shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on the agent-facing exemplar settings. `exemplarTraceSourceId` was a bare `z.string()` on the MCP surface, so an agent passing a source *name* had the tile saved and every marker's trace link silently dead. It now uses the shared objectIdSchema, matching the external API. Format alone was not enough though: a well-formed id for a source that does not exist, or for a metric or log source, failed the same way. It now goes through the same existence check as every other tile source reference, plus a Trace-kind gate modelled on the heatmap one — which is what the field's own description has been promising all along. The eligibility text in both descriptions omitted the aggregation constraint, so an agent following it with `count` — which the neighbouring aggFn description recommends for histograms — got an inert overlay and no error. Both now name the compatible functions and say why count and sum are excluded. Tests: both tile types through the MCP schema, both directions of the tile conversion (the changeset's headline claim had no coverage, and the two tile types have separate blocks in both converters), and the existence and kind gates end to end. --- .changeset/exemplars-agent-surface.md | 14 ++- packages/api/openapi.json | 8 +- .../__tests__/exemplarSettings.test.ts | 85 ++++++++++++++++++ .../api/src/mcp/tools/dashboards/schemas.ts | 30 ++++--- .../__tests__/dashboards.int.test.ts | 89 +++++++++++++++++++ .../src/routers/external-api/v2/dashboards.ts | 22 +++-- .../v2/utils/__tests__/dashboards.test.ts | 68 ++++++++++++++ .../external-api/v2/utils/dashboards.ts | 49 ++++++++++ 8 files changed, 337 insertions(+), 28 deletions(-) create mode 100644 packages/api/src/mcp/tools/dashboards/__tests__/exemplarSettings.test.ts diff --git a/.changeset/exemplars-agent-surface.md b/.changeset/exemplars-agent-surface.md index 0a501ddf2e..e529f57706 100644 --- a/.changeset/exemplars-agent-surface.md +++ b/.changeset/exemplars-agent-surface.md @@ -5,7 +5,13 @@ feat: accept exemplar settings on API- and agent-authored dashboard tiles `enableExemplars` and `exemplarTraceSourceId` are now accepted on line and -stacked-bar tiles through the external v2 API and the MCP dashboard tools, survive -the tile conversion in both directions, and are documented in the OpenAPI spec. -Previously they validated on write and were dropped before persistence, so the -overlay could only ever be switched on by a human in the chart editor. +stacked-bar tiles through the external v2 API and the MCP dashboard tools, +survive the tile conversion in both directions, and are documented in the +OpenAPI spec. Previously they validated on write and were dropped before +persistence, so they could not be set through any surface. + +`exemplarTraceSourceId` is checked three ways, because a marker's "view trace" +link is only as good as the source behind it: an ObjectId on both surfaces, the +source must exist for the team, and it must actually be a Trace source. The +existence and kind checks mirror the heatmap gate. Without them a well-formed id +for a metric source saved cleanly and left every marker linking nowhere. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index cedffac281..e9d21a8076 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -1651,12 +1651,12 @@ }, "enableExemplars": { "type": "boolean", - "description": "Overlay exemplars: markers for individual trace-linked data points. Only renders when the tile is exemplar-eligible — a single non-ratio histogram metric series with no groupBy — and is accepted but inert otherwise.\n", + "description": "Overlay exemplars: markers for individual trace-linked data points. Only renders when the tile is exemplar-eligible — a single non-ratio histogram metric series with no groupBy, aggregated with avg, min, max, quantile or last_value. Not count or sum: a counted point is not attributable to any one trace, so the overlay is accepted but stays inert.\n", "default": false }, "exemplarTraceSourceId": { "type": "string", - "description": "ID of the Trace source an exemplar marker links to. Defaults to the chart source's linked trace source when omitted.\n", + "description": "ID of the Trace source an exemplar marker links to. Must exist and be a Trace source; a request naming anything else is rejected. Defaults to the chart source's linked trace source when omitted.\n", "example": "65f5e4a3b9e77c001a222222" } } @@ -1725,12 +1725,12 @@ }, "enableExemplars": { "type": "boolean", - "description": "Overlay exemplars: markers for individual trace-linked data points. Only renders when the tile is exemplar-eligible — a single non-ratio histogram metric series with no groupBy — and is accepted but inert otherwise.\n", + "description": "Overlay exemplars: markers for individual trace-linked data points. Only renders when the tile is exemplar-eligible — a single non-ratio histogram metric series with no groupBy, aggregated with avg, min, max, quantile or last_value. Not count or sum: a counted point is not attributable to any one trace, so the overlay is accepted but stays inert.\n", "default": false }, "exemplarTraceSourceId": { "type": "string", - "description": "ID of the Trace source an exemplar marker links to. Defaults to the chart source's linked trace source when omitted.\n", + "description": "ID of the Trace source an exemplar marker links to. Must exist and be a Trace source; a request naming anything else is rejected. Defaults to the chart source's linked trace source when omitted.\n", "example": "65f5e4a3b9e77c001a222222" } } diff --git a/packages/api/src/mcp/tools/dashboards/__tests__/exemplarSettings.test.ts b/packages/api/src/mcp/tools/dashboards/__tests__/exemplarSettings.test.ts new file mode 100644 index 0000000000..625b8027bc --- /dev/null +++ b/packages/api/src/mcp/tools/dashboards/__tests__/exemplarSettings.test.ts @@ -0,0 +1,85 @@ +import { Types } from 'mongoose'; + +import { mcpTilesParam } from '@/mcp/tools/dashboards/schemas'; + +/** + * `enableExemplars` and `exemplarTraceSourceId` are the two exemplar settings an + * MCP agent can put on a tile. The trace source id has to survive as something + * the source lookup can actually resolve — it is read back as a Mongo ObjectId — + * so accepting any string here would persist a tile whose markers silently never + * link to a trace. + */ +const tile = + (displayType: 'line' | 'stacked_bar') => + (config: Record) => [ + { + name: 'Latency', + config: { + displayType, + sourceId: new Types.ObjectId().toString(), + select: [{ aggFn: 'count', alias: 'Requests' }], + ...config, + }, + }, + ]; + +// The two tile types declare the exemplar fields in separate schema blocks, so +// an edit that only updates one would otherwise go unnoticed. +const lineTile = tile('line'); +const barTile = tile('stacked_bar'); + +describe('MCP tile exemplar settings', () => { + it('accepts a valid ObjectId as the exemplar trace source', () => { + const traceSourceId = new Types.ObjectId().toString(); + const parsed = mcpTilesParam.parse( + lineTile({ enableExemplars: true, exemplarTraceSourceId: traceSourceId }), + ); + expect(parsed[0].config).toMatchObject({ + enableExemplars: true, + exemplarTraceSourceId: traceSourceId, + }); + }); + + it.each([ + ['a source name rather than an id', 'Traces'], + ['a truncated id', '507f1f77bcf86cd7994390'], + ['an over-long id', '507f1f77bcf86cd799439011ff'], + ['an empty string', ''], + ['a non-hex id of the right length', 'zzzzzzzzzzzzzzzzzzzzzzzz'], + ])('rejects %s as the exemplar trace source', (_label, value) => { + expect(() => + mcpTilesParam.parse(lineTile({ exemplarTraceSourceId: value })), + ).toThrow(/Invalid ObjectId/); + }); + + // Worth recording because it is surprising: Mongo also accepts a 12-character + // string as 12 raw bytes, so `Types.ObjectId.isValid` — and therefore every + // objectIdSchema field in this codebase, not just this one — lets one through. + // Such an id simply resolves to no source, which is the same outcome as any + // other id that does not exist, so it is not worth diverging from the shared + // validator here. + it('lets a 12-character string through, as every other id field does', () => { + expect(() => + mcpTilesParam.parse(lineTile({ exemplarTraceSourceId: 'trace-source' })), + ).not.toThrow(); + }); + + it('leaves both settings optional', () => { + const parsed = mcpTilesParam.parse(lineTile({})); + expect(parsed[0].config).not.toHaveProperty('enableExemplars'); + expect(parsed[0].config).not.toHaveProperty('exemplarTraceSourceId'); + }); + + it.each([ + ['line', lineTile], + ['stacked_bar', barTile], + ])('validates the trace source on a %s tile', (_label, build) => { + expect(() => + mcpTilesParam.parse(build({ exemplarTraceSourceId: 'Traces' })), + ).toThrow(/Invalid ObjectId/); + const id = new Types.ObjectId().toString(); + expect( + mcpTilesParam.parse(build({ exemplarTraceSourceId: id }))[0].config, + ).toMatchObject({ exemplarTraceSourceId: id }); + }); +}); diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 62c79c7b74..9bdc0ab75f 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -549,15 +549,18 @@ const mcpLineTileSchema = mcpTileLayoutSchema.extend({ .describe( 'Overlay exemplars: markers for individual trace-linked data points. ' + 'Only renders on an exemplar-eligible tile — a single non-ratio ' + - 'histogram metric series with no groupBy — and is inert otherwise.', + 'histogram metric series with no groupBy, aggregated with avg, min, ' + + 'max, quantile or last_value. Not count or sum: a counted point is ' + + 'not attributable to any one trace, so the overlay stays inert with ' + + 'no error.', ), - exemplarTraceSourceId: z - .string() + exemplarTraceSourceId: objectIdSchema .optional() .describe( - 'Trace source an exemplar marker links to. Must be a Trace source: use ' + - 'clickstack_list_sources and pick one whose kind is "trace". Defaults ' + - "to the chart source's linked trace source when omitted.", + 'Trace source an exemplar marker links to. Must be an existing Trace ' + + 'source — use clickstack_list_sources and pick one whose kind is ' + + '"trace"; anything else is rejected on save. Defaults to the chart ' + + "source's linked trace source when omitted.", ), }), }); @@ -583,15 +586,18 @@ const mcpBarTileSchema = mcpTileLayoutSchema.extend({ .describe( 'Overlay exemplars: markers for individual trace-linked data points. ' + 'Only renders on an exemplar-eligible tile — a single non-ratio ' + - 'histogram metric series with no groupBy — and is inert otherwise.', + 'histogram metric series with no groupBy, aggregated with avg, min, ' + + 'max, quantile or last_value. Not count or sum: a counted point is ' + + 'not attributable to any one trace, so the overlay stays inert with ' + + 'no error.', ), - exemplarTraceSourceId: z - .string() + exemplarTraceSourceId: objectIdSchema .optional() .describe( - 'Trace source an exemplar marker links to. Must be a Trace source: use ' + - 'clickstack_list_sources and pick one whose kind is "trace". Defaults ' + - "to the chart source's linked trace source when omitted.", + 'Trace source an exemplar marker links to. Must be an existing Trace ' + + 'source — use clickstack_list_sources and pick one whose kind is ' + + '"trace"; anything else is rejected on save. Defaults to the chart ' + + "source's linked trace source when omitted.", ), }), }); diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts index e860f3aba5..10582ac450 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts @@ -2824,6 +2824,95 @@ describe('External API v2 Dashboards - new format', () => { ); }); + // `exemplarTraceSourceId` is where a marker's "view trace" link points, so a + // well-formed id for the wrong kind of source saves a tile whose markers all + // lead nowhere. Format validation alone cannot catch that. + it('rejects an exemplarTraceSourceId that is not a Trace source', async () => { + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard with exemplars on a metric trace source', + tiles: [ + { + name: 'Latency', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: metricSource._id.toString(), + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + enableExemplars: true, + exemplarTraceSourceId: metricSource._id.toString(), + }, + }, + ], + tags: [], + }) + .expect(400); + + expect(response.body.message).toContain( + 'exemplarTraceSourceId must reference a Trace source', + ); + }); + + it('rejects an exemplarTraceSourceId for a source that does not exist', async () => { + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard with exemplars on a missing source', + tiles: [ + { + name: 'Latency', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: metricSource._id.toString(), + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + enableExemplars: true, + exemplarTraceSourceId: new ObjectId().toString(), + }, + }, + ], + tags: [], + }) + .expect(400); + + expect(response.body.message).toContain('Could not find'); + }); + + it('accepts an exemplarTraceSourceId pointing at a Trace source', async () => { + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard with exemplars', + tiles: [ + { + name: 'Latency', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: metricSource._id.toString(), + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + enableExemplars: true, + exemplarTraceSourceId: traceSource._id.toString(), + }, + }, + ], + tags: [], + }) + .expect(200); + + expect(response.body.data.tiles[0].config).toMatchObject({ + enableExemplars: true, + exemplarTraceSourceId: traceSource._id.toString(), + }); + }); + it('round-trips a heatmap tile with only required fields', async () => { // Covers the minimal payload path: countExpression, heatmapScaleType, // where, whereLanguage, and numberFormat are all omitted on the diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index a17274e4d9..0f2efa03e0 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -639,14 +639,17 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * description: > * Overlay exemplars: markers for individual trace-linked data points. * Only renders when the tile is exemplar-eligible — a single - * non-ratio histogram metric series with no groupBy — and is - * accepted but inert otherwise. + * non-ratio histogram metric series with no groupBy, aggregated with + * avg, min, max, quantile or last_value. Not count or sum: a counted + * point is not attributable to any one trace, so the overlay is + * accepted but stays inert. * default: false * exemplarTraceSourceId: * type: string * description: > - * ID of the Trace source an exemplar marker links to. Defaults to - * the chart source's linked trace source when omitted. + * ID of the Trace source an exemplar marker links to. Must exist and + * be a Trace source; a request naming anything else is rejected. + * Defaults to the chart source's linked trace source when omitted. * example: "65f5e4a3b9e77c001a222222" * * BarBuilderChartConfig: @@ -705,14 +708,17 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * description: > * Overlay exemplars: markers for individual trace-linked data points. * Only renders when the tile is exemplar-eligible — a single - * non-ratio histogram metric series with no groupBy — and is - * accepted but inert otherwise. + * non-ratio histogram metric series with no groupBy, aggregated with + * avg, min, max, quantile or last_value. Not count or sum: a counted + * point is not attributable to any one trace, so the overlay is + * accepted but stays inert. * default: false * exemplarTraceSourceId: * type: string * description: > - * ID of the Trace source an exemplar marker links to. Defaults to - * the chart source's linked trace source when omitted. + * ID of the Trace source an exemplar marker links to. Must exist and + * be a Trace source; a request naming anything else is rejected. + * Defaults to the chart source's linked trace source when omitted. * example: "65f5e4a3b9e77c001a222222" * * TableBuilderChartConfig: diff --git a/packages/api/src/routers/external-api/v2/utils/__tests__/dashboards.test.ts b/packages/api/src/routers/external-api/v2/utils/__tests__/dashboards.test.ts index 29618d8bb3..3abfae023f 100644 --- a/packages/api/src/routers/external-api/v2/utils/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/v2/utils/__tests__/dashboards.test.ts @@ -400,3 +400,71 @@ describe('convertToExternalDashboard orphan-ref heal', () => { expect(ext.tiles.map(t => t.id)).toEqual(['normal-tile']); }); }); + +/** + * The exemplar settings are only useful if they survive the tile conversion in + * both directions — accepting them at the schema and dropping them in the + * converter was the original bug this feature fixed, and the two tile types have + * separate blocks in both converters, so one can silently regress without the + * other. + */ +describe('exemplar settings round-trip', () => { + const traceSourceId = new mongoose.Types.ObjectId().toString(); + const sourceId = new mongoose.Types.ObjectId().toString(); + + const externalTile = (displayType: 'line' | 'stacked_bar'): ConfigTile => ({ + id: 'tile-1', + x: 0, + y: 0, + w: 6, + h: 4, + name: 'Latency', + config: { + displayType, + sourceId, + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + enableExemplars: true, + exemplarTraceSourceId: traceSourceId, + } as ConfigTile['config'], + }); + + it.each(['line', 'stacked_bar'] as const)( + 'keeps both settings converting a %s tile inwards', + displayType => { + const { config } = convertToInternalTileConfig(externalTile(displayType)); + expect(config).toMatchObject({ + enableExemplars: true, + exemplarTraceSourceId: traceSourceId, + }); + }, + ); + + it.each(['line', 'stacked_bar'] as const)( + 'returns both settings for a %s tile on the way out', + displayType => { + const internal = convertToInternalTileConfig(externalTile(displayType)); + const dashboard = { + _id: new mongoose.Types.ObjectId(), + name: 'D', + tiles: [{ ...externalTile(displayType), config: internal.config }], + tags: [], + } as unknown as DashboardDocument; + + const [tile] = convertToExternalDashboard(dashboard).tiles; + expect(tile.config).toMatchObject({ + enableExemplars: true, + exemplarTraceSourceId: traceSourceId, + }); + }, + ); + + it('omits both when unset rather than emitting nulls', () => { + const bare = externalTile('line'); + delete (bare.config as Record).enableExemplars; + delete (bare.config as Record).exemplarTraceSourceId; + + const { config } = convertToInternalTileConfig(bare); + expect(config).not.toHaveProperty('enableExemplars'); + expect(config).not.toHaveProperty('exemplarTraceSourceId'); + }); +}); diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 5dbe58f6ac..4dde44d771 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -943,6 +943,15 @@ function getMissingSources( if ('sourceId' in tile.config && tile.config.sourceId) { sourceIds.add(tile.config.sourceId); } + // The exemplar trace source is a source reference like any other. Without + // this it was only format-checked, so a well-formed id for a source that + // does not exist saved fine and left every marker's trace link dead. + if ( + 'exemplarTraceSourceId' in tile.config && + tile.config.exemplarTraceSourceId + ) { + sourceIds.add(tile.config.exemplarTraceSourceId); + } } // Include source IDs referenced by OnClick link-outs (mode=id, type=search) @@ -997,6 +1006,38 @@ function getHeatmapTilesWithIncompatibleSources( }); } +/** + * Returns exemplar trace-source IDs that exist but are not Trace sources. + * + * The MCP and OpenAPI descriptions both tell the caller this must be a Trace + * source; without a gate that was only a suggestion, and pointing it at a log or + * metric source saved a tile whose markers link nowhere. Mirrors the heatmap + * kind-gate above rather than inventing a second shape. + */ +function getInvalidExemplarTraceSources( + sources: SourceForValidation[], + tiles: ExternalDashboardTileWithId[], +): string[] { + const traceSourceIds = new Set(); + for (const tile of tiles) { + if ( + isConfigTile(tile) && + 'exemplarTraceSourceId' in tile.config && + tile.config.exemplarTraceSourceId + ) { + traceSourceIds.add(tile.config.exemplarTraceSourceId); + } + } + if (traceSourceIds.size === 0) return []; + + const sourceById = new Map(sources.map(s => [s._id.toString(), s])); + return [...traceSourceIds].filter(id => { + const source = sourceById.get(id); + // Absent ids are getMissingSources' business, and it runs first. + return source !== undefined && !isTraceSource(source); + }); +} + /** * For a PUT (update) request, return only the heatmap tiles that need * to be re-validated against the source-kind gate. A heatmap tile that @@ -1309,6 +1350,14 @@ export async function validateDashboardTiles( return `Heatmap tiles require a Trace source. The following source IDs are not Trace sources: ${heatmapNonTraceSources.join(', ')}`; } + const invalidExemplarTraceSources = getInvalidExemplarTraceSources( + sources, + tiles, + ); + if (invalidExemplarTraceSources.length > 0) { + return `exemplarTraceSourceId must reference a Trace source. The following source IDs are not Trace sources: ${invalidExemplarTraceSources.join(', ')}`; + } + if (missingOnClickDashboards.length > 0) { return `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join(', ')}`; } From a59288ea1586c223b68b1167397bec227f34a0e6 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 6 Aug 2026 08:18:19 +1000 Subject: [PATCH 3/6] chore(api): raise the api lint ceiling by one for the round-trip test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-trip test needs one type assertion: its tile builder takes the display type as a parameter, so `displayType` is the union rather than a literal and TS cannot pick a branch of the external tile config union without it. Writing the two configs out separately to avoid the assertion loses the it.each that makes the line and stacked_bar cases symmetrical — which is the point, since both converters handle them in separate blocks. --- packages/api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/package.json b/packages/api/package.json index 3a05a3d60e..bdc39bc1e6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -92,7 +92,7 @@ "dev-task": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/tasks/index.ts", "build": "rimraf ./build && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r ./src/opamp/proto ./build/opamp/", "build:vercel": "rimraf ./build && tsc -p tsconfig.vercel.json && tsc-alias -p tsconfig.vercel.json && cp -r ./src/opamp/proto ./build/opamp/", - "lint": "npx eslint . --ext .ts --max-warnings 357", + "lint": "npx eslint . --ext .ts --max-warnings 358", "lint:fix": "npx eslint . --ext .ts --fix", "ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:openapi", "ci:unit": "jest --ci --coverage", From 399e733483bb714d67c90ebb8f72ff0203e97faf Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 6 Aug 2026 08:41:15 +1000 Subject: [PATCH 4/6] fix(api): exempt unchanged tiles from the exemplar trace-source kind check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a review finding on the previous fix. The kind gate ran over every tile in the request, so an unchanged tile whose trace source had since been deleted or changed kind would fail it on every subsequent save — making an unrelated edit elsewhere on the dashboard impossible to persist. Scoped to new and changed tiles on update, mirroring filterChangedHeatmapTiles, which exists for exactly this reason. The existence check in getMissingSources is deliberately left unscoped: it treats every source reference on the dashboard the same way and reports them with one clear message, and singling this field out would be the inconsistency rather than the fix. --- .../__tests__/dashboards.int.test.ts | 74 +++++++++++++++++++ .../external-api/v2/utils/dashboards.ts | 43 ++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts index 10582ac450..9d50017ddc 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts @@ -5126,6 +5126,80 @@ describe('External API v2 Dashboards - new format', () => { }) .expect(200); }); + + // Same reasoning as the heatmap case above: the trace source can be deleted + // or change kind long after the tile was accepted, and an optional marker + // link target must not make the whole dashboard unsaveable for edits made + // elsewhere on it. + it('does not re-validate the exemplar trace source for unchanged tiles', async () => { + const createResponse = await authRequest('post', BASE_URL) + .send({ + name: 'Exemplar PUT scoping test', + tiles: [ + { + name: 'Latency', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: metricSource._id.toString(), + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + enableExemplars: true, + exemplarTraceSourceId: traceSource._id.toString(), + }, + }, + { + name: 'Other line tile', + x: 6, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: traceSource._id.toString(), + select: [{ aggFn: 'count', where: '' }], + }, + }, + ], + tags: [], + }) + .expect(200); + + const dashboardId = createResponse.body.data.id; + const exemplarTile = createResponse.body.data.tiles.find( + (t: { name: string }) => t.name === 'Latency', + ); + const otherTile = createResponse.body.data.tiles.find( + (t: { name: string }) => t.name === 'Other line tile', + ); + + // The trace source stops being a Trace source after the fact. Written + // straight to the collection for the same reason the heatmap test does. + await Source.collection.updateOne( + { _id: traceSource._id }, + { $set: { kind: SourceKind.Log } }, + ); + + await authRequest('put', `${BASE_URL}/${dashboardId}`) + .send({ + name: 'Exemplar PUT scoping test - renamed', + tiles: [ + { ...exemplarTile }, + { + ...otherTile, + name: 'Other line tile, edited', + config: { + ...otherTile.config, + select: [{ aggFn: 'count', where: 'level:error' }], + }, + }, + ], + tags: [], + }) + .expect(200); + }); }); describe('Number tile color (HDX-1360)', () => { diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 4dde44d771..a3b7a17b3b 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -1083,6 +1083,41 @@ function filterChangedHeatmapTiles( }); } +/** + * For a PUT (update), return only the tiles whose exemplar trace source needs + * re-checking: new tiles, and existing ones where the id actually changed. + * + * Same reasoning as filterChangedHeatmapTiles. Without it, a tile whose trace + * source was later deleted or changed kind would fail the gate on every + * subsequent save, so an unrelated edit elsewhere on the dashboard could not be + * persisted at all. + */ +function filterChangedExemplarTiles( + requestTiles: ExternalDashboardTileWithId[], + existingTiles: DashboardDocument['tiles'], +): ExternalDashboardTileWithId[] { + const existingTilesById = new Map( + existingTiles.map(t => [t.id, t]), + ); + return requestTiles.filter(tile => { + if ( + !isConfigTile(tile) || + !('exemplarTraceSourceId' in tile.config) || + !tile.config.exemplarTraceSourceId + ) { + return false; + } + const existing = tile.id ? existingTilesById.get(tile.id) : undefined; + // A new tile, or one that had no exemplar trace source before: validate. + if (existing === undefined) return true; + const existingConfig = existing.config; + if (isRawSqlSavedChartConfig(existingConfig)) return true; + return ( + existingConfig.exemplarTraceSourceId !== tile.config.exemplarTraceSourceId + ); + }); +} + /** * Returns source IDs referenced by onClick search link-outs (mode=id, * type=search) whose source kind is not log or trace. The /search destination @@ -1350,9 +1385,15 @@ export async function validateDashboardTiles( return `Heatmap tiles require a Trace source. The following source IDs are not Trace sources: ${heatmapNonTraceSources.join(', ')}`; } + // Scoped to changed tiles on update, like the heatmap gate above: an unchanged + // tile whose trace source has since been deleted must not block edits made + // elsewhere on the dashboard. + const exemplarTilesToCheck = existingTiles + ? filterChangedExemplarTiles(tiles, existingTiles) + : tiles; const invalidExemplarTraceSources = getInvalidExemplarTraceSources( sources, - tiles, + exemplarTilesToCheck, ); if (invalidExemplarTraceSources.length > 0) { return `exemplarTraceSourceId must reference a Trace source. The following source IDs are not Trace sources: ${invalidExemplarTraceSources.join(', ')}`; From b6cf62ba9ec2759aa88797a0bbd3553b8ff03720 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 6 Aug 2026 09:10:43 +1000 Subject: [PATCH 5/6] fix(api): exempt unchanged tiles from the exemplar source existence check too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a review finding on the previous fix, which only went half way. The kind gate was scoped to changed tiles but the existence check was not, so deleting an exemplar trace source still made every later save of that dashboard fail. The two checks now live together and share one exemption: a deleted source and a source whose kind changed are the same situation to whoever hits them, and nobody could predict that one blocks an edit and the other does not. My earlier reasoning for leaving the existence check unscoped — that getMissingSources should treat every source reference alike — weighted mechanical consistency over what actually happens. A missing `sourceId` means the tile cannot render; a missing `exemplarTraceSourceId` means a marker's trace link is dead on a chart that is otherwise fine. That is not worth refusing an unrelated edit over. --- .../__tests__/dashboards.int.test.ts | 66 +++++++++++++++++++ .../external-api/v2/utils/dashboards.ts | 53 ++++++++------- 2 files changed, 95 insertions(+), 24 deletions(-) diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts index 9d50017ddc..3d76bee9d6 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts @@ -5200,6 +5200,72 @@ describe('External API v2 Dashboards - new format', () => { }) .expect(200); }); + + // Deletion, not just a kind change. These were split for a while: the kind + // gate was exempted for unchanged tiles and the existence check was not, so + // deleting the source still wedged every later save. Only the exemplar + // reference dangles here — both tiles sit on a source that still exists, so a + // rejection could only come from the exemplar check. + it('does not re-validate a deleted exemplar trace source for unchanged tiles', async () => { + const createResponse = await authRequest('post', BASE_URL) + .send({ + name: 'Exemplar deletion scoping test', + tiles: [ + { + name: 'Latency', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: metricSource._id.toString(), + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + enableExemplars: true, + exemplarTraceSourceId: traceSource._id.toString(), + }, + }, + { + name: 'Other line tile', + x: 6, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: metricSource._id.toString(), + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + }, + }, + ], + tags: [], + }) + .expect(200); + + const dashboardId = createResponse.body.data.id; + const exemplarTile = createResponse.body.data.tiles.find( + (t: { name: string }) => t.name === 'Latency', + ); + const otherTile = createResponse.body.data.tiles.find( + (t: { name: string }) => t.name === 'Other line tile', + ); + + await Source.collection.deleteOne({ _id: traceSource._id }); + + await authRequest('put', `${BASE_URL}/${dashboardId}`) + .send({ + name: 'Exemplar deletion scoping test - renamed', + tiles: [ + { ...exemplarTile }, + { + ...otherTile, + name: 'Other line tile, edited', + }, + ], + tags: [], + }) + .expect(200); + }); }); describe('Number tile color (HDX-1360)', () => { diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index a3b7a17b3b..f30c101dce 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -943,15 +943,9 @@ function getMissingSources( if ('sourceId' in tile.config && tile.config.sourceId) { sourceIds.add(tile.config.sourceId); } - // The exemplar trace source is a source reference like any other. Without - // this it was only format-checked, so a well-formed id for a source that - // does not exist saved fine and left every marker's trace link dead. - if ( - 'exemplarTraceSourceId' in tile.config && - tile.config.exemplarTraceSourceId - ) { - sourceIds.add(tile.config.exemplarTraceSourceId); - } + // exemplarTraceSourceId is deliberately NOT collected here: it is checked + // separately so the same unchanged-tile exemption can apply to it. See + // getExemplarTraceSourceIssues. } // Include source IDs referenced by OnClick link-outs (mode=id, type=search) @@ -1007,17 +1001,22 @@ function getHeatmapTilesWithIncompatibleSources( } /** - * Returns exemplar trace-source IDs that exist but are not Trace sources. + * Both problems an exemplar trace source can have: it does not exist, or it + * exists and is not a Trace source. + * + * Checked here rather than folding existence into getMissingSources so one + * unchanged-tile exemption covers both. Splitting them meant a deleted source + * blocked an update while a source whose kind had changed did not, which is not a + * distinction anyone could predict. * - * The MCP and OpenAPI descriptions both tell the caller this must be a Trace - * source; without a gate that was only a suggestion, and pointing it at a log or - * metric source saved a tile whose markers link nowhere. Mirrors the heatmap - * kind-gate above rather than inventing a second shape. + * Unlike a tile's `sourceId`, this reference is optional decoration: the chart + * renders the same without it, only a marker's "view trace" link goes dead. That + * is not worth refusing an edit made elsewhere on the dashboard over. */ -function getInvalidExemplarTraceSources( +function getExemplarTraceSourceIssues( sources: SourceForValidation[], tiles: ExternalDashboardTileWithId[], -): string[] { +): { missing: string[]; notTrace: string[] } { const traceSourceIds = new Set(); for (const tile of tiles) { if ( @@ -1028,14 +1027,17 @@ function getInvalidExemplarTraceSources( traceSourceIds.add(tile.config.exemplarTraceSourceId); } } - if (traceSourceIds.size === 0) return []; + if (traceSourceIds.size === 0) return { missing: [], notTrace: [] }; const sourceById = new Map(sources.map(s => [s._id.toString(), s])); - return [...traceSourceIds].filter(id => { + const missing: string[] = []; + const notTrace: string[] = []; + for (const id of traceSourceIds) { const source = sourceById.get(id); - // Absent ids are getMissingSources' business, and it runs first. - return source !== undefined && !isTraceSource(source); - }); + if (source === undefined) missing.push(id); + else if (!isTraceSource(source)) notTrace.push(id); + } + return { missing, notTrace }; } /** @@ -1391,12 +1393,15 @@ export async function validateDashboardTiles( const exemplarTilesToCheck = existingTiles ? filterChangedExemplarTiles(tiles, existingTiles) : tiles; - const invalidExemplarTraceSources = getInvalidExemplarTraceSources( + const exemplarTraceSourceIssues = getExemplarTraceSourceIssues( sources, exemplarTilesToCheck, ); - if (invalidExemplarTraceSources.length > 0) { - return `exemplarTraceSourceId must reference a Trace source. The following source IDs are not Trace sources: ${invalidExemplarTraceSources.join(', ')}`; + if (exemplarTraceSourceIssues.missing.length > 0) { + return `Could not find the following exemplarTraceSourceId source IDs: ${exemplarTraceSourceIssues.missing.join(', ')}`; + } + if (exemplarTraceSourceIssues.notTrace.length > 0) { + return `exemplarTraceSourceId must reference a Trace source. The following source IDs are not Trace sources: ${exemplarTraceSourceIssues.notTrace.join(', ')}`; } if (missingOnClickDashboards.length > 0) { From be4166f7f81cc0d77750dd52446dffc74b099e6d Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 6 Aug 2026 09:44:31 +1000 Subject: [PATCH 6/6] fix(api): re-validate the exemplar trace source when exemplars are switched on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a review finding on the previous fix. The unchanged-tile exemption keyed on the trace source id, so a tile carrying a source id while exemplars were disabled kept the exemption when an update turned them on. Nothing was following that reference in the meantime, so the source could have been deleted or stopped being a Trace source with no visible effect — and switching markers on then persisted them pointing at nothing. An id nobody was following is not the same as one about to draw markers, so the off-to-on transition is now checked as if the reference were new. A tile whose exemplars were already on keeps the exemption, which is the case that exemption exists for. --- .../__tests__/dashboards.int.test.ts | 99 +++++++++++++++++++ .../external-api/v2/utils/dashboards.ts | 12 ++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts index 3d76bee9d6..9d0e5ec1f0 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts @@ -5268,6 +5268,105 @@ describe('External API v2 Dashboards - new format', () => { }); }); + describe('exemplar settings enabled on an existing tile', () => { + // The unchanged-tile exemption keys on the source id, but an id nobody was + // following is not the same as one about to draw markers: while exemplars + // were off the source could have been deleted or stopped being a Trace + // source with no effect at all. + it('validates the trace source when exemplars are switched on', async () => { + const createResponse = await authRequest('post', BASE_URL) + .send({ + name: 'Exemplar enable test', + tiles: [ + { + name: 'Latency', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: metricSource._id.toString(), + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + enableExemplars: false, + exemplarTraceSourceId: traceSource._id.toString(), + }, + }, + ], + tags: [], + }) + .expect(200); + + const dashboardId = createResponse.body.data.id; + const tile = createResponse.body.data.tiles[0]; + + // The source stops being a Trace source while nothing was following it. + await Source.collection.updateOne( + { _id: traceSource._id }, + { $set: { kind: SourceKind.Log } }, + ); + + const response = await authRequest('put', `${BASE_URL}/${dashboardId}`) + .send({ + name: 'Exemplar enable test', + tiles: [ + { + ...tile, + config: { ...tile.config, enableExemplars: true }, + }, + ], + tags: [], + }) + .expect(400); + + expect(response.body.message).toContain( + 'exemplarTraceSourceId must reference a Trace source', + ); + }); + + it('still exempts a tile whose exemplars were already on', async () => { + const createResponse = await authRequest('post', BASE_URL) + .send({ + name: 'Exemplar already-on test', + tiles: [ + { + name: 'Latency', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: metricSource._id.toString(), + select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + enableExemplars: true, + exemplarTraceSourceId: traceSource._id.toString(), + }, + }, + ], + tags: [], + }) + .expect(200); + + const dashboardId = createResponse.body.data.id; + const tile = createResponse.body.data.tiles[0]; + + await Source.collection.updateOne( + { _id: traceSource._id }, + { $set: { kind: SourceKind.Log } }, + ); + + // enableExemplars stays true, so this is the exemption, not the transition. + await authRequest('put', `${BASE_URL}/${dashboardId}`) + .send({ + name: 'Exemplar already-on test - renamed', + tiles: [{ ...tile }], + tags: [], + }) + .expect(200); + }); + }); + describe('Number tile color (HDX-1360)', () => { // Minimal builder number tile; callers supply color / colorRules. The // payload is sent through `.send()` (untyped) so negative tests can post diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index f30c101dce..2b36964987 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -1114,8 +1114,18 @@ function filterChangedExemplarTiles( if (existing === undefined) return true; const existingConfig = existing.config; if (isRawSqlSavedChartConfig(existingConfig)) return true; - return ( + if ( existingConfig.exemplarTraceSourceId !== tile.config.exemplarTraceSourceId + ) { + return true; + } + // The id is unchanged, but a reference nobody was following is not the same + // as one that is about to draw markers. While exemplars were off the source + // could have been deleted or stopped being a Trace source with no effect, so + // switching them on has to be checked as if the reference were new. + return ( + tile.config.enableExemplars === true && + existingConfig.enableExemplars !== true ); }); }