Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/exemplars-agent-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@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 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.
20 changes: 20 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1884,6 +1884,16 @@
"type": "boolean",
"description": "Only meaningful with formulas. When false, only the formula series are returned; the raw operand series are hidden.\n",
"default": true
},
"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, 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. 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"
}
}
},
Expand Down Expand Up @@ -1961,6 +1971,16 @@
"type": "boolean",
"description": "Only meaningful with formulas. When false, only the formula series are returned; the raw operand series are hidden.\n",
"default": true
},
"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, 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. 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"
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,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 302",
"lint": "npx eslint . --ext .ts --max-warnings 304",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Lint warning cap raised instead of fixing the new warnings

The two new warnings appear to come from the new tests' casts (as ConfigTile['config'], as unknown as DashboardDocument in v2/utils/tests/dashboards.test.ts), which break the repo's "avoid as casts" rule. Type the fixtures with satisfies or a typed builder and keep the cap at 302.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

"lint:fix": "npx eslint . --ext .ts --fix",
"ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:openapi",
"ci:unit": "jest --ci --coverage",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => [
{
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, the same outcome as an 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 });
});
});
38 changes: 38 additions & 0 deletions packages/api/src/mcp/tools/dashboards/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,25 @@ export const mcpLineTileSchema = mcpTileLayoutSchema.extend({
lineInterpolation: LineInterpolationSchema.optional().describe(
'How points are connected: "smooth" (default), "linear", or "step".',
),
enableExemplars: z

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Exemplar field definitions and describe strings copied verbatim into two MCP tile schemas

Define one shared mcpExemplarConfigFields object, like the existing mcpStackedTimeChartConfigFields, and spread it into the line, stacked_bar and stacked_line schemas. Then the copy can't drift between tile types.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

.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, 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: objectIdSchema
.optional()
.describe(
'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.",
),
})
.describe('Line chart query and display settings.'),
});
Expand Down Expand Up @@ -786,6 +805,25 @@ export const mcpBarTileSchema = mcpTileLayoutSchema.extend({
.literal('stacked_bar')
.describe('Stacked bar chart over time'),
...mcpStackedTimeChartConfigFields,
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, 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: objectIdSchema
.optional()
.describe(
'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.",
),
})
.describe('Stacked bar chart query and display settings.'),
});
Expand Down
Loading
Loading