Skip to content

Commit be496d8

Browse files
test(api/mcp): cover full heatmap save/get/update round-trip (#2199)
## Summary Adds a thorough save → get → update → get round-trip test for the heatmap MCP path. Brandon flagged on this PR that the schema unit tests at `mcp/tools/dashboards/__tests__/schemas.test.ts` only assert that zod accepts the shape and don't prove that an agent-issued `hyperdx_save_dashboard` call actually persists a heatmap with every MCP-specific field. The bare-minimum heatmap test that landed with #2200 covers only the required `valueExpression`; this test exercises `heatmapScaleType`, `countExpression`, `numberFormat`, `where`, and `whereLanguage` all at once, then mutates them on PUT and re-reads to confirm the new values stick. ## Backstory The MCP heatmap schema work originally drafted on this branch (`mcpHeatmapSelectItemSchema`, `mcpHeatmapTileSchema`, the few-shot example, the prompt guides) was absorbed into #2200 during the convergence on the deep-review feedback. #2200 has merged on `main`, so the only delta this PR adds on top is the integration test. ## What's in this PR - One new test in `packages/api/src/mcp/__tests__/dashboards.test.ts`: `should round-trip a fully-specified heatmap tile through save, get, and update` - Drive-by: drops one em-dash from a sibling test comment in the same file so the prose-lint hook passes when this file is touched ## Test plan - [x] `yarn workspace @hyperdx/api lint` clean - [x] `yarn workspace @hyperdx/api tsc --noEmit` clean - [x] `yarn workspace @hyperdx/api docgen` produces no openapi.json diff - [ ] CI runs the integration suite. Local `make dev-int` needs Docker BuildKit which isn't available on this host.
1 parent aa1a852 commit be496d8

1 file changed

Lines changed: 234 additions & 1 deletion

File tree

‎packages/api/src/mcp/__tests__/dashboards.test.ts‎

Lines changed: 234 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,85 @@ describe('MCP Dashboard Tools', () => {
459459
});
460460
});
461461

462+
it('should round-trip every MCP-specific heatmap field through save, get, update, and re-get', async () => {
463+
const sourceId = traceSource._id.toString();
464+
465+
const createConfig = {
466+
displayType: 'heatmap' as const,
467+
sourceId,
468+
select: [
469+
{
470+
valueExpression: 'Duration',
471+
countExpression: 'count()',
472+
heatmapScaleType: 'log' as const,
473+
},
474+
],
475+
where: 'level:error',
476+
whereLanguage: 'lucene' as const,
477+
numberFormat: { output: 'duration' as const, factor: 1e-9 },
478+
};
479+
// Mutate select + where on update; numberFormat, whereLanguage,
480+
// and sourceId carry forward via the spread. Re-asserting against
481+
// updatedConfig catches a regression where PUT silently drops any
482+
// carried-forward field.
483+
const updatedConfig = {
484+
...createConfig,
485+
select: [
486+
{
487+
valueExpression: "SpanAttributes['http.duration']",
488+
heatmapScaleType: 'linear' as const,
489+
},
490+
],
491+
where: 'level:error AND service:checkout',
492+
};
493+
494+
const saveResult = await callTool(client, 'hyperdx_save_dashboard', {
495+
name: 'Heatmap Full Round-Trip',
496+
tiles: [
497+
{
498+
name: 'Latency Heatmap',
499+
x: 0,
500+
y: 0,
501+
w: 12,
502+
h: 4,
503+
config: createConfig,
504+
},
505+
],
506+
});
507+
expect(saveResult.isError).toBeFalsy();
508+
const saved = JSON.parse(getFirstText(saveResult));
509+
expect(saved.tiles).toHaveLength(1);
510+
expect(saved.tiles[0].config).toMatchObject(createConfig);
511+
512+
const getResult = await callTool(client, 'hyperdx_get_dashboard', {
513+
id: saved.id,
514+
});
515+
expect(getResult.isError).toBeFalsy();
516+
const fetched = JSON.parse(getFirstText(getResult));
517+
expect(fetched.tiles[0].config).toMatchObject(createConfig);
518+
519+
const updateResult = await callTool(client, 'hyperdx_save_dashboard', {
520+
id: saved.id,
521+
name: 'Heatmap Full Round-Trip',
522+
tiles: [
523+
{
524+
...fetched.tiles[0],
525+
config: { ...fetched.tiles[0].config, ...updatedConfig },
526+
},
527+
],
528+
});
529+
expect(updateResult.isError).toBeFalsy();
530+
const updated = JSON.parse(getFirstText(updateResult));
531+
expect(updated.tiles[0].config).toMatchObject(updatedConfig);
532+
533+
const getAfterUpdate = await callTool(client, 'hyperdx_get_dashboard', {
534+
id: saved.id,
535+
});
536+
expect(getAfterUpdate.isError).toBeFalsy();
537+
const refetched = JSON.parse(getFirstText(getAfterUpdate));
538+
expect(refetched.tiles[0].config).toMatchObject(updatedConfig);
539+
});
540+
462541
it('should reject heatmap tile with empty valueExpression at the schema layer', async () => {
463542
const sourceId = traceSource._id.toString();
464543
const result = await callTool(client, 'hyperdx_save_dashboard', {
@@ -594,6 +673,160 @@ describe('MCP Dashboard Tools', () => {
594673
expect(text).toContain('Trace source');
595674
expect(text).toContain(logSource._id.toString());
596675
});
676+
677+
// Exercises the update-side source-kind gate via filterChangedHeatmapTiles
678+
// (displayType changed to heatmap on an existing tile).
679+
it('should reject update that changes a tile to heatmap on a non-Trace source', async () => {
680+
const logSource = await Source.create({
681+
kind: SourceKind.Log,
682+
team: team._id,
683+
from: { databaseName: DEFAULT_DATABASE, tableName: 'otel_logs' },
684+
timestampValueExpression: 'Timestamp',
685+
connection: connection._id,
686+
name: 'Logs',
687+
});
688+
689+
const created = await callTool(client, 'hyperdx_save_dashboard', {
690+
name: 'Line on Log Source',
691+
tiles: [
692+
{
693+
name: 'Line',
694+
config: {
695+
displayType: 'line',
696+
sourceId: logSource._id.toString(),
697+
select: [{ aggFn: 'count' }],
698+
},
699+
},
700+
],
701+
});
702+
expect(created.isError).toBeFalsy();
703+
const saved = JSON.parse(getFirstText(created));
704+
705+
const update = await callTool(client, 'hyperdx_save_dashboard', {
706+
id: saved.id,
707+
name: 'Line on Log Source',
708+
tiles: [
709+
{
710+
...saved.tiles[0],
711+
config: {
712+
...saved.tiles[0].config,
713+
displayType: 'heatmap',
714+
select: [{ valueExpression: 'Duration' }],
715+
},
716+
},
717+
],
718+
});
719+
expect(update.isError).toBe(true);
720+
const text = getFirstText(update);
721+
expect(text).toContain('Trace source');
722+
expect(text).toContain(logSource._id.toString());
723+
});
724+
725+
// Exercises the update-side source-kind gate via filterChangedHeatmapTiles
726+
// (sourceId changed on an existing heatmap tile).
727+
it('should reject update that changes a heatmap tile source to a non-Trace source', async () => {
728+
const logSource = await Source.create({
729+
kind: SourceKind.Log,
730+
team: team._id,
731+
from: { databaseName: DEFAULT_DATABASE, tableName: 'otel_logs' },
732+
timestampValueExpression: 'Timestamp',
733+
connection: connection._id,
734+
name: 'Logs',
735+
});
736+
737+
const created = await callTool(client, 'hyperdx_save_dashboard', {
738+
name: 'Heatmap re-pointed at Log',
739+
tiles: [
740+
{
741+
name: 'Heatmap',
742+
config: {
743+
displayType: 'heatmap',
744+
sourceId: traceSource._id.toString(),
745+
select: [{ valueExpression: 'Duration' }],
746+
},
747+
},
748+
],
749+
});
750+
expect(created.isError).toBeFalsy();
751+
const saved = JSON.parse(getFirstText(created));
752+
753+
const update = await callTool(client, 'hyperdx_save_dashboard', {
754+
id: saved.id,
755+
name: 'Heatmap re-pointed at Log',
756+
tiles: [
757+
{
758+
...saved.tiles[0],
759+
config: {
760+
...saved.tiles[0].config,
761+
sourceId: logSource._id.toString(),
762+
},
763+
},
764+
],
765+
});
766+
expect(update.isError).toBe(true);
767+
const text = getFirstText(update);
768+
expect(text).toContain('Trace source');
769+
expect(text).toContain(logSource._id.toString());
770+
});
771+
772+
// Asserts each tile's config survives the serializer/deserializer cycle
773+
// independently when mixed with other displayTypes on the same dashboard.
774+
it('should round-trip a heatmap alongside line and number tiles in one dashboard', async () => {
775+
const sourceId = traceSource._id.toString();
776+
777+
const heatmapConfig = {
778+
displayType: 'heatmap' as const,
779+
sourceId,
780+
select: [
781+
{ valueExpression: 'Duration', heatmapScaleType: 'log' as const },
782+
],
783+
};
784+
const lineConfig = {
785+
displayType: 'line' as const,
786+
sourceId,
787+
select: [{ aggFn: 'count' as const }],
788+
groupBy: "SpanAttributes['service.name']",
789+
};
790+
const numberConfig = {
791+
displayType: 'number' as const,
792+
sourceId,
793+
select: [{ aggFn: 'count' as const }],
794+
numberFormat: { output: 'number' as const, average: true },
795+
};
796+
797+
const save = await callTool(client, 'hyperdx_save_dashboard', {
798+
name: 'Mixed Tile Round-Trip',
799+
tiles: [
800+
{ name: 'Heatmap Tile', config: heatmapConfig },
801+
{ name: 'Line Tile', config: lineConfig },
802+
{ name: 'Number Tile', config: numberConfig },
803+
],
804+
});
805+
expect(save.isError).toBeFalsy();
806+
const saved = JSON.parse(getFirstText(save));
807+
expect(saved.tiles).toHaveLength(3);
808+
809+
const byName: Record<string, ExternalDashboardTileWithId> =
810+
Object.fromEntries(
811+
saved.tiles.map((t: ExternalDashboardTileWithId) => [t.name, t]),
812+
);
813+
expect(byName['Heatmap Tile'].config).toMatchObject(heatmapConfig);
814+
expect(byName['Line Tile'].config).toMatchObject(lineConfig);
815+
expect(byName['Number Tile'].config).toMatchObject(numberConfig);
816+
817+
const fetched = JSON.parse(
818+
getFirstText(
819+
await callTool(client, 'hyperdx_get_dashboard', { id: saved.id }),
820+
),
821+
);
822+
const fetchedByName: Record<string, ExternalDashboardTileWithId> =
823+
Object.fromEntries(
824+
fetched.tiles.map((t: ExternalDashboardTileWithId) => [t.name, t]),
825+
);
826+
expect(fetchedByName['Heatmap Tile'].config).toMatchObject(heatmapConfig);
827+
expect(fetchedByName['Line Tile'].config).toMatchObject(lineConfig);
828+
expect(fetchedByName['Number Tile'].config).toMatchObject(numberConfig);
829+
});
597830
});
598831

599832
describe('hyperdx_save_dashboard - containers and tabs', () => {
@@ -653,7 +886,7 @@ describe('MCP Dashboard Tools', () => {
653886
containerId: 'service-health',
654887
tabId: 'latency',
655888
}),
656-
// Tile inside a tabbed container without a tabId — renders in
889+
// Tile inside a tabbed container without a tabId renders in
657890
// the container shell rather than under a tab. Guards that the
658891
// schema does not accidentally require tabId for every tile in
659892
// a tabbed container.

0 commit comments

Comments
 (0)