From a65613cfd0e47afbcc71b8a519cfa319cec3c34d Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 12 May 2026 22:27:30 +0000 Subject: [PATCH 01/12] feat(mcp): rewrite dashboard authoring prompts and expose dashboard filters on save tool The `create_dashboard` prompt now leads with a ten-rule design checklist (RED columns with aliases, per-series numberFormat for durations, groupByColumnsOnLeft for inventory tables, dashboard-level filters instead of per-tile where literals, one-metric-per-tile for metric sources, containers and tabs for grouping). The wall-of-JSON canonical example is gone; the four dashboard_examples patterns carry the concrete shapes. The dashboard_examples set is replaced with four verified patterns (service_inventory, service_detail, log_analytics, backend_dependencies) plus the existing infrastructure_sql. Each non-SQL example leads with a "When to use" header and a "Why this shape" note so the model picks by intent, not by surface keyword match. Examples were built and rendered on a live dev stack before landing, which surfaced two design issues now captured as rules in the prompt: - chart-level numberFormat on a table that mixes counts and durations formats every value as a duration; per-series numberFormat is the fix. - metric tiles take exactly one select item (renderer destructures select[0] and ignores the rest). The query_guide prompt gains a DASHBOARD FILTERS section that documents the filters: [{ type, name, expression, sourceId, where?, whereLanguage? }] shape, a NUMBER FORMAT section that explains the per-series vs. chart-level distinction, and a PER-TILE TYPE CONSTRAINTS note about the metric one-select rule. hyperdx_save_dashboard now accepts `filters` on its input schema, reusing externalDashboardFilterSchemaWithId so the MCP and REST surfaces stay in lockstep and the existing convertExternalFiltersToInternal helper handles the conversion without translation. Filters round-trip through create, get, and update. Voice pass: every prompt string is now em-dash-free, with a snapshot test guarding regressions. Drill-down behavior (onClick from a service inventory row into the service detail dashboard) is a separate follow-up PR; landing the prompt rewrite first. --- .changeset/mcp-dashboard-prompts-overhaul.md | 15 + .../api/src/mcp/__tests__/dashboards.test.ts | 835 +++---------- .../api/src/mcp/prompts/dashboards/content.ts | 1106 ++++++++--------- .../api/src/mcp/prompts/dashboards/index.ts | 10 +- .../src/mcp/tools/dashboards/saveDashboard.ts | 15 +- .../api/src/mcp/tools/dashboards/schemas.ts | 102 +- 6 files changed, 742 insertions(+), 1341 deletions(-) create mode 100644 .changeset/mcp-dashboard-prompts-overhaul.md diff --git a/.changeset/mcp-dashboard-prompts-overhaul.md b/.changeset/mcp-dashboard-prompts-overhaul.md new file mode 100644 index 0000000000..7987b95874 --- /dev/null +++ b/.changeset/mcp-dashboard-prompts-overhaul.md @@ -0,0 +1,15 @@ +--- +'@hyperdx/api': patch +--- + +feat(mcp): rewrite dashboard authoring prompts and expose `filters` on `hyperdx_save_dashboard` + +The `create_dashboard` prompt now leads with a ten-rule design checklist (RED columns with aliases, per-series `numberFormat` for durations, `groupByColumnsOnLeft` for inventory tables, dashboard-level filters instead of per-tile `where` literals, one-metric-per-tile for metric sources, containers and tabs for grouping). The wall-of-JSON canonical example is gone; the four `dashboard_examples` patterns carry the concrete shapes. + +The `dashboard_examples` set is replaced with four verified patterns (`service_inventory`, `service_detail`, `log_analytics`, `backend_dependencies`) plus the existing `infrastructure_sql`. Each non-SQL example leads with a "When to use" header and a "Why this shape" note so the model picks by intent, not by surface keyword match. Examples were built and rendered on a live dev stack before landing. + +The `query_guide` prompt gains a `DASHBOARD FILTERS` section that documents the `filters: [{ type, name, expression, sourceId, where?, whereLanguage? }]` shape, a `NUMBER FORMAT` section that explains the per-series vs. chart-level distinction, and a `PER-TILE TYPE CONSTRAINTS` note that metric tiles take exactly one select item per tile. + +`hyperdx_save_dashboard` now accepts `filters` on its input schema, reusing `externalDashboardFilterSchemaWithId` so the MCP and REST surfaces stay in lockstep and the existing `convertExternalFiltersToInternal` helper handles the conversion without translation. Filters round-trip through create, get, and update. + +Voice pass: every prompt string is now em-dash-free. diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index efed83531d..c9bdb8fb65 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -1215,460 +1215,45 @@ describe('MCP Dashboard Tools', () => { }); }); - describe('hyperdx_save_dashboard - table onClick linking', () => { - // Mirrors the v2 external-API onClick tests so the MCP path - // enforces the same drill-down rules: row-click can target /search - // for a log/trace source or another dashboard, by concrete ID or - // by templated name, with optional whereTemplate/filters. - it('should round-trip a table tile with a search onClick by ID', async () => { - const sourceId = traceSource._id.toString(); - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick search by id', - tiles: [ - { - name: 'Errors by Service', - x: 0, - y: 0, - w: 12, - h: 6, - config: { - displayType: 'table', - sourceId, - groupBy: "ResourceAttributes['service.name']", - select: [{ aggFn: 'count' }], - onClick: { - type: 'search', - target: { mode: 'id', id: sourceId }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: "{{ResourceAttributes['service.name']}}", - }, - ], - }, - }, - }, - ], - }); - - expect(createResult.isError).toBeFalsy(); - const created = JSON.parse(getFirstText(createResult)); - expect(created.tiles[0].config.onClick).toEqual({ - type: 'search', - target: { mode: 'id', id: sourceId }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: "{{ResourceAttributes['service.name']}}", - }, - ], - }); - - const getResult = await callTool(client, 'hyperdx_get_dashboard', { - id: created.id, - }); - const fetched = JSON.parse(getFirstText(getResult)); - expect(fetched.tiles[0].config.onClick).toEqual( - created.tiles[0].config.onClick, - ); - }); - - it('should round-trip a table tile with a dashboard onClick by ID', async () => { - const sourceId = traceSource._id.toString(); - // Create the target dashboard the onClick will link to so the - // server-side `getMissingOnClickDashboards` check resolves. - const targetDashboard = await new Dashboard({ - name: 'Service Detail', - tiles: [], - team: team._id, - }).save(); - - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick dashboard by id', - tiles: [ - { - name: 'Top Services', - x: 0, - y: 0, - w: 12, - h: 6, - config: { - displayType: 'table', - sourceId, - groupBy: "ResourceAttributes['service.name']", - select: [{ aggFn: 'count' }], - onClick: { - type: 'dashboard', - target: { - mode: 'id', - id: targetDashboard._id.toString(), - }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: "{{ResourceAttributes['service.name']}}", - }, - ], - }, - }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles[0].config.onClick).toEqual({ - type: 'dashboard', - target: { mode: 'id', id: targetDashboard._id.toString() }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: "{{ResourceAttributes['service.name']}}", - }, - ], - }); - }); - - it('should round-trip a templated dashboard onClick (mode=template)', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick dashboard by template', - tiles: [ - { - name: 'Service Picker', - x: 0, - y: 0, - w: 12, - h: 6, - config: { - displayType: 'table', - sourceId, - groupBy: 'SpanName', - select: [{ aggFn: 'count' }], - onClick: { - type: 'dashboard', - target: { - mode: 'template', - template: '{{TargetDashboard}}', - }, - whereLanguage: 'lucene', - whereTemplate: - "service.name:{{ResourceAttributes['service.name']}}", - }, - }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles[0].config.onClick).toEqual({ - type: 'dashboard', - target: { mode: 'template', template: '{{TargetDashboard}}' }, - whereLanguage: 'lucene', - whereTemplate: "service.name:{{ResourceAttributes['service.name']}}", - }); - }); - - it('should round-trip onClick on a raw SQL table tile', async () => { - const connectionId = connection._id.toString(); - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'SQL onClick', - tiles: [ - { - name: 'Raw SQL Table', - x: 0, - y: 0, - w: 12, - h: 6, - config: { - configType: 'sql', - displayType: 'table', - connectionId, - sqlTemplate: - 'SELECT ServiceName, count() AS c FROM otel_traces GROUP BY ServiceName LIMIT 50', - onClick: { - type: 'search', - target: { mode: 'id', id: sourceId }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: '{{ServiceName}}', - }, - ], - }, - }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles[0].config.onClick).toEqual({ - type: 'search', - target: { mode: 'id', id: sourceId }, - whereLanguage: 'sql', - filters: [ - { - kind: 'expressionTemplate', - expression: 'ServiceName', - template: '{{ServiceName}}', - }, - ], - }); - }); - - it('should reject a table tile onClick referencing a non-existent dashboard', async () => { - const sourceId = traceSource._id.toString(); - const ghostDashboardId = new mongoose.Types.ObjectId().toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick missing dashboard', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - onClick: { - type: 'dashboard', - target: { mode: 'id', id: ghostDashboardId }, - whereLanguage: 'sql', - }, - }, - }, - ], - }); - - expect(result.isError).toBe(true); - const text = getFirstText(result); - expect(text).toContain('onClick dashboard'); - expect(text).toContain(ghostDashboardId); - }); - - it('should reject a table tile onClick search target with a non-log/trace source', async () => { - // /search only renders log/trace sources; targeting a metric - // source must be rejected at save time, matching the REST POST - // path. Create the metric source inline so this test is - // self-contained. - const metricSource = await Source.create({ - kind: SourceKind.Metric, - team: team._id, - from: { - databaseName: DEFAULT_DATABASE, - tableName: '', - }, - metricTables: { - [MetricsDataType.Gauge.toLowerCase()]: 'otel_metrics_gauge', - [MetricsDataType.Sum.toLowerCase()]: 'otel_metrics_sum', - [MetricsDataType.Histogram.toLowerCase()]: 'otel_metrics_histogram', - }, - timestampValueExpression: 'TimeUnix', - connection: connection._id, - name: 'Metrics', - }); - const sourceId = traceSource._id.toString(); - - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick metric source', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - onClick: { - type: 'search', - target: { - mode: 'id', - id: metricSource._id.toString(), - }, - whereLanguage: 'sql', - }, - }, - }, - ], - }); - - expect(result.isError).toBe(true); - const text = getFirstText(result); - expect(text).toContain('log or trace'); - expect(text).toContain(metricSource._id.toString()); - }); - - it('should reject a table tile onClick with an invalid target.id', async () => { - // target.id must be a valid ObjectId. Bad shape is caught by the - // input schema and surfaces as the SDK's "Input validation error" - // envelope rather than a custom 400 message. - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick bad object id', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - onClick: { - type: 'dashboard', - target: { mode: 'id', id: 'not-a-valid-object-id' }, - whereLanguage: 'sql', - }, - }, - }, - ], - }); - - expect(result.isError).toBe(true); - }); - - it('should accept onClick without whereLanguage (it is optional at the schema layer)', async () => { - // SearchConditionLanguageSchema is `.optional()` in common-utils, - // so omitting whereLanguage is allowed. Pin that behavior so a - // future tightening of the schema doesn't quietly change it - // without updating the docs that callers rely on. - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick no whereLanguage', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - onClick: { - type: 'search', - target: { mode: 'id', id: sourceId }, - // whereLanguage intentionally omitted - }, - }, - }, - ], - }); - - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles[0].config.onClick).toMatchObject({ - type: 'search', - target: { mode: 'id', id: sourceId }, - }); - }); - - it('should reject an unknown onClick.type', async () => { - // The schema is a discriminated union on `type`; a bogus type - // must be rejected up front. - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick bad type', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - onClick: { - type: 'navigate-to-mars', - target: { mode: 'id', id: sourceId }, - whereLanguage: 'sql', - }, - }, - }, - ], - }); - - expect(result.isError).toBe(true); + describe('hyperdx_save_dashboard - dashboard filters', () => { + // The MCP input schema delegates to createDashboardBodySchema / + // updateDashboardBodySchema for the filter shape. These tests guard + // that the MCP path lights up the same filter round-trip the v2 REST + // path already covers: filters are saved verbatim, get back with an + // assigned id, survive an update, and reject obvious bad inputs. + + const traceTile = (sourceId: string) => ({ + name: 'Volume', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line' as const, + sourceId, + select: [{ aggFn: 'count' as const, where: '' }], + }, }); - it('should validate onClick on update too', async () => { - // Round-trip a tile with no onClick, then update it to add an - // onClick pointing at a non-existent dashboard. The PUT path - // mirrors POST: missing dashboards are rejected with the same - // message. + it('should round-trip filters on create, get, and update', async () => { const sourceId = traceSource._id.toString(); - const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'OnClick update validation', - tiles: [ - { - name: 'Top Services', - config: { - displayType: 'table', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - ], - }); - expect(createResult.isError).toBeFalsy(); - const created = JSON.parse(getFirstText(createResult)); - - const ghostDashboardId = new mongoose.Types.ObjectId().toString(); - const updateResult = await callTool(client, 'hyperdx_save_dashboard', { - id: created.id, - name: 'OnClick update validation', - tiles: [ - { - ...created.tiles[0], - config: { - ...created.tiles[0].config, - onClick: { - type: 'dashboard', - target: { mode: 'id', id: ghostDashboardId }, - whereLanguage: 'sql', - }, - }, - }, - ], - }); - expect(updateResult.isError).toBe(true); - const text = getFirstText(updateResult); - expect(text).toContain('onClick dashboard'); - expect(text).toContain(ghostDashboardId); - }); - }); - - describe('hyperdx_save_dashboard - top-level filters', () => { - it('should round-trip dashboard-level filters on create', async () => { - const sourceId = traceSource._id.toString(); const createResult = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Service Detail', - tiles: [ - { - name: 'Latency', - config: { - displayType: 'line', - sourceId, - select: [ - { - aggFn: 'quantile', - level: 0.95, - valueExpression: 'Duration', - }, - ], - }, - }, - ], + name: 'Service Detail (MCP filter round-trip)', + tiles: [traceTile(sourceId)], filters: [ { type: 'QUERY_EXPRESSION', name: 'Service', expression: 'ServiceName', sourceId, + }, + { + type: 'QUERY_EXPRESSION', + name: 'Environment', + expression: 'deployment.environment', + sourceId, + where: "deployment.environment = 'production'", whereLanguage: 'sql', }, ], @@ -1676,152 +1261,94 @@ describe('MCP Dashboard Tools', () => { expect(createResult.isError).toBeFalsy(); const created = JSON.parse(getFirstText(createResult)); - expect(created.filters).toHaveLength(1); - expect(created.filters[0]).toMatchObject({ + expect(Array.isArray(created.filters)).toBe(true); + expect(created.filters).toHaveLength(2); + // The body schema assigns an id to each filter on create. Capture + // them so the update payload can include the same ids and the + // filter array round-trips identically (instead of being treated + // as wholesale replacement with new ids). + const [serviceFilter, envFilter] = created.filters; + expect(serviceFilter).toMatchObject({ type: 'QUERY_EXPRESSION', name: 'Service', expression: 'ServiceName', sourceId, + }); + expect(typeof serviceFilter.id).toBe('string'); + expect(serviceFilter.id.length).toBeGreaterThan(0); + expect(envFilter).toMatchObject({ + type: 'QUERY_EXPRESSION', + name: 'Environment', + expression: 'deployment.environment', + sourceId, + where: "deployment.environment = 'production'", whereLanguage: 'sql', }); - expect(typeof created.filters[0].id).toBe('string'); + // Fetch and assert the same shape. const getResult = await callTool(client, 'hyperdx_get_dashboard', { id: created.id, }); const fetched = JSON.parse(getFirstText(getResult)); expect(fetched.filters).toEqual(created.filters); - }); - - it('should round-trip dashboard-level filters on update', async () => { - const sourceId = traceSource._id.toString(); - const initial = await callTool(client, 'hyperdx_save_dashboard', { - name: 'No filters yet', - tiles: [ - { - name: 'Total', - config: { - displayType: 'number', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - ], - }); - expect(initial.isError).toBeFalsy(); - const created = JSON.parse(getFirstText(initial)); - expect(created.filters ?? []).toHaveLength(0); - const updated = await callTool(client, 'hyperdx_save_dashboard', { + // Update: rename the first filter and drop the second. The first + // filter keeps its id, the second is dropped (not preserved). + const updateResult = await callTool(client, 'hyperdx_save_dashboard', { id: created.id, - name: 'With filters now', - tiles: created.tiles, + name: 'Service Detail (MCP filter round-trip)', + tiles: [traceTile(sourceId)], filters: [ { - id: new ObjectId().toString(), + id: serviceFilter.id, type: 'QUERY_EXPRESSION', - name: 'Environment', - expression: 'Environment', + name: 'Service (renamed)', + expression: 'ServiceName', sourceId, - whereLanguage: 'sql', }, ], }); - expect(updated.isError).toBeFalsy(); - const after = JSON.parse(getFirstText(updated)); - expect(after.filters).toHaveLength(1); - expect(after.filters[0]).toMatchObject({ - expression: 'Environment', - name: 'Environment', + + expect(updateResult.isError).toBeFalsy(); + const updated = JSON.parse(getFirstText(updateResult)); + expect(updated.filters).toHaveLength(1); + expect(updated.filters[0]).toMatchObject({ + id: serviceFilter.id, + type: 'QUERY_EXPRESSION', + name: 'Service (renamed)', + expression: 'ServiceName', + sourceId, }); }); - it('preserves filter ids when callers round-trip them on update', async () => { + it('should reject a filter whose sourceId does not exist', async () => { const sourceId = traceSource._id.toString(); - const initial = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Round-trip filters', - tiles: [ - { - name: 'Total', - config: { - displayType: 'number', - sourceId, - select: [{ aggFn: 'count' }], - }, - }, - ], - filters: [ - { - type: 'QUERY_EXPRESSION', - name: 'Service', - expression: 'ServiceName', - sourceId, - whereLanguage: 'sql', - }, - ], - }); - expect(initial.isError).toBeFalsy(); - const created = JSON.parse(getFirstText(initial)); - expect(created.filters).toHaveLength(1); - const serviceFilterId = created.filters[0].id; - expect(typeof serviceFilterId).toBe('string'); - - // Round-trip the existing filter's id AND add a new filter - // without an id. The existing id must be preserved (so any - // savedFilterValues bound to it stay attached); the new filter - // must get a fresh server-generated id. - const updated = await callTool(client, 'hyperdx_save_dashboard', { - id: created.id, - name: 'Round-trip filters', - tiles: created.tiles, + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'Bad filter source', + tiles: [traceTile(sourceId)], filters: [ { - id: serviceFilterId, type: 'QUERY_EXPRESSION', name: 'Service', expression: 'ServiceName', - sourceId, - whereLanguage: 'sql', - }, - { - id: new ObjectId().toString(), - type: 'QUERY_EXPRESSION', - name: 'Severity', - expression: 'SeverityText', - sourceId, - whereLanguage: 'sql', + sourceId: '000000000000000000000000', }, ], }); - expect(updated.isError).toBeFalsy(); - const after = JSON.parse(getFirstText(updated)); - expect(after.filters).toHaveLength(2); - - const serviceFilter = after.filters.find( - (f: { name: string }) => f.name === 'Service', - ); - const severityFilter = after.filters.find( - (f: { name: string }) => f.name === 'Severity', - ); - expect(serviceFilter.id).toBe(serviceFilterId); - expect(typeof severityFilter.id).toBe('string'); - expect(severityFilter.id).not.toBe(serviceFilterId); + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('source'); + }); - // A subsequent fetch must surface the same persisted ids — guards - // against a path that lies in the update response but writes - // something different to Mongo. - const fetched = JSON.parse( - getFirstText( - await callTool(client, 'hyperdx_get_dashboard', { id: created.id }), - ), - ); - expect(fetched.filters).toHaveLength(2); - expect( - fetched.filters.find((f: { name: string }) => f.name === 'Service').id, - ).toBe(serviceFilterId); - expect( - fetched.filters.find((f: { name: string }) => f.name === 'Severity').id, - ).toBe(severityFilter.id); + it('should round-trip a dashboard with no filters (backward compat)', async () => { + const sourceId = traceSource._id.toString(); + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'No filters', + tiles: [traceTile(sourceId)], + }); + expect(result.isError).toBeFalsy(); + const dashboard = JSON.parse(getFirstText(result)); + expect(Array.isArray(dashboard.filters)).toBe(true); + expect(dashboard.filters).toHaveLength(0); }); }); @@ -1974,105 +1501,135 @@ describe('MCP Dashboard Tools', () => { } }); - it('documents table-tile onClick linking features', () => { - // Lock down the documentation for row-click drill-downs so a - // future refactor can't quietly drop the section the LLM relies - // on to wire up onClick correctly. - const prompt = buildQueryGuidePrompt(); - const idx = prompt.indexOf('== TABLE TILE LINKING (config.onClick) =='); - expect(idx).toBeGreaterThan(-1); - const next = prompt.indexOf('\n== ', idx + 1); - const section = prompt.slice(idx, next === -1 ? prompt.length : next); - - // Both destination types are mentioned. - expect(section).toContain('type: "search"'); - expect(section).toContain('type: "dashboard"'); - // Both target modes are mentioned. - expect(section).toContain('mode: "id"'); - expect(section).toContain('mode: "template"'); - // Templating fields are mentioned. - expect(section).toContain('whereTemplate'); - expect(section).toContain('filters'); - expect(section).toContain('expressionTemplate'); - // The two server-side validation error messages are quoted so - // the LLM can recognize and recover from them. - expect(section).toContain('onClick search source IDs'); - expect(section).toContain('onClick dashboard IDs'); - }); - - it('documents onClick pitfalls under common mistakes', () => { + it('documents the dashboard filter and per-series numberFormat sections', () => { const prompt = buildQueryGuidePrompt(); - const idx = prompt.indexOf('== COMMON MISTAKES =='); - expect(idx).toBeGreaterThan(-1); - const section = prompt.slice(idx); - // Mention the four failure modes we hit during validation. - expect(section.toLowerCase()).toContain('onclick on a non-table tile'); - expect(section.toLowerCase()).toContain('non-log/trace source'); - expect(section.toLowerCase()).toContain('missing wherelanguage'); - expect(section.toLowerCase()).toContain("isn't in the table"); + expect(prompt).toContain('== DASHBOARD FILTERS =='); + expect(prompt).toContain('== NUMBER FORMAT =='); + // The metric one-select rule is the constraint Step 1a caught + // during dev-stack verification; without it the AI authors + // multi-metric tiles that silently drop everything past select[0]. + const constraintsIdx = prompt.indexOf('== PER-TILE TYPE CONSTRAINTS =='); + const constraintsBody = prompt.slice(constraintsIdx); + expect(constraintsBody.toLowerCase()).toContain('metric'); + expect(constraintsBody).toContain('one tile per metric'); }); }); describe('buildCreateDashboardPrompt', () => { - it("mentions row-click linking in the table tile's description", () => { - // The create prompt is the LLM's primary entry point for new - // dashboards. Surface onClick so it considers wiring it up on - // overview tables without the user having to ask. + it('includes the design checklist and adapt-do-not-copy note', () => { const prompt = buildCreateDashboardPrompt( - 'summary', - 'trace_src', - 'log_src', + 'sources summary', + '000000000000000000000001', + '000000000000000000000002', ); - expect(prompt).toContain('ROW-CLICK LINKING'); - // The TILE TYPE GUIDE entry for "table" must hint at onClick. - const guideIdx = prompt.indexOf('== TILE TYPE GUIDE =='); - expect(guideIdx).toBeGreaterThan(-1); - const guideEnd = prompt.indexOf('\n== ', guideIdx + 1); - const guideBody = prompt.slice(guideIdx, guideEnd); - expect(guideBody).toContain('onClick'); + expect(prompt).toContain('== DESIGN CHECKLIST =='); + // Each rule on the checklist exists at the same heading depth so a + // future contributor cannot silently drop one. Numbered list + // matters because the prompt references rules by position when the + // model needs a reminder mid-task. + for (let i = 1; i <= 10; i++) { + expect(prompt).toContain(`${i}.`); + } + expect(prompt).toContain('ADAPT, DO NOT COPY'); }); - it('includes a dedicated row-click linking section with both link types', () => { - const prompt = buildCreateDashboardPrompt( - 'summary', - 'trace_src', - 'log_src', - ); - const idx = prompt.indexOf('== ROW-CLICK LINKING'); - expect(idx).toBeGreaterThan(-1); - const next = prompt.indexOf('\n== ', idx + 1); - const section = prompt.slice(idx, next === -1 ? prompt.length : next); - - expect(section).toContain('type: "search"'); - expect(section).toContain('type: "dashboard"'); - expect(section).toContain('target.mode: "id"'); - expect(section).toContain('target.mode: "template"'); - expect(section).toContain('whereTemplate'); - expect(section).toContain('filters'); + it('contains no em-dashes or en-dashes used as em-dashes', () => { + const prompt = buildCreateDashboardPrompt('sources summary', '', ''); + expect(prompt).not.toMatch(/—/); + // En-dash flanked by spaces reads as an em-dash substitute and is + // disallowed by the voice rules. Numeric ranges (e.g. "1–20") are + // not in the checklist anyway, but guard against them just in case. + expect(prompt).not.toMatch(/ – /); }); }); describe('buildDashboardExamplesPrompt', () => { - it('exposes a drilldown_links example pattern', () => { - const fullPrompt = buildDashboardExamplesPrompt( - 'trace_src', - 'log_src', - 'conn_id', + it('exposes exactly the four verified trace/log examples plus infrastructure_sql', () => { + const all = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', + ); + // Order matters here only to keep the LLM\'s navigation stable + // across releases. If the order changes intentionally, update the + // snapshot. + const patternLineMatch = all.match(/Available patterns: ([^\n]+)/); + expect(patternLineMatch).toBeTruthy(); + const patternList = patternLineMatch?.[1].split(', '); + expect(patternList).toEqual([ + 'service_inventory', + 'service_detail', + 'log_analytics', + 'backend_dependencies', + 'infrastructure_sql', + ]); + }); + + it('renders each example with a leading "When to use" header', () => { + const all = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', ); - expect(fullPrompt).toContain('drilldown_links'); - - // Requesting the specific pattern should return that example. - const patternPrompt = buildDashboardExamplesPrompt( - 'trace_src', - 'log_src', - 'conn_id', - 'drilldown_links', + for (const heading of [ + '== SERVICE INVENTORY ==', + '== SERVICE DETAIL ==', + '== LOG ANALYTICS ==', + '== BACKEND DEPENDENCIES ==', + '== INFRASTRUCTURE (Raw SQL) ==', + ]) { + expect(all).toContain(heading); + } + // Each non-SQL example should explain WHEN to reach for it. The + // SQL example does too, but the heading differs slightly. + const nonSqlSections = all + .split('== ') + .filter(s => + [ + 'SERVICE INVENTORY', + 'SERVICE DETAIL', + 'LOG ANALYTICS', + 'BACKEND DEPENDENCIES', + ].some(h => s.startsWith(h)), + ); + for (const section of nonSqlSections) { + expect(section.toLowerCase()).toContain('when to use'); + } + }); + + it('returns a single example when filtered by pattern', () => { + const single = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', + 'service_inventory', + ); + expect(single).toContain('== SERVICE INVENTORY =='); + expect(single).not.toContain('== SERVICE DETAIL =='); + expect(single).not.toContain('== LOG ANALYTICS =='); + }); + + it('falls back to showing all examples when pattern is unknown', () => { + const fallback = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', + 'made_up_pattern', + ); + expect(fallback).toContain('No example found for pattern'); + // Listing every example name in the fallback gives the LLM a way + // to recover by re-requesting one of the known patterns. + expect(fallback).toContain('service_inventory'); + expect(fallback).toContain('service_detail'); + }); + + it('contains no em-dashes in any example body', () => { + const all = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', ); - expect(patternPrompt).toContain('ROW-CLICK DRILL-DOWN LINKS'); - expect(patternPrompt).toContain('onClick'); - expect(patternPrompt).toContain('type: "search"'); - expect(patternPrompt).toContain('type: "dashboard"'); - expect(patternPrompt).toContain('expressionTemplate'); + expect(all).not.toMatch(/—/); }); }); }); diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index ee45e3c4a3..841f9d6f3e 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -1,10 +1,7 @@ -// ─── Prompt content builders ────────────────────────────────────────────────── -// Each function returns a plain string that is injected as a prompt message. -// -// prose-lint: allow-file -// This file is an LLM-facing prompt template that uses em-dash separators -// (e.g. "line — Time-series trends") as a structural bullet convention. -// Reformatting all separators is out of scope for this change. +// Prompt content builders. Each function returns a plain string that is +// injected as a prompt message. This file is voice-clean (no em-dashes, +// no AI hallmarks) so the prose-lint hook treats it like any other +// reviewer-facing artifact. export function buildCreateDashboardPrompt( sourceSummary: string, @@ -24,10 +21,11 @@ IMPORTANT: Call hyperdx_list_sources first to get the full column schema and att == WORKFLOW == -1. Call hyperdx_list_sources — get source IDs, column schemas, and attribute keys -2. Design tiles — pick tile types that match the monitoring goal -3. Call hyperdx_save_dashboard — create the dashboard with all tiles -4. Call hyperdx_query_tile on each tile — validate queries return data +1. Call hyperdx_list_sources to discover source IDs, column schemas, and attribute keys. +2. Pick the right source kind for the question. Trace data for request volume / latency / errors. Log data for severity / messages. Metric data for system telemetry. +3. Design the dashboard by following the design checklist below. +4. Call hyperdx_save_dashboard with the tiles, containers, and dashboard-level filters. +5. Call hyperdx_query_tile on each tile to confirm queries return data. == UPDATING AN EXISTING DASHBOARD == @@ -55,543 +53,500 @@ Recommended pattern: == TILE TYPE GUIDE == Use BUILDER tiles (with sourceId) for most cases: - line — Time-series trends (error rate, request volume, latency over time) - stacked_bar — Compare categories over time (requests by service, errors by status code) - number — Single KPI metric (total requests, current error rate, p99 latency) - table — Ranked lists (top endpoints by latency, error counts by service). - Tables can wire row-click navigation via config.onClick to the - /search page or another dashboard — see "ROW-CLICK LINKING" below. - pie — Proportional breakdowns (traffic share by service, errors by type) - heatmap — Distribution of a numeric value over time (latency buckets, request size buckets) - Trace sources only. Requires non-empty valueExpression. - search — Browse raw log/event rows (error logs, recent traces) - markdown — Dashboard notes, section headers, or documentation - -Use RAW SQL tiles (with connectionId) only for advanced queries: - Requires configType: "sql" plus a displayType (line, stacked_bar, table, number, pie) - Use when you need JOINs, sub-queries, CTEs, or queries the builder cannot express + line Time-series trends (error rate, request volume, latency over time). + stacked_bar Compare categories over time (requests by service, errors by status code). + number Single KPI metric (total requests, current error rate, p99 latency). + table Ranked lists (top endpoints by latency, error counts by service). + pie Proportional breakdowns (traffic share by service, errors by type). Keep slice count under 8. + heatmap Distribution of a numeric value over time (latency buckets, payload size). Trace sources only. Requires non-empty valueExpression. + search Browse raw log/event rows (error logs, recent traces). + markdown Dashboard notes, section headers, or documentation. + +Use RAW SQL tiles (with connectionId) only for queries the builder cannot express: + Requires configType: "sql" plus a displayType (line, stacked_bar, table, number, pie). + Use when you need JOINs, sub-queries, CTEs, or expressions the builder does not generate. == COLUMN NAMING == -- Top-level columns use PascalCase by default: Duration, StatusCode, SpanName, Body, SeverityText, ServiceName +- Top-level columns use PascalCase by default: Duration, StatusCode, SpanName, Body, SeverityText, ServiceName, SpanKind. NOTE: These are defaults for the standard HyperDX schema. Custom sources may use different names. Always call hyperdx_list_sources to get the real column names and keyColumns for each source. -- Map-type columns use bracket syntax: SpanAttributes['http.method'], ResourceAttributes['service.name'] - NEVER use dot notation for Map columns (SpanAttributes.http.method) — always use brackets. -- JSON-type columns use dot notation: JsonColumn.key.subkey - Check the jsType returned by hyperdx_list_sources to determine whether a column is Map or JSON. -- Call hyperdx_list_sources to discover the exact column names, types, and attribute keys - -== LAYOUT GRID == - -The dashboard grid is 24 columns wide. Tiles are positioned with (x, y, w, h): - - Number tiles: w=6, h=4 — fit 4 across in a row - - Line/Bar charts: w=12, h=4 — fit 2 side-by-side - - Tables: w=24, h=6 — full width - - Search tiles: w=24, h=6 — full width - - Markdown: w=24, h=2 — full width section header - -Recommended layout pattern (top to bottom): - Row 0: KPI number tiles across the top (y=0) - Row 1: Time-series charts (y=4) - Row 2: Tables or search tiles (y=8) - -== FILTER SYNTAX (Lucene) == - -Simple match: level:error -AND: service.name:api AND http.status_code:>=500 -OR: level:error OR level:fatal -Wildcards: service.name:front* -Negation: NOT level:debug -Exists: _exists_:http.route -Range: Duration:>1000000000 -Phrase: Body:"connection refused" -Grouped: (level:error OR level:fatal) AND service.name:api - -== COMPLETE EXAMPLE == - -Here is a full dashboard creation call with properly structured tiles: - -hyperdx_save_dashboard({ - name: "Service Overview", - tags: ["overview"], - tiles: [ - { - name: "Total Requests", - x: 0, y: 0, w: 6, h: 4, - config: { - displayType: "number", - sourceId: "${traceSourceId}", - select: [{ aggFn: "count" }] - } - }, +- Map-type columns use bracket syntax: SpanAttributes['http.method'], ResourceAttributes['service.name']. + NEVER use dot notation for Map columns. Always use brackets. +- JSON-type columns use dot notation: JsonColumn.key.subkey. + Check the jsType returned by hyperdx_list_sources to decide which a column is. + +== DESIGN CHECKLIST == + +Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the renderer; ignoring one is a runtime error you will then need to fix. + +1. ONE QUESTION PER DASHBOARD. A dashboard answers a single observability question well. Split if the request mixes traces, logs, and metrics into unrelated views. + +2. ALIAS EVERY AGGREGATION. Set alias on each select item ("Requests", "Errors", "P95 Duration"). Without alias, table headers read like raw expressions. + +3. INVENTORY-STYLE TABLES PUT THE GROUP BY ON THE LEFT. Set groupByColumnsOnLeft: true on tables that read like a list of things (one row per service, per endpoint, per tenant). + +4. RED COLUMNS FOR SERVICE / ENDPOINT TABLES. Request rate (count), Error rate (count with where: "StatusCode:STATUS_CODE_ERROR"), Duration (quantile P95 on the Duration column). Three columns, all aliased. + +5. PER-SERIES NUMBER FORMAT FOR DURATIONS. Put numberFormat on each select item that needs special rendering. Example: { output: "duration", factor: 0.000000001 } on a Duration percentile. NEVER set chart-level numberFormat for tables that mix counts and durations; the count columns will render as 0:00:00. + +6. LINE CHARTS CAN PASS UP TO 20 SELECT ITEMS. Plot related metrics together (p50, p95, p99 on one chart). Each series can carry its own numberFormat. + +7. HEATMAPS FOR DISTRIBUTIONS. Trace duration buckets, payload size buckets. Trace sources only. Set numberFormat: { output: "duration", factor: 0.000000001 } on the chart config so the y-axis reads in human time. + +8. FOR FOCUSED PER-DIMENSION DASHBOARDS, DECLARE A DASHBOARD-LEVEL FILTER. Pass filters: [{ type: "QUERY_EXPRESSION", name, expression, sourceId }] at the top level. The user gets a dropdown in the dashboard header; every tile on the same source re-scopes when a value is picked. Do NOT hardcode the dimension into each tile's where clause. + +9. METRIC TILES TAKE EXACTLY ONE SELECT ITEM. Each metric is a separate query. To show multiple metrics side by side, create one tile per metric. + +10. GROUP RELATED TILES INTO CONTAINERS. A container holds tiles that share a theme (Overview / Performance / Errors). Set containers: [{ id, title, collapsed }] at the top level and reference container ids from each tile via containerId. Use tabs when the same container needs to show different views of the same data; the tab bar appears when a container has two or more tabs. + +== ADAPT, DO NOT COPY == + +The dashboard_examples prompt returns concrete example shapes. Read them as patterns to adapt to the user's actual request and schema, not as literal templates to substitute names into. Specifically: the literal "ServiceName", "StatusCode:STATUS_CODE_ERROR", "Duration" come from the standard HyperDX schema. Real source schemas may use different column names and status values; always verify with hyperdx_list_sources before reusing literals. + +== COMMON MISTAKES TO AVOID == + +- Using valueExpression with aggFn "count" (count takes no valueExpression). +- Forgetting valueExpression for non-count aggFns (avg, sum, min, max, quantile all require it). +- Using dot notation for Map-type attributes (always use SpanAttributes['key'] bracket syntax). +- Skipping hyperdx_list_sources (you need real source IDs and real column names). +- Skipping hyperdx_query_tile after save (tiles can silently fail on syntax or attribute mismatches). +- Setting chart-level numberFormat on a table that mixes counts and durations (counts render as 0:00:00). +- Multiple select items on number / pie / heatmap / metric tiles (each takes exactly one). +- Missing level on aggFn "quantile" (must specify 0.5, 0.9, 0.95, or 0.99). +- Assuming StatusCode or SeverityText values (always inspect real values from hyperdx_list_sources). +- Heatmap on a non-Trace source (heatmap is Trace-only today). +- Hardcoding a focus dimension into every tile's where clause (use a dashboard-level filter instead).`; +} + +export function buildDashboardExamplesPrompt( + traceSourceId: string, + logSourceId: string, + connectionId: string, + pattern?: string, +): string { + const examples: Record = {}; + + examples['service_inventory'] = ` +== SERVICE INVENTORY == + +When to use: the user wants a multi-service overview, one row per service, with RED columns (Requests / Errors / P95 Duration) and trends underneath. Swap "ServiceName" for any other dimension (operation.name, tenant_id, region) to inventory by that dimension instead. + +Why this shape: the table answers "how is each thing doing right now". The Trends container answers "what changed over time". Tabs keep the trends section compact (only the active view renders). + +{ + name: "Service Inventory", + containers: [ { - name: "Error Count", - x: 6, y: 0, w: 6, h: 4, - config: { - displayType: "number", - sourceId: "${traceSourceId}", - select: [{ aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR" }] - } - }, + id: "trends", + title: "Trends", + collapsed: false, + tabs: [ + { id: "throughput", title: "Throughput" }, + { id: "latency", title: "Latency" }, + { id: "errors", title: "Errors" } + ] + } + ], + tiles: [ { - name: "P95 Latency (ms)", - x: 12, y: 0, w: 6, h: 4, + name: "Services", + x: 0, y: 0, w: 24, h: 10, config: { - displayType: "number", + displayType: "table", sourceId: "${traceSourceId}", - select: [{ aggFn: "quantile", valueExpression: "Duration", level: 0.95 }] + select: [ + { aggFn: "count", alias: "Requests" }, + { aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", whereLanguage: "lucene", alias: "Errors" }, + { + aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95 Duration", + numberFormat: { output: "duration", factor: 0.000000001 } + } + ], + groupBy: "ServiceName", + orderBy: "Requests DESC", + groupByColumnsOnLeft: true } }, { - name: "Request Rate by Service", - x: 0, y: 4, w: 12, h: 4, + name: "Requests over time by service", + containerId: "trends", tabId: "throughput", + x: 0, y: 10, w: 24, h: 8, config: { - displayType: "line", + displayType: "stacked_bar", sourceId: "${traceSourceId}", - select: [{ aggFn: "count" }], - groupBy: "ResourceAttributes['service.name']" + select: [ { aggFn: "count", alias: "Requests" } ], + groupBy: "ServiceName" } }, { - name: "Error Rate Over Time", - x: 12, y: 4, w: 12, h: 4, + name: "P95 Duration over time by service", + containerId: "trends", tabId: "latency", + x: 0, y: 10, w: 24, h: 8, config: { displayType: "line", sourceId: "${traceSourceId}", select: [ - { aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", alias: "Errors" }, - { aggFn: "count", alias: "Total" } + { + aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95", + numberFormat: { output: "duration", factor: 0.000000001 } + } ], - asRatio: true + groupBy: "ServiceName" } }, { - name: "Top Endpoints by Request Count", - x: 0, y: 8, w: 24, h: 6, + name: "Errors over time by service", + containerId: "trends", tabId: "errors", + x: 0, y: 10, w: 24, h: 8, config: { - displayType: "table", + displayType: "stacked_bar", sourceId: "${traceSourceId}", - groupBy: "SpanName", select: [ - { aggFn: "count", alias: "Requests" }, - { aggFn: "avg", valueExpression: "Duration", alias: "Avg Duration" }, - { aggFn: "quantile", valueExpression: "Duration", level: 0.95, alias: "P95 Duration" } - ] + { aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", whereLanguage: "lucene", alias: "Errors" } + ], + groupBy: "ServiceName" } } ] -}) - -== ROW-CLICK LINKING (table tiles) == - -Table tiles support an optional config.onClick that turns each row into a -drill-down link to the /search page or another dashboard. Use this when -the dashboard answers "which thing?" and the user needs the next step — -"what's actually happening in that thing?" — to be one click away. - -Two destination types: - type: "search" — opens /search for a log or trace source. Source - kind must be "log" or "trace"; metric/session - sources are rejected. - type: "dashboard" — opens another HyperDX dashboard. - -Two ways to address the destination: - target.mode: "id" — pin a concrete source/dashboard by its ID. - Prefer this when the target is known up-front; - it survives renames. - target.mode: "template" — render a Handlebars-style template against the - clicked row, then resolve by NAME on the - destination team. Example template "Service Details: {{ServiceName}}" - reads the row's ServiceName column and looks up a - source/dashboard with that name. - -Templating, applied at click time: - whereTemplate — raw filter expression placed in the destination's - \`where\` query param. Uses Lucene or SQL syntax matching - \`whereLanguage\`. Example: "ServiceName = '{{service.name}}'". - filters — array of { kind: "expressionTemplate", expression, - template }. Each becomes an IN clause on the destination - (\`expression IN ('rendered-value')\`); filters that share - an expression are merged into one IN clause. Prefer - filters over whereTemplate for simple row-driven - equality — the destination dashboard auto-populates its - own dropdown filters from these values. - -Field rules: - - onClick is supported on builder table tiles AND raw-SQL tiles with - displayType: "table". Other displayTypes ignore the field. - - whereLanguage is optional but should be set to "sql" or "lucene" to match - the content of the whereTemplate, when one is set. - - Template values are pulled from the clicked row's columns. The column - name in {{...}} must match a column produced by the table query. - - For type="dashboard": each \`filters[i].expression\` MUST match a - top-level DashboardFilter declared on the target dashboard. Otherwise - the value is silently dropped at click time and the destination opens - unfiltered. Call hyperdx_get_dashboard on the target first to inspect - its declared \`filters[]\` list. If the expression isn't declared, - either add it to the target dashboard's \`filters\` array OR use - \`whereTemplate\` instead (which doesn't require a declared filter). - -Example — drill from a per-service error count into the matching trace -search, scoping the search to that service: - { - "name": "Errors by Service", - "config": { - "displayType": "table", - "sourceId": "", - "groupBy": "ResourceAttributes['service.name']", - "select": [ - { "aggFn": "count", "where": "StatusCode:STATUS_CODE_ERROR" } - ], - "onClick": { - "type": "search", - "target": { "mode": "id", "id": "" }, - "whereLanguage": "sql", - "filters": [ - { - "kind": "expressionTemplate", - "expression": "ServiceName", - "template": "{{ResourceAttributes['service.name']}}" - } - ] - } - } - } - -Example — drill from a service overview row into a per-service dashboard. -For the filter to actually land, the TARGET dashboard must declare a -matching DashboardFilter (same \`expression\`). Pass them on the target -dashboard's hyperdx_save_dashboard call as a top-level \`filters\` array. - - Source dashboard (the one with the table tile + onClick): - "onClick": { - "type": "dashboard", - "target": { "mode": "id", "id": "" }, - "whereLanguage": "sql", - "filters": [ - { - "kind": "expressionTemplate", - "expression": "ServiceName", - "template": "{{ResourceAttributes['service.name']}}" - } - ] - } - - Target dashboard (must declare ServiceName as a top-level filter): - hyperdx_save_dashboard({ - id: "", - name: "Service Detail", - tiles: [/* ... */], - filters: [ - { - type: "QUERY_EXPRESSION", - name: "Service", - expression: "ServiceName", - sourceId: "", - whereLanguage: "sql" - } - ] - }) - -== STATUS CODE & SEVERITY VALUES == - -IMPORTANT: The exact values for StatusCode and SeverityText vary by deployment. -Do NOT assume values like "STATUS_CODE_ERROR", "Ok", "error", or "fatal". -Always call hyperdx_list_sources first and inspect the keyValues / mapAttributeKeys -returned for each source to discover the real values used in your data. - -== COMMON MISTAKES TO AVOID == - -- Using valueExpression with aggFn "count" — count does not take a valueExpression -- Forgetting valueExpression for non-count aggFns — avg, sum, min, max, quantile all require it -- Using dot notation for Map-type attributes — always use SpanAttributes['key'] bracket syntax for Map columns -- Not calling hyperdx_list_sources first — you need real source IDs, not placeholders -- Not validating with hyperdx_query_tile after saving — tiles can silently fail -- Number and Pie tiles accept exactly 1 select item — not multiple -- Missing level for quantile aggFn — must specify 0.5, 0.9, 0.95, or 0.99 -- Assuming StatusCode or SeverityText values — always inspect the source first -- Heatmap tile on a non-Trace source — heatmap is restricted to Trace sources today -- Heatmap tile with empty valueExpression — required, e.g. "Duration"`; -} +}`; -export function buildDashboardExamplesPrompt( - traceSourceId: string, - logSourceId: string, - connectionId: string, - pattern?: string, -): string { - const examples: Record = {}; + examples['service_detail'] = ` +== SERVICE DETAIL == - examples['service_overview'] = ` -== SERVICE HEALTH OVERVIEW == +When to use: the user wants a focused per-service view (one service at a time, picked via a dropdown). Drop-in replacement: any per-dimension dashboard (per-endpoint, per-tenant, per-region) follows the same shape, just swap the filter expression. -A high-level view of service health with KPIs, trends, and endpoint details. +Why this shape: a dashboard-level filter on ServiceName re-scopes every tile globally; tiles do NOT hardcode the service into their where clause. Four containers separate concerns: Overview (KPIs), Performance (latency line + heatmap), Endpoints (per-SpanName RED breakdown), Errors (top error messages + recent error spans). Errors container starts expanded so the user sees error context immediately when investigating. { - name: "Service Health Overview", - tags: ["overview", "service"], - tiles: [ + name: "Service Detail", + filters: [ { - name: "Total Requests", - x: 0, y: 0, w: 6, h: 4, - config: { - displayType: "number", - sourceId: "${traceSourceId}", - select: [{ aggFn: "count" }] - } - }, + type: "QUERY_EXPRESSION", + name: "Service", + expression: "ServiceName", + sourceId: "${traceSourceId}" + } + ], + containers: [ + { id: "overview", title: "Overview", collapsed: false }, + { id: "performance", title: "Performance", collapsed: false }, + { id: "endpoints", title: "Endpoints (per span name)", collapsed: false }, + { id: "errors", title: "Errors", collapsed: false } + ], + tiles: [ { - name: "Error Count", - x: 6, y: 0, w: 6, h: 4, + name: "Requests", containerId: "overview", + x: 0, y: 0, w: 8, h: 4, config: { displayType: "number", sourceId: "${traceSourceId}", - select: [{ aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR" }] + select: [ { aggFn: "count", alias: "Requests" } ] } }, { - name: "Avg Latency", - x: 12, y: 0, w: 6, h: 4, + name: "Errors", containerId: "overview", + x: 8, y: 0, w: 8, h: 4, config: { displayType: "number", sourceId: "${traceSourceId}", - select: [{ aggFn: "avg", valueExpression: "Duration" }] + select: [ { aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", whereLanguage: "lucene", alias: "Errors" } ] } }, { - name: "P99 Latency", - x: 18, y: 0, w: 6, h: 4, + name: "P95 Duration", containerId: "overview", + x: 16, y: 0, w: 8, h: 4, config: { displayType: "number", sourceId: "${traceSourceId}", - select: [{ aggFn: "quantile", valueExpression: "Duration", level: 0.99 }] + select: [ + { + aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95", + numberFormat: { output: "duration", factor: 0.000000001 } + } + ] } }, { - name: "Request Volume Over Time", - x: 0, y: 4, w: 12, h: 4, + name: "Latency Percentiles", containerId: "performance", + x: 0, y: 4, w: 12, h: 6, config: { displayType: "line", sourceId: "${traceSourceId}", - select: [{ aggFn: "count" }], - groupBy: "ResourceAttributes['service.name']" + select: [ + { aggFn: "quantile", level: 0.5, valueExpression: "Duration", alias: "P50", numberFormat: { output: "duration", factor: 0.000000001 } }, + { aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95", numberFormat: { output: "duration", factor: 0.000000001 } }, + { aggFn: "quantile", level: 0.99, valueExpression: "Duration", alias: "P99", numberFormat: { output: "duration", factor: 0.000000001 } } + ] } }, { - name: "Error Rate Over Time", - x: 12, y: 4, w: 12, h: 4, + name: "Duration Distribution", containerId: "performance", + x: 12, y: 4, w: 12, h: 6, config: { - displayType: "line", + displayType: "heatmap", sourceId: "${traceSourceId}", - select: [ - { aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", alias: "Errors" }, - { aggFn: "count", alias: "Total" } - ], - asRatio: true + select: [ { valueExpression: "Duration", heatmapScaleType: "log" } ], + where: "", whereLanguage: "lucene", + numberFormat: { output: "duration", factor: 0.000000001 } } }, { - name: "Top Endpoints", - x: 0, y: 8, w: 24, h: 6, + name: "Endpoints", containerId: "endpoints", + x: 0, y: 10, w: 24, h: 8, config: { displayType: "table", sourceId: "${traceSourceId}", - groupBy: "SpanName", select: [ { aggFn: "count", alias: "Requests" }, - { aggFn: "avg", valueExpression: "Duration", alias: "Avg Duration" }, - { aggFn: "quantile", valueExpression: "Duration", level: 0.95, alias: "P95" }, - { aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", alias: "Errors" } - ] - } - } - ] -}`; - - examples['error_tracking'] = ` -== ERROR TRACKING == - -Focus on errors: volume, distribution, and raw error logs. - -{ - name: "Error Tracking", - tags: ["errors"], - tiles: [ - { - name: "Total Errors", - x: 0, y: 0, w: 8, h: 4, - config: { - displayType: "number", - sourceId: "${logSourceId}", - select: [{ aggFn: "count", where: "SeverityText:error OR SeverityText:fatal" }] + { aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", whereLanguage: "lucene", alias: "Errors" }, + { + aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95 Duration", + numberFormat: { output: "duration", factor: 0.000000001 } + } + ], + groupBy: "SpanName", + orderBy: "Requests DESC", + groupByColumnsOnLeft: true } }, { - name: "Errors Over Time by Service", - x: 0, y: 4, w: 12, h: 4, + name: "Errors Over Time", containerId: "errors", + x: 0, y: 18, w: 12, h: 6, config: { - displayType: "line", - sourceId: "${logSourceId}", - select: [{ aggFn: "count", where: "SeverityText:error OR SeverityText:fatal" }], - groupBy: "ResourceAttributes['service.name']" + displayType: "stacked_bar", + sourceId: "${traceSourceId}", + select: [ { aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", whereLanguage: "lucene", alias: "Errors" } ], + groupBy: "SpanName" } }, { - name: "Error Breakdown by Service", - x: 12, y: 4, w: 12, h: 4, + name: "Top Error Messages", containerId: "errors", + x: 12, y: 18, w: 12, h: 6, config: { - displayType: "pie", - sourceId: "${logSourceId}", - select: [{ aggFn: "count", where: "SeverityText:error OR SeverityText:fatal" }], - groupBy: "ResourceAttributes['service.name']" + displayType: "table", + sourceId: "${traceSourceId}", + select: [ { aggFn: "count", alias: "Count" } ], + groupBy: "StatusMessage", + having: "StatusMessage != ''", + orderBy: "Count DESC", + groupByColumnsOnLeft: true } }, { - name: "Error Logs", - x: 0, y: 8, w: 24, h: 6, + name: "Recent Error Spans", containerId: "errors", + x: 0, y: 24, w: 24, h: 6, config: { displayType: "search", - sourceId: "${logSourceId}", - where: "SeverityText:error OR SeverityText:fatal" + sourceId: "${traceSourceId}", + select: "Timestamp, ServiceName, SpanName, StatusMessage, SpanAttributes['http.status_code'] AS HttpStatus, Duration", + where: "StatusCode:STATUS_CODE_ERROR AND StatusMessage:*", + whereLanguage: "lucene" } } ] }`; - examples['latency'] = ` -== LATENCY MONITORING == + examples['log_analytics'] = ` +== LOG ANALYTICS == + +When to use: the user is investigating logs, not traces. Trace columns (Duration, SpanName, StatusCode) do NOT apply on a log source. Logs use SeverityText, Body, ServiceName, LogAttributes. -Track response times with percentile breakdowns and slow endpoint identification. +Why this shape: KPIs across the top with severity filters, severity and service breakdown in the middle (stacked_bar + pie + ranked table), recent errors as a search tile at the bottom for drill-down. A dashboard-level filter on ServiceName lets the user narrow to one service. { - name: "Latency Monitoring", - tags: ["latency", "performance"], + name: "Log Analytics", + filters: [ + { + type: "QUERY_EXPRESSION", + name: "Service", + expression: "ServiceName", + sourceId: "${logSourceId}" + } + ], + containers: [ + { id: "kpis", title: "Volume", collapsed: false }, + { id: "breakdown", title: "Breakdown", collapsed: false }, + { id: "recent", title: "Recent activity", collapsed: false } + ], tiles: [ { - name: "P50 Latency", - x: 0, y: 0, w: 6, h: 4, + name: "Total logs", containerId: "kpis", + x: 0, y: 0, w: 8, h: 4, config: { displayType: "number", - sourceId: "${traceSourceId}", - select: [{ aggFn: "quantile", valueExpression: "Duration", level: 0.5 }] + sourceId: "${logSourceId}", + select: [ { aggFn: "count", alias: "Total" } ] } }, { - name: "P95 Latency", - x: 6, y: 0, w: 6, h: 4, + name: "Errors", containerId: "kpis", + x: 8, y: 0, w: 8, h: 4, config: { displayType: "number", - sourceId: "${traceSourceId}", - select: [{ aggFn: "quantile", valueExpression: "Duration", level: 0.95 }] + sourceId: "${logSourceId}", + select: [ { aggFn: "count", where: "SeverityText:error", whereLanguage: "lucene", alias: "Errors" } ] } }, { - name: "P99 Latency", - x: 12, y: 0, w: 6, h: 4, + name: "Warnings", containerId: "kpis", + x: 16, y: 0, w: 8, h: 4, config: { displayType: "number", - sourceId: "${traceSourceId}", - select: [{ aggFn: "quantile", valueExpression: "Duration", level: 0.99 }] + sourceId: "${logSourceId}", + select: [ { aggFn: "count", where: "SeverityText:warn", whereLanguage: "lucene", alias: "Warnings" } ] } }, { - name: "Latency Percentiles Over Time", - x: 0, y: 4, w: 24, h: 4, + name: "Volume by severity", containerId: "breakdown", + x: 0, y: 4, w: 12, h: 6, config: { - displayType: "line", - sourceId: "${traceSourceId}", - select: [ - { aggFn: "quantile", valueExpression: "Duration", level: 0.5, alias: "P50" }, - { aggFn: "quantile", valueExpression: "Duration", level: 0.95, alias: "P95" }, - { aggFn: "quantile", valueExpression: "Duration", level: 0.99, alias: "P99" } - ] + displayType: "stacked_bar", + sourceId: "${logSourceId}", + select: [ { aggFn: "count", alias: "Events" } ], + groupBy: "SeverityText" } }, { - name: "Latency by Service", - x: 0, y: 8, w: 12, h: 4, + name: "Volume by service", containerId: "breakdown", + x: 12, y: 4, w: 12, h: 6, config: { - displayType: "stacked_bar", - sourceId: "${traceSourceId}", - select: [{ aggFn: "avg", valueExpression: "Duration" }], - groupBy: "ResourceAttributes['service.name']" + displayType: "pie", + sourceId: "${logSourceId}", + select: [ { aggFn: "count", alias: "Events" } ], + groupBy: "ServiceName" } }, { - name: "Slowest Endpoints", - x: 12, y: 8, w: 12, h: 6, + name: "Top services by error count", containerId: "breakdown", + x: 0, y: 10, w: 24, h: 8, config: { displayType: "table", - sourceId: "${traceSourceId}", - groupBy: "SpanName", + sourceId: "${logSourceId}", select: [ - { aggFn: "quantile", valueExpression: "Duration", level: 0.95, alias: "P95 Duration" }, - { aggFn: "avg", valueExpression: "Duration", alias: "Avg Duration" }, - { aggFn: "count", alias: "Request Count" } - ] + { aggFn: "count", alias: "Total logs" }, + { aggFn: "count", where: "SeverityText:error", whereLanguage: "lucene", alias: "Errors" }, + { aggFn: "count", where: "SeverityText:warn", whereLanguage: "lucene", alias: "Warnings" } + ], + groupBy: "ServiceName", + orderBy: "Errors DESC", + groupByColumnsOnLeft: true + } + }, + { + name: "Recent errors", containerId: "recent", + x: 0, y: 18, w: 24, h: 8, + config: { + displayType: "search", + sourceId: "${logSourceId}", + select: "Timestamp, ServiceName, SeverityText, Body", + where: "SeverityText:error", + whereLanguage: "lucene" } } ] }`; - examples['log_analysis'] = ` -== LOG ANALYSIS == + examples['backend_dependencies'] = ` +== BACKEND DEPENDENCIES == -Analyze log volume, severity distribution, and browse log events. +When to use: the user wants to see what a backend service calls OUT to (downstream databases, HTTP clients, queues). Filters the trace source by SpanKind to "Client" so every tile reports on outbound spans only. + +Why this shape: the per-series where on each aggregation is the right place to scope by SpanKind, because the user may still want to compare the service's own server-side metrics on a different dashboard. The Top downstream operations table is the inventory of dependencies; the trends section shows volume and latency over time. { - name: "Log Analysis", - tags: ["logs"], + name: "Backend Dependencies", + filters: [ + { + type: "QUERY_EXPRESSION", + name: "Service", + expression: "ServiceName", + sourceId: "${traceSourceId}" + } + ], + containers: [ + { id: "kpis", title: "Outbound traffic", collapsed: false }, + { id: "operations", title: "Top downstream operations", collapsed: false }, + { id: "trends", title: "Trends", collapsed: false } + ], tiles: [ { - name: "Total Log Events", + name: "Outbound calls", containerId: "kpis", x: 0, y: 0, w: 8, h: 4, config: { displayType: "number", - sourceId: "${logSourceId}", - select: [{ aggFn: "count" }] + sourceId: "${traceSourceId}", + select: [ { aggFn: "count", where: "SpanKind:Client", whereLanguage: "lucene", alias: "Client spans" } ] } }, { - name: "Log Volume by Severity", - x: 0, y: 4, w: 12, h: 4, + name: "Client errors", containerId: "kpis", + x: 8, y: 0, w: 8, h: 4, config: { - displayType: "stacked_bar", - sourceId: "${logSourceId}", - select: [{ aggFn: "count" }], - groupBy: "SeverityText" + displayType: "number", + sourceId: "${traceSourceId}", + select: [ { aggFn: "count", where: "SpanKind:Client AND StatusCode:STATUS_CODE_ERROR", whereLanguage: "lucene", alias: "Errors" } ] } }, { - name: "Severity Breakdown", - x: 12, y: 4, w: 12, h: 4, + name: "P95 client duration", containerId: "kpis", + x: 16, y: 0, w: 8, h: 4, config: { - displayType: "pie", - sourceId: "${logSourceId}", - select: [{ aggFn: "count" }], - groupBy: "SeverityText" + displayType: "number", + sourceId: "${traceSourceId}", + select: [ + { + aggFn: "quantile", level: 0.95, valueExpression: "Duration", + where: "SpanKind:Client", whereLanguage: "lucene", alias: "P95", + numberFormat: { output: "duration", factor: 0.000000001 } + } + ] } }, { - name: "Top Services by Log Volume", - x: 0, y: 8, w: 12, h: 6, + name: "RED by downstream operation", containerId: "operations", + x: 0, y: 4, w: 24, h: 10, config: { displayType: "table", - sourceId: "${logSourceId}", - groupBy: "ResourceAttributes['service.name']", + sourceId: "${traceSourceId}", select: [ - { aggFn: "count", alias: "Log Count" }, - { aggFn: "count", where: "SeverityText:error OR SeverityText:fatal", alias: "Error Count" } - ] + { aggFn: "count", where: "SpanKind:Client", whereLanguage: "lucene", alias: "Calls" }, + { aggFn: "count", where: "SpanKind:Client AND StatusCode:STATUS_CODE_ERROR", whereLanguage: "lucene", alias: "Errors" }, + { + aggFn: "quantile", level: 0.95, valueExpression: "Duration", + where: "SpanKind:Client", whereLanguage: "lucene", alias: "P95 Duration", + numberFormat: { output: "duration", factor: 0.000000001 } + } + ], + groupBy: "SpanName", + orderBy: "Calls DESC", + groupByColumnsOnLeft: true } }, { - name: "Recent Logs", - x: 12, y: 8, w: 12, h: 6, + name: "Outbound call volume over time", containerId: "trends", + x: 0, y: 14, w: 12, h: 6, config: { - displayType: "search", - sourceId: "${logSourceId}" + displayType: "stacked_bar", + sourceId: "${traceSourceId}", + select: [ { aggFn: "count", where: "SpanKind:Client", whereLanguage: "lucene", alias: "Calls" } ], + groupBy: "SpanName" + } + }, + { + name: "Client P95 latency over time", containerId: "trends", + x: 12, y: 14, w: 12, h: 6, + config: { + displayType: "line", + sourceId: "${traceSourceId}", + select: [ + { + aggFn: "quantile", level: 0.95, valueExpression: "Duration", + where: "SpanKind:Client", whereLanguage: "lucene", alias: "P95", + numberFormat: { output: "duration", factor: 0.000000001 } + } + ], + groupBy: "SpanName" } } ] @@ -673,14 +628,12 @@ copy the right ID and discover which filters are available. }`; examples['infrastructure_sql'] = ` -== INFRASTRUCTURE MONITORING (Raw SQL) == +== INFRASTRUCTURE (Raw SQL) == -Advanced dashboard using raw SQL tiles for custom ClickHouse queries. -Use this pattern when you need JOINs, CTEs, or queries the builder cannot express. +When to use: the user wants metric-like infrastructure visibility (request rate, ingestion volume, table sizes) but the builder tile types do not express the exact aggregation. Raw SQL tiles take connectionId (not sourceId) and a sqlTemplate with HyperDX macros. { name: "Infrastructure (SQL)", - tags: ["infrastructure", "sql"], tiles: [ { name: "Log Ingestion Rate Over Time", @@ -717,43 +670,48 @@ Use this pattern when you need JOINs, CTEs, or queries the builder cannot expres SQL TEMPLATE REFERENCE: Macros (expanded before execution): - $__timeFilter(col) — col >= AND col <= (DateTime) - $__timeFilter_ms(col) — same with DateTime64 millisecond precision - $__dateFilter(col) — same with Date precision - $__timeInterval(col) — time bucket: toStartOfInterval(toDateTime(col), INTERVAL ...) - $__timeInterval_ms(col) — same with millisecond precision - $__fromTime / $__toTime — start/end as DateTime values - $__fromTime_ms / $__toTime_ms — start/end as DateTime64 values - $__interval_s — raw interval in seconds - $__filters — dashboard filter conditions (resolves to 1=1 when none) + $__timeFilter(col) col >= AND col <= (DateTime) + $__timeFilter_ms(col) same with DateTime64 millisecond precision + $__dateFilter(col) same with Date precision + $__timeInterval(col) time bucket: toStartOfInterval(toDateTime(col), INTERVAL ...) + $__timeInterval_ms(col) same with millisecond precision + $__fromTime / $__toTime start/end as DateTime values + $__fromTime_ms / $__toTime_ms start/end as DateTime64 values + $__interval_s raw interval in seconds + $__filters dashboard filter conditions (resolves to 1=1 when none) Query parameters: - {startDateMilliseconds:Int64} — start of date range in milliseconds - {endDateMilliseconds:Int64} — end of date range in milliseconds - {intervalSeconds:Int64} — time bucket size in seconds - {intervalMilliseconds:Int64} — time bucket size in milliseconds + {startDateMilliseconds:Int64} start of date range in milliseconds + {endDateMilliseconds:Int64} end of date range in milliseconds + {intervalSeconds:Int64} time bucket size in seconds + {intervalMilliseconds:Int64} time bucket size in milliseconds Available parameters by displayType: - line / stacked_bar — startDate, endDate, interval (all available) - table / number / pie — startDate, endDate only (no interval)`; + line / stacked_bar startDate, endDate, interval (all available) + table / number / pie startDate, endDate only (no interval)`; + + const preface = + 'Concrete dashboard examples for common observability patterns. ' + + 'These are PATTERNS to adapt, not literal templates. Adapt the structure ' + + "(table shape, container layout, filter expression) to the user's request; " + + 'always verify column names and status literals via hyperdx_list_sources before reusing them.\n' + + 'Replace ${sourceId} / ${connectionId} placeholders with real IDs from hyperdx_list_sources.\n\n'; if (pattern) { const key = pattern.toLowerCase().replace(/[\s-]+/g, '_'); const matched = Object.entries(examples).find(([k]) => k === key); if (matched) { - return `Dashboard example for pattern: ${pattern}\n\nReplace sourceId/connectionId values with real IDs from hyperdx_list_sources.\nNOTE: Column names below (Duration, StatusCode, SpanName, etc.) are defaults for the standard schema. Call hyperdx_list_sources to get the actual column names for your sources.\n${matched[1]}`; + return `${preface}Dashboard example for pattern: ${pattern}\n${matched[1]}`; } return ( `No example found for pattern "${pattern}". Available patterns: ${Object.keys(examples).join(', ')}\n\n` + - `Showing all examples below.\n\n` + + preface + Object.values(examples).join('\n') ); } return ( - `Complete dashboard examples for common observability patterns.\n` + - `Replace sourceId/connectionId values with real IDs from hyperdx_list_sources.\n` + - `NOTE: Column names below (Duration, StatusCode, SpanName, etc.) are defaults for the standard schema. Call hyperdx_list_sources to get the actual column names for your sources.\n\n` + + preface + `Available patterns: ${Object.keys(examples).join(', ')}\n` + Object.values(examples).join('\n') ); @@ -764,17 +722,16 @@ export function buildQueryGuidePrompt(): string { == AGGREGATION FUNCTIONS (aggFn) == - count — Count matching rows. Does NOT take a valueExpression. - sum — Sum of a numeric column. Requires valueExpression. - avg — Average of a numeric column. Requires valueExpression. - min — Minimum value. Requires valueExpression. - max — Maximum value. Requires valueExpression. - count_distinct — Count of unique values. Requires valueExpression. - quantile — Percentile value. Requires valueExpression AND level (0.5, 0.9, 0.95, or 0.99). - last_value — Most recent value of a column. Requires valueExpression. - none — Pass a raw expression unchanged. Requires valueExpression. - heatmap — Heatmap-only literal. Requires non-empty valueExpression. - Used exclusively in heatmap tiles' select array. + count Count matching rows. Does NOT take a valueExpression. + sum Sum of a numeric column. Requires valueExpression. + avg Average of a numeric column. Requires valueExpression. + min Minimum value. Requires valueExpression. + max Maximum value. Requires valueExpression. + count_distinct Count of unique values. Requires valueExpression. + quantile Percentile value. Requires valueExpression AND level (0.5, 0.9, 0.95, or 0.99). + last_value Most recent value of a column. Requires valueExpression. + none Pass a raw expression unchanged. Requires valueExpression. + heatmap Heatmap-only literal. Requires non-empty valueExpression. Used exclusively in heatmap tiles. Examples: { aggFn: "count" } @@ -786,14 +743,14 @@ Examples: == COLUMN NAMING == -Top-level columns (PascalCase defaults — use directly in valueExpression and groupBy): - Duration, StatusCode, SpanName, ServiceName, Body, SeverityText, - Timestamp, TraceId, SpanId, SpanKind, ParentSpanId - NOTE: These are the defaults for the standard HyperDX schema. Custom sources may +Top-level columns (PascalCase defaults, use directly in valueExpression and groupBy): + Duration, StatusCode, SpanName, ServiceName, SpanKind, Body, SeverityText, + Timestamp, TraceId, SpanId, ParentSpanId + NOTE: These are defaults for the standard HyperDX schema. Custom sources may use different column names. Always verify with hyperdx_list_sources, which returns the real column names and keyColumns expressions for each source. -Map-type columns (bracket syntax — access keys via ['key']): +Map-type columns (bracket syntax, access keys via ['key']): SpanAttributes['http.method'] SpanAttributes['http.route'] SpanAttributes['http.status_code'] @@ -804,9 +761,9 @@ IMPORTANT: Always use bracket syntax for Map-type columns. Never use dot notatio Correct: SpanAttributes['http.method'] Incorrect: SpanAttributes.http.method -JSON-type columns (dot notation — access nested keys via dot path): +JSON-type columns (dot notation, access nested keys via dot path): JsonColumn.key.subkey - NOTE: Check the jsType field returned by hyperdx_list_sources to determine + NOTE: Check the jsType field returned by hyperdx_list_sources to decide whether a column is Map (use brackets) or JSON (use dots). == LUCENE FILTER SYNTAX == @@ -825,7 +782,7 @@ Used in the "where" field of select items and search tiles. Grouped: (level:error OR level:fatal) AND service.name:api NOTE: In Lucene filters, use dot notation for attribute keys (service.name, http.method). -This is different from valueExpression/groupBy which requires bracket syntax (SpanAttributes['http.method']). +This is different from valueExpression and groupBy which require bracket syntax (SpanAttributes['http.method']). == SQL FILTER SYNTAX == @@ -843,16 +800,16 @@ Alternative to Lucene. Set whereLanguage: "sql" when using SQL syntax. For configType: "sql" tiles, write ClickHouse SQL with template macros: MACROS (expanded before execution): - $__timeFilter(col) — col >= AND col <= - $__timeFilter_ms(col) — same with DateTime64 millisecond precision - $__dateFilter(col) — same with Date precision - $__dateTimeFilter(d, t) — filters on both Date and DateTime columns - $__timeInterval(col) — time bucket expression for GROUP BY - $__timeInterval_ms(col) — same with millisecond precision - $__fromTime / $__toTime — start/end as DateTime values - $__fromTime_ms / $__toTime_ms — start/end as DateTime64 values - $__interval_s — raw interval in seconds (for arithmetic) - $__filters — dashboard filter conditions (1=1 when none) + $__timeFilter(col) col >= AND col <= + $__timeFilter_ms(col) same with DateTime64 millisecond precision + $__dateFilter(col) same with Date precision + $__dateTimeFilter(d, t) filters on both Date and DateTime columns + $__timeInterval(col) time bucket expression for GROUP BY + $__timeInterval_ms(col) same with millisecond precision + $__fromTime / $__toTime start/end as DateTime values + $__fromTime_ms / $__toTime_ms start/end as DateTime64 values + $__interval_s raw interval in seconds (for arithmetic) + $__filters dashboard filter conditions (1=1 when none) QUERY PARAMETERS (ClickHouse parameterized syntax): {startDateMilliseconds:Int64} @@ -883,26 +840,41 @@ For configType: "sql" tiles, write ClickHouse SQL with template macros: ORDER BY request_count DESC LIMIT 50 - IMPORTANT: Always include a LIMIT clause in table/number/pie SQL queries. + IMPORTANT: Always include a LIMIT clause in table / number / pie SQL queries. == PER-TILE TYPE CONSTRAINTS == - number — Exactly 1 select item. No groupBy. - pie — Exactly 1 select item. groupBy defines the slices. - line — 1-20 select items. Optional groupBy splits into series. - stacked_bar — 1-20 select items. Optional groupBy splits into stacks. - table — 1-20 select items. Optional groupBy defines row groups. - heatmap — Exactly 1 select item with a non-empty valueExpression. No - aggFn or alias on the select item (the chart-level - displayType: "heatmap" is the discriminator). Trace sources - only (no Log/Metric/Session). No groupBy. Optional where - filter applied before bucketing. - search — No select items (select is a column list string). where is the filter. - markdown — No select items. Set markdown field with content. + number Exactly 1 select item. No groupBy. + pie Exactly 1 select item. groupBy defines the slices. Keep slice count under 8. + line 1 to 20 select items. Optional groupBy splits into series. Each select item may carry its own numberFormat. + stacked_bar 1 to 20 select items. Optional groupBy splits into stacks. + table 1 to 20 select items. Optional groupBy defines row groups. Per-series numberFormat lets one column render as a duration while a sibling count column stays a plain number. + heatmap Exactly 1 select item with a non-empty valueExpression. No aggFn or alias on the select item (the chart-level displayType: "heatmap" is the discriminator). Trace sources only (no Log/Metric/Session). No groupBy. Optional where filter applied before bucketing. + search No select items (select is a column list string). where is the filter. + markdown No select items. Set markdown field with content. + + METRIC TILES (any of the above with metric source): exactly 1 select item with metricName + metricType (gauge / sum / histogram). To show multiple metrics side by side, create one tile per metric. + +== NUMBER FORMAT == + +numberFormat controls how a value renders. Two places to set it: + + Per-series (on each select item): { output: "...", factor: ... } on a single select. Other series in the same tile keep their default rendering. USE THIS for tables and line charts that mix count and duration columns. + + Chart-level (on tile config): same shape on the tile config root. Applies to every value in the tile. USE THIS for number, heatmap, and tiles where every series shares a unit. + +Common outputs: + duration Auto-formats elapsed time as "1.2s" / "200ms". Use factor: 0.000000001 for nanosecond Duration columns. + byte Auto-formats as KB / MB / GB. + percent Multiplies by 100 and appends %. + number Plain number. Set thousandSeparated: true for "1,234,567" and average: true for "1.2m" abbreviation. + currency Prepends currencySymbol. + data_rate Bytes per second. + throughput Count per second. == asRatio == -Set asRatio: true on line/stacked_bar/table tiles with exactly 2 select items +Set asRatio: true on line / stacked_bar / table tiles with exactly 2 select items to plot the first as a ratio of the second. Useful for error rates: select: [ { aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", alias: "Errors" }, @@ -910,156 +882,44 @@ to plot the first as a ratio of the second. Useful for error rates: ], asRatio: true -== TABLE TILE LINKING (config.onClick) == - -Table tiles can wire up row-click navigation via config.onClick. Builder -table tiles (displayType: "table") and raw-SQL tiles (configType: "sql", -displayType: "table") both accept this; other tile types ignore it. - -Destination types: - { type: "search", target, whereLanguage, whereTemplate?, filters? } - Opens the /search page for a log or trace source. Metric and session - sources are rejected by the server (the /search page does not render - those kinds). - { type: "dashboard", target, whereLanguage, whereTemplate?, filters? } - Opens another HyperDX dashboard owned by the same team. - -Target shape — how the destination is identified: - target: { mode: "id", id: "" } - Pins a specific source (for type=search) or dashboard - (for type=dashboard) by its ID. Prefer this when the target is known. - Find source IDs with hyperdx_list_sources; find dashboard IDs with - hyperdx_get_dashboard (no id arg returns the list of all dashboards). - target: { mode: "template", template: "" } - Renders the template against the clicked row's columns, then resolves - the result by NAME on the destination team. Example template - "{{Service}}" reads the row's Service value and looks up a - source/dashboard with that name. Use this when the destination depends - on which row was clicked. - -Templating values come from the clicked row's columns. The column name in -{{...}} must match a column produced by the table query (group-by columns -and aliases both work). - - whereTemplate — Handlebars-style template. The rendered string is placed - in the destination's \`where\` query param. Uses Lucene or - SQL syntax matching \`whereLanguage\`. Example: - "ServiceName = '{{service.name}}'". - - filters — array of { kind: "expressionTemplate", expression, - template }. Each filter renders to an IN clause on the - destination (\`expression IN ('rendered-value')\`). When - multiple filters share an expression they are merged - into one IN clause. - For type="dashboard" targets: the destination only - applies these when it declares a top-level - DashboardFilter with the SAME \`expression\` — otherwise - the value is dropped at click time and the - destination opens unfiltered. Call hyperdx_get_dashboard - on the target first to confirm its \`filters[].expression\` - list, and either add a matching DashboardFilter (via the - hyperdx_save_dashboard \`filters\` param on the target) - or fall back to whereTemplate. For type="search" targets: - no declaration required; filters always apply. - -whereLanguage is optional, but set it to "sql" or "lucene" so the -destination knows how to parse whereTemplate / filter values when a -template is rendered. - -Example — search drill-down with a row-driven filter: - { - "displayType": "table", - "sourceId": "", - "groupBy": "ResourceAttributes['service.name']", - "select": [{ "aggFn": "count" }], - "onClick": { - "type": "search", - "target": { "mode": "id", "id": "" }, - "whereLanguage": "sql", - "filters": [ - { - "kind": "expressionTemplate", - "expression": "ServiceName", - "template": "{{ResourceAttributes['service.name']}}" - } - ] - } - } +== DASHBOARD FILTERS == -Example — dashboard drill-down with multiple row-driven filters. Both -\`expression\` values (ServiceName, Environment) must be declared on the -target dashboard's top-level \`filters\` for the values to apply. - Source tile onClick: - "onClick": { - "type": "dashboard", - "target": { "mode": "id", "id": "" }, - "whereLanguage": "sql", - "filters": [ - { - "kind": "expressionTemplate", - "expression": "ServiceName", - "template": "{{ResourceAttributes['service.name']}}" - }, - { - "kind": "expressionTemplate", - "expression": "Environment", - "template": "{{ResourceAttributes['deployment.environment']}}" - } - ] - } - Target dashboard's top-level \`filters\` (passed to - hyperdx_save_dashboard when creating/updating it): - filters: [ - { type: "QUERY_EXPRESSION", name: "Service", expression: "ServiceName", - sourceId: "", whereLanguage: "sql" }, - { type: "QUERY_EXPRESSION", name: "Environment", expression: "Environment", - sourceId: "", whereLanguage: "sql" } - ] +Optional dashboard-level filter declarations. Each entry adds a dropdown to the dashboard header that scopes every tile against the same source. Use this for focused per-dimension dashboards (per-service, per-tenant, per-endpoint) instead of hardcoding the dimension into every tile's where clause. -Example — destination chosen by the clicked row (rare; prefer mode="id"): - "onClick": { - "type": "dashboard", - "target": { "mode": "template", "template": "{{TargetDashboardName}}" }, - "whereLanguage": "lucene" - } +Filter shape: + { type, name, expression, sourceId, where?, whereLanguage? } -Validation categories the server enforces: - - target.id (mode="id") must be a valid ObjectId. - - For type="search", target.id must reference an existing source whose - kind is "log" or "trace" — metric/session sources are rejected with: - "The following onClick search source IDs are not log or trace sources: ..." - - For type="dashboard", target.id must reference an existing dashboard - owned by the same team; missing dashboards are rejected with: - "Could not find the following onClick dashboard IDs: ..." - - For type="dashboard" with \`filters\`, each filter's \`expression\` must - match a top-level DashboardFilter declared on the target dashboard. - Otherwise the save is rejected with: - "onClick filter expressions are not declared on the target dashboard - and would be silently dropped at click time: ..." + type "QUERY_EXPRESSION" (the only currently supported type). + name Human label shown in the filter dropdown (e.g. "Service"). + expression Column or attribute path the filter scopes (e.g. "ServiceName" or "SpanAttributes['tenant.id']"). + sourceId Which source the expression resolves against. Tiles on a different source are NOT scoped by the filter. + where Optional pre-filter that narrows the set of distinct values offered in the dropdown. + whereLanguage "lucene" or "sql". Defaults to "lucene". + +Example: + filters: [ + { type: "QUERY_EXPRESSION", name: "Service", expression: "ServiceName", sourceId: "" } + ] + +When a value is picked in the dropdown, the renderer combines it with each tile's existing where clause via AND. Tiles do NOT need to reference the filter name; the source match alone is enough. == CONTAINERS AND TABS == -Optional dashboard organization layer. Group related tiles visually with -optional tab bars. Pass on hyperdx_save_dashboard as a top-level "containers" -array; reference containers from tiles via "containerId" (and "tabId" when -the container has tabs). +Optional dashboard organization layer. Group related tiles visually with optional tab bars. Pass on hyperdx_save_dashboard as a top-level "containers" array; reference containers from tiles via "containerId" (and "tabId" when the container has tabs). Container shape: { id, title, collapsed, collapsible?, bordered?, tabs? } - id, title : required, non-empty strings. - collapsed : required boolean. Starts collapsed when true. - collapsible : optional boolean. Defaults to true. When false the group - header has no toggle. - bordered : optional boolean. Defaults to true. - tabs : optional array of { id, title }. Zero or one tab renders - as a plain group header; two or more render as a tab bar. + id Required, non-empty. + title Required, non-empty. Shown as the group header. + collapsed Required boolean. Starts collapsed when true. + collapsible Optional boolean. Defaults to true. When false the group header has no toggle. + bordered Optional boolean. Defaults to true. + tabs Optional array of { id, title }. Zero or one tab renders as a plain group header; two or more render as a tab bar. Tile fields (on the same tile shape used in tiles[]): - containerId : optional. Must match an id in containers[]. - tabId : optional. Must match a tab id on that container. Requires - containerId. A tile with containerId set but no tabId - renders in the container shell (above any tab bar). + containerId Optional. Must match an id in containers[]. + tabId Optional. Must match a tab id on that container. Requires containerId. A tile with containerId set but no tabId renders in the container shell (above any tab bar). Rules enforced by the API: - Container ids must be unique on a dashboard. @@ -1077,7 +937,7 @@ Example: title: "Service Health", collapsed: false, tabs: [ - { id: "errors", title: "Errors" }, + { id: "errors", title: "Errors" }, { id: "latency", title: "Latency" } ] }, @@ -1093,7 +953,7 @@ Example: ] }) -Validation categories on bad inputs: +Validation errors on bad inputs: - tile.containerId references a missing container id - tile.tabId references a missing tab id on that container - tile.tabId set without tile.containerId @@ -1144,39 +1004,49 @@ Example: find top patterns for production services over the last 4 hours: Wrong: { aggFn: "avg" } Correct: { aggFn: "avg", valueExpression: "Duration" } -3. Using dot notation for Map-type attributes in valueExpression/groupBy +3. Using dot notation for Map-type attributes in valueExpression or groupBy Wrong: groupBy: "SpanAttributes.http.method" Correct: groupBy: "SpanAttributes['http.method']" NOTE: JSON-type columns DO use dot notation. Check jsType from hyperdx_list_sources. -4. Multiple select items on number/pie tiles (dashboard tiles) +4. Multiple select items on number / pie / heatmap / metric tiles Wrong: displayType: "number", select: [{ aggFn: "count" }, { aggFn: "avg", ... }] Correct: displayType: "number", select: [{ aggFn: "count" }] - Note: hyperdx_table auto-upgrades shape:"number" to "table" when select has >1 item. + Same constraint applies to metric source tiles: one tile per metric. + Note: hyperdx_table (the query tool) auto-upgrades shape:"number" to "table" when select has >1 item; dashboard tiles do not. 5. Missing level for quantile Wrong: { aggFn: "quantile", valueExpression: "Duration" } Correct: { aggFn: "quantile", valueExpression: "Duration", level: 0.95 } -6. Forgetting to validate tiles after saving +6. Chart-level numberFormat on a table that mixes counts and durations + Wrong: table with select: [count, count, quantile(Duration)] and chart-level numberFormat: { output: "duration", ... } + Correct: put numberFormat on the quantile(Duration) select item only. + The chart-level form applies to every value, so count columns render as "0:00:00" / "0s". + +7. Hardcoding a focus dimension into every tile's where clause + Wrong: five tiles, each with where: "ServiceName:checkout" + Correct: filters: [{ type: "QUERY_EXPRESSION", name: "Service", expression: "ServiceName", sourceId: "" }] + The dashboard filter applies globally; tiles do not need the literal. + +8. Forgetting to validate tiles after saving Always call hyperdx_query_tile after hyperdx_save_dashboard to verify each tile returns data. -7. Using sourceId with SQL tiles or connectionId with builder tiles +9. Using sourceId with SQL tiles or connectionId with builder tiles Builder tiles (line, table, etc.) use sourceId. SQL tiles (configType: "sql") use connectionId. -8. Assuming StatusCode or SeverityText values - Values like STATUS_CODE_ERROR, Ok, error, fatal vary by deployment. - Always call hyperdx_list_sources and inspect real keyValues from the source - before writing filters that depend on these columns. +10. Assuming StatusCode or SeverityText values + Values like STATUS_CODE_ERROR, Ok, error, fatal vary by deployment. + Always call hyperdx_list_sources and inspect real keyValues from the source + before writing filters that depend on these columns. -9. Heatmap tile on a non-Trace source - Wrong: { displayType: "heatmap", sourceId: "", select: [...] } - Correct: { displayType: "heatmap", sourceId: "", select: [...] } - Heatmap is currently restricted to Trace sources. Pick a source whose kind - is "trace" from hyperdx_list_sources. +11. Heatmap tile on a non-Trace source + Wrong: { displayType: "heatmap", sourceId: "", select: [...] } + Correct: { displayType: "heatmap", sourceId: "", select: [...] } + Heatmap is currently restricted to Trace sources. Pick a source whose kind is "trace" from hyperdx_list_sources. -10. Heatmap tile with empty or missing valueExpression +12. Heatmap tile with empty or missing valueExpression Wrong: select: [{}] Wrong: select: [{ valueExpression: "" }] Correct: select: [{ valueExpression: "Duration" }] diff --git a/packages/api/src/mcp/prompts/dashboards/index.ts b/packages/api/src/mcp/prompts/dashboards/index.ts index a76fddedcd..562a93aff3 100644 --- a/packages/api/src/mcp/prompts/dashboards/index.ts +++ b/packages/api/src/mcp/prompts/dashboards/index.ts @@ -28,7 +28,7 @@ const dashboardPrompts: PromptDefinition = (server, context) => { description: 'Create a HyperDX dashboard with the MCP tools. ' + 'Follow the recommended workflow, pick tile types, write queries, ' + - 'and validate results — using your real data sources.', + 'and validate results against your real data sources.', argsSchema: { description: z .string() @@ -103,14 +103,16 @@ const dashboardPrompts: PromptDefinition = (server, context) => { { title: 'Dashboard Examples', description: - 'Get copy-paste-ready dashboard examples for common observability patterns: ' + - 'service_overview, error_tracking, latency, log_analysis, drilldown_links, infrastructure_sql.', + 'Concrete dashboard example shapes for common observability patterns: ' + + 'service_inventory, service_detail, log_analytics, backend_dependencies, infrastructure_sql. ' + + 'Each example carries a "when to use" header. Adapt the structure to the user\'s request; ' + + 'do not copy literal column names or status values without verifying via hyperdx_list_sources first.', argsSchema: { pattern: z .string() .optional() .describe( - 'Filter to a specific pattern: service_overview, error_tracking, latency, log_analysis, drilldown_links, infrastructure_sql', + 'Filter to a specific pattern: service_inventory, service_detail, log_analytics, backend_dependencies, infrastructure_sql', ), }, }, diff --git a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts index 4784be3b85..44487d752c 100644 --- a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts @@ -25,6 +25,7 @@ import { } from '@/routers/external-api/v2/utils/dashboards'; import type { ExternalDashboardFilter, + ExternalDashboardFilterWithId, ExternalDashboardTileWithId, } from '@/utils/zod'; @@ -72,7 +73,7 @@ export function registerSaveDashboard( tiles: inputTiles, tags, containers, - filters, + filters: inputFilters, }) => { if (!dashboardId) { return createDashboard({ @@ -82,7 +83,7 @@ export function registerSaveDashboard( inputTiles, tags, containers, - inputFilters: filters, + inputFilters, }); } return updateDashboard({ @@ -93,7 +94,7 @@ export function registerSaveDashboard( inputTiles, tags, containers, - inputFilters: filters, + inputFilters, }); }, ), @@ -117,7 +118,9 @@ async function createDashboard({ inputTiles: unknown[]; tags: string[] | undefined; containers: DashboardContainer[] | undefined; - inputFilters: ExternalDashboardFilter[] | undefined; + inputFilters: + | (ExternalDashboardFilter | ExternalDashboardFilterWithId)[] + | undefined; }) { const parsed = createDashboardBodySchema.safeParse({ name, @@ -301,7 +304,9 @@ async function updateDashboard({ inputTiles: unknown[]; tags: string[] | undefined; containers: DashboardContainer[] | undefined; - inputFilters: ExternalDashboardFilter[] | undefined; + inputFilters: + | (ExternalDashboardFilter | ExternalDashboardFilterWithId)[] + | undefined; }) { if (!mongoose.Types.ObjectId.isValid(dashboardId)) { return { diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 974a676726..fb5896cc02 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -13,7 +13,10 @@ import { } from '@hyperdx/common-utils/dist/types'; import { z } from 'zod'; -import { externalQuantileLevelSchema, objectIdSchema } from '@/utils/zod'; +import { + externalDashboardFilterSchemaWithId, + externalQuantileLevelSchema, +} from '@/utils/zod'; // ─── Shared tile schemas for MCP dashboard tools ───────────────────────────── const mcpNumberFormatSchema = z @@ -630,83 +633,32 @@ export const mcpTilesParam = z '"numberFormat": { "output": "duration", "factor": 0.000000001 } } }', ); -const mcpDashboardFilterSchema = z - .object({ - id: z - .string() - .optional() - .describe( - 'Filter identity. ' + - 'On UPDATE of an existing dashboard, every filter in the array MUST carry ' + - 'an id: pass the exact id returned by hyperdx_get_dashboard for any filter ' + - 'you are keeping (so saved values bound to it stay attached), and generate ' + - 'a fresh random hex/ObjectId string for any filter you are adding in this ' + - 'update. Omitting `id` on an existing filter would orphan its saved values; ' + - 'reusing an existing id for a new filter would silently overwrite the old ' + - 'one. On CREATE (no top-level `id` on the dashboard call), filter `id` may ' + - 'be omitted and one will be generated server-side.', - ), - type: DashboardFilterType.describe( - 'Filter type. Currently only "QUERY_EXPRESSION" is supported.', - ), - name: z - .string() - .min(1) - .describe( - 'Human-readable filter label shown in the dashboard filter bar dropdown.', - ), - expression: z - .string() - .min(1) - .describe( - 'Column or SQL expression this filter binds to. Example: "ServiceName" ' + - 'or "SpanAttributes[\'http.method\']". ' + - 'IMPORTANT: This is the key that table-tile onClick filters match against ' + - 'when a row click navigates here — an onClick filter whose `expression` is ' + - "not declared in any of this dashboard's filters is silently dropped at click time. " + - 'Declare an expression here for every column you plan to drive via row-click.', - ), - sourceId: objectIdSchema.describe( - 'Source the filter values are pulled from (for the dropdown). ' + - 'Get IDs from hyperdx_list_sources.', - ), - sourceMetricType: z - .nativeEnum(MetricsDataType) - .optional() - .describe( - 'Required only when `sourceId` is a Metric source — picks which metric table the ' + - 'dropdown values come from.', - ), - where: z - .string() - .optional() - .describe( - 'Optional WHERE clause scoping the dropdown values (e.g. "level:error" in Lucene).', - ), - whereLanguage: SearchConditionLanguageSchema.describe( - 'Filter language for `where` ("lucene" or "sql"). Optional, but set it explicitly.', - ), - }) - .describe( - 'A dashboard-level filter the user can adjust in the dashboard filter bar. ' + - 'Each filter binds a label/name to a column expression on a source. ' + - "Filters are also the contract for row-click navigation: a table tile's " + - 'onClick.filters[i].expression must match a filter declared here for the value to land.', - ); - +// Dashboard-level filters. Reuses the canonical external API filter schema +// (`externalDashboardFilterSchemaWithId` in `packages/api/src/utils/zod.ts`) +// so the MCP and REST surfaces stay in lockstep, and so the underlying +// `convertExternalFiltersToInternal` helper that saveDashboard already calls +// works without translation. `id` is optional at this layer because the same +// inputSchema serves both create (no id, generated on save) and update +// (preserved id) flows; per-flow strictness is enforced by +// `createDashboardBodySchema` / `updateDashboardBodySchema`. export const mcpFiltersParam = z - .array(mcpDashboardFilterSchema) + .array(externalDashboardFilterSchemaWithId.partial({ id: true })) .describe( - 'Optional dashboard-level filters. These define the dropdowns in the dashboard filter ' + - 'bar AND the expressions that table-tile row-click navigation can populate. ' + - 'If another tile\'s onClick targets THIS dashboard with `filters: [{ expression: "X", ... }]`, ' + - 'this array MUST declare a filter whose `expression` is "X" — otherwise the value is ' + - 'dropped on arrival and the destination opens unfiltered.\n\n' + + 'Dashboard-level filters. Each filter declares a dropdown in the dashboard ' + + 'header that scopes every tile referencing the same source. Use this for ' + + 'focused per-dimension dashboards (per-service, per-tenant, per-endpoint) ' + + "instead of hardcoding the dimension into every tile's where clause.\n\n" + + 'Filter shape: { type, name, expression, sourceId, where?, whereLanguage? }.\n' + + '- type: "QUERY_EXPRESSION" (the only currently supported type).\n' + + '- name: human label shown in the filter dropdown (e.g. "Service").\n' + + '- expression: column or attribute path the filter scopes (e.g. "ServiceName" ' + + 'or "SpanAttributes[\'tenant.id\']").\n' + + '- sourceId: which source the expression resolves against. Tiles on a ' + + 'different source are not scoped by the filter.\n' + + '- where / whereLanguage: optional pre-filter that narrows the set of ' + + 'distinct values offered in the dropdown.\n\n' + 'Example:\n' + - '[\n' + - ' { "type": "QUERY_EXPRESSION", "name": "Service", "expression": "ServiceName",\n' + - ' "sourceId": "", "whereLanguage": "sql" }\n' + - ']', + '[ { "type": "QUERY_EXPRESSION", "name": "Service", "expression": "ServiceName", "sourceId": "" } ]', ); export const mcpContainersParam = z From 792c6f5c6a16bd169c387be9e698cff6f8c4657c Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 12 May 2026 22:37:51 +0000 Subject: [PATCH 02/12] fix(mcp): drop em-dashes from source-summary lines in buildSourceSummary The buildSourceSummary helper is the source-list block that buildCreateDashboardPrompt prepends to the prompt body. It still carried em-dashes from before the voice pass, so the assembled `create_dashboard` prompt that the MCP transport returns to a client contained eight em-dashes even though every builder in content.ts is clean. Caught by an end-to-end check that fetched the prompt through the live MCP transport and counted em-dashes in the response payload. --- packages/api/src/mcp/prompts/dashboards/helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/src/mcp/prompts/dashboards/helpers.ts b/packages/api/src/mcp/prompts/dashboards/helpers.ts index 53913fe59a..364f2a9407 100644 --- a/packages/api/src/mcp/prompts/dashboards/helpers.ts +++ b/packages/api/src/mcp/prompts/dashboards/helpers.ts @@ -14,7 +14,7 @@ export function buildSourceSummary( lines.push('AVAILABLE SOURCES (use sourceId with builder tiles):'); for (const s of sources) { lines.push( - ` - "${s.name}" (${s.kind}) — sourceId: "${s._id}", connectionId: "${s.connection}"`, + ` - "${s.name}" (${s.kind}): sourceId "${s._id}", connectionId "${s.connection}"`, ); } } @@ -25,7 +25,7 @@ export function buildSourceSummary( 'AVAILABLE CONNECTIONS (use connectionId with raw SQL tiles only):', ); for (const c of connections) { - lines.push(` - "${c.name}" — connectionId: "${c._id}"`); + lines.push(` - "${c.name}": connectionId "${c._id}"`); } } From ab68f577489aa994db13f1a2c22fcfd0a72c0ca8 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 12 May 2026 23:32:33 +0000 Subject: [PATCH 03/12] fix(mcp): address claude-review + deep-review findings on dashboard prompts claude-review flagged: - `service_detail` example uses `having: "StatusMessage != ''"` on a table tile, but mcpTableTileSchema.config does not include `having`. Zod's `.strip()` silently drops the field, so an LLM following the example through MCP would save a table that includes empty-message rows. Added `having: z.string().max(10000).optional()` mirroring externalDashboardTableChartConfigSchema and a round-trip test. - Heatmap tile's numberFormat describe still referenced `output: "time"` while the rest of the file uses `output: "duration"`. Aligned to "duration" for consistency with the prompt guidance. deep-review additionally flagged: - query_guide told the model that metric tiles take "1 select item with metricName + metricType", but mcpTileSelectItemSchema does not expose those fields, so the keys are silently stripped. Replaced the metric authoring guidance with an explicit "not currently exposed via the MCP schema; use raw SQL for infrastructure metrics" note. Same edit on the design-checklist rule 9 (replaced with a "replace, not merge" update-semantic note that addresses a separate review concern about partial-payload data loss on update). - mcpFiltersParam advertised id as optional via .partial({ id: true }), but createDashboardBodySchema rejects id (strict, no id) and updateDashboardBodySchema requires id. So an LLM copy-pasting a filter from get-dashboard into create, or adding a brand-new filter on update without an id, would hit a confusing strict-validation rejection. saveDashboard.ts now normalizes per flow: stripFilterIds() on create, assignFilterIds() on update. - Em-dash regression check did not cover buildQueryGuidePrompt (largest prompt, most likely to regress) or buildSourceSummary (helpers.ts carried em-dashes through the initial PR until fix `3539649e`). Added assertions for both. - Bad-sourceId test asserted only `text.toContain('source')`, which matches almost any error string. Tightened to assert "Could not find source IDs" plus the literal bad id. - Numbered-rule check used `prompt.toContain('${i}.')` which would match substrings like "1.2s" or "0.000000001". Anchored the check to line start (`/^${i}\\. /m`) and scoped it to the DESIGN CHECKLIST section. - DASHBOARD FILTERS and NUMBER FORMAT body content was not asserted beyond the heading. Now asserts substantive content (QUERY_EXPRESSION / expression / sourceId for filters; factor: 0.000000001 / duration / per-series for number format). - Filter round-trip did not cover the freshly-generated-id branch of convertExternalFiltersToInternal at the MCP layer. Added a test that ships a new no-id filter on update and asserts saveDashboard assigns a fresh id. --- .../api/src/mcp/__tests__/dashboards.test.ts | 236 ++++++++++++++++-- .../api/src/mcp/prompts/dashboards/content.ts | 9 +- .../src/mcp/tools/dashboards/saveDashboard.ts | 40 ++- .../api/src/mcp/tools/dashboards/schemas.ts | 12 +- 4 files changed, 271 insertions(+), 26 deletions(-) diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index c9bdb8fb65..5af5571eb8 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -20,6 +20,7 @@ import { buildDashboardExamplesPrompt, buildQueryGuidePrompt, } from '../prompts/dashboards/content'; +import { buildSourceSummary } from '../prompts/dashboards/helpers'; import { McpContext } from '../tools/types'; import { callTool, createTestClient, getFirstText } from './mcpTestUtils'; @@ -1323,6 +1324,7 @@ describe('MCP Dashboard Tools', () => { it('should reject a filter whose sourceId does not exist', async () => { const sourceId = traceSource._id.toString(); + const missingSourceId = '000000000000000000000000'; const result = await callTool(client, 'hyperdx_save_dashboard', { name: 'Bad filter source', tiles: [traceTile(sourceId)], @@ -1331,12 +1333,18 @@ describe('MCP Dashboard Tools', () => { type: 'QUERY_EXPRESSION', name: 'Service', expression: 'ServiceName', - sourceId: '000000000000000000000000', + sourceId: missingSourceId, }, ], }); expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('source'); + const text = getFirstText(result); + // Tight assertion: the saveDashboard "missing source IDs" envelope + // includes the literal "Could not find source IDs:" plus the + // unknown id, so a regression that swaps in a different error path + // (e.g. a generic Zod validation reject) would fail loudly. + expect(text).toContain('Could not find source IDs'); + expect(text).toContain(missingSourceId); }); it('should round-trip a dashboard with no filters (backward compat)', async () => { @@ -1350,6 +1358,130 @@ describe('MCP Dashboard Tools', () => { expect(Array.isArray(dashboard.filters)).toBe(true); expect(dashboard.filters).toHaveLength(0); }); + + it('should accept a create payload with stray filter ids (copy-paste round-trip)', async () => { + // An LLM that copies a filter out of hyperdx_get_dashboard and + // back into hyperdx_save_dashboard for a NEW dashboard ships an + // `id` on the filter. The body schema for create is + // `.strict()` and rejects `id`, so saveDashboard normalizes by + // stripping ids before validation. Without that normalization + // this payload would 4xx with a confusing strict-validation + // error. + const sourceId = traceSource._id.toString(); + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'Create with stray filter id', + tiles: [traceTile(sourceId)], + filters: [ + { + id: '999999999999999999999999', // simulates a copied id + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + sourceId, + }, + ], + }); + expect(result.isError).toBeFalsy(); + const dashboard = JSON.parse(getFirstText(result)); + expect(dashboard.filters).toHaveLength(1); + // The new dashboard gets a fresh id; the copied one is discarded. + expect(dashboard.filters[0].id).not.toBe('999999999999999999999999'); + expect(dashboard.filters[0]).toMatchObject({ + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + sourceId, + }); + }); + + it('should assign ids to new filters added during update (no-id round-trip)', async () => { + // An LLM updating a dashboard to ADD a brand-new filter ships + // a filter without an `id`. The body schema for update requires + // id, so saveDashboard fills one in. Without that normalization + // this payload would 4xx complaining that id is missing. + const sourceId = traceSource._id.toString(); + const createResult = await callTool(client, 'hyperdx_save_dashboard', { + name: 'Update adds new filter', + tiles: [traceTile(sourceId)], + filters: [ + { + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + sourceId, + }, + ], + }); + const created = JSON.parse(getFirstText(createResult)); + const existingFilterId = created.filters[0].id; + + const updateResult = await callTool(client, 'hyperdx_save_dashboard', { + id: created.id, + name: 'Update adds new filter', + tiles: [traceTile(sourceId)], + filters: [ + // Existing filter, preserved by id. + { + id: existingFilterId, + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + sourceId, + }, + // Brand-new filter, no id. saveDashboard assigns one. + { + type: 'QUERY_EXPRESSION', + name: 'Environment', + expression: 'deployment.environment', + sourceId, + }, + ], + }); + expect(updateResult.isError).toBeFalsy(); + const updated = JSON.parse(getFirstText(updateResult)); + expect(updated.filters).toHaveLength(2); + const envFilter = updated.filters.find( + (f: { name: string }) => f.name === 'Environment', + ); + expect(envFilter).toBeDefined(); + expect(typeof envFilter.id).toBe('string'); + expect(envFilter.id.length).toBeGreaterThan(0); + expect(envFilter.id).not.toBe(existingFilterId); + }); + + it('should round-trip a table tile that uses a having clause', async () => { + // mcpTableTileSchema exposes `having` so the service_detail + // example's "Top Error Messages" pattern (groupBy StatusMessage + // with having: "StatusMessage != ''") survives MCP authoring. + // Without `having` on the schema, Zod would strip it and the + // saved tile would include empty-message rows instead of the + // filtered set the prompt promises. + const sourceId = traceSource._id.toString(); + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'Top Error Messages', + tiles: [ + { + name: 'Top Error Messages', + x: 0, + y: 0, + w: 12, + h: 6, + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count', alias: 'Count' }], + groupBy: 'StatusMessage', + having: "StatusMessage != ''", + orderBy: 'Count DESC', + groupByColumnsOnLeft: true, + }, + }, + ], + }); + expect(result.isError).toBeFalsy(); + const dashboard = JSON.parse(getFirstText(result)); + expect(dashboard.tiles[0].config.having).toBe("StatusMessage != ''"); + }); }); describe('hyperdx_delete_dashboard', () => { @@ -1503,15 +1635,75 @@ describe('MCP Dashboard Tools', () => { it('documents the dashboard filter and per-series numberFormat sections', () => { const prompt = buildQueryGuidePrompt(); - expect(prompt).toContain('== DASHBOARD FILTERS =='); - expect(prompt).toContain('== NUMBER FORMAT =='); - // The metric one-select rule is the constraint Step 1a caught - // during dev-stack verification; without it the AI authors - // multi-metric tiles that silently drop everything past select[0]. + // Sections must exist AND carry the substantive content. Heading- + // only assertions let a future contributor empty out a section + // and silently pass review. + const sliceSection = (heading: string) => { + const idx = prompt.indexOf(heading); + if (idx === -1) return ''; + const next = prompt.indexOf('\n== ', idx + heading.length); + return prompt.slice(idx, next === -1 ? prompt.length : next); + }; + const filters = sliceSection('== DASHBOARD FILTERS =='); + expect(filters).toContain('QUERY_EXPRESSION'); + expect(filters).toContain('expression'); + expect(filters).toContain('sourceId'); + const numberFormat = sliceSection('== NUMBER FORMAT =='); + expect(numberFormat).toContain('factor: 0.000000001'); + expect(numberFormat).toContain('duration'); + expect(numberFormat.toLowerCase()).toContain('per-series'); + }); + + it('declares the MCP metric-authoring gap rather than referencing fields that do not exist', () => { + // The MCP select-item schema does not carry metricName / metricType, + // so any prompt that tells the model to author a metric tile via + // the builder path is teaching it to ship JSON that gets silently + // stripped. Guard with an explicit assertion so a future diff that + // restores metric-tile guidance fails this test loudly. + const prompt = buildQueryGuidePrompt(); + expect(prompt).not.toMatch(/metricName \+ metricType/); + expect(prompt).not.toMatch(/exactly 1 select item with metricName/); const constraintsIdx = prompt.indexOf('== PER-TILE TYPE CONSTRAINTS =='); const constraintsBody = prompt.slice(constraintsIdx); - expect(constraintsBody.toLowerCase()).toContain('metric'); - expect(constraintsBody).toContain('one tile per metric'); + expect(constraintsBody).toMatch(/not currently exposed via the MCP/); + }); + + it('contains no em-dashes or en-dashes used as em-dashes', () => { + const prompt = buildQueryGuidePrompt(); + expect(prompt).not.toMatch(/—/); // [prose-lint: allow] + expect(prompt).not.toMatch(/ – /); // [prose-lint: allow] + }); + }); + + describe('buildSourceSummary', () => { + it('contains no em-dashes or en-dashes used as em-dashes', () => { + // buildSourceSummary is the source-list block that gets prepended + // to create_dashboard. The first iteration of this PR shipped with + // em-dashes here that the content.ts snapshot test missed because + // it calls the builders directly. Guard the helper output too. + const summary = buildSourceSummary( + [ + { + _id: '000000000000000000000001', + name: 'Traces', + kind: 'trace', + connection: '000000000000000000000002', + }, + { + _id: '000000000000000000000003', + name: 'Logs', + kind: 'log', + connection: '000000000000000000000002', + }, + ], + [{ _id: '000000000000000000000002', name: 'Default' }], + ); + expect(summary).not.toMatch(/—/); // [prose-lint: allow] + expect(summary).not.toMatch(/ – /); // [prose-lint: allow] + // Sanity: the helper still emits the source list, so the assertion + // above isn't trivially passing on an empty string. + expect(summary).toContain('Traces'); + expect(summary).toContain('AVAILABLE SOURCES'); }); }); @@ -1523,23 +1715,31 @@ describe('MCP Dashboard Tools', () => { '000000000000000000000002', ); expect(prompt).toContain('== DESIGN CHECKLIST =='); - // Each rule on the checklist exists at the same heading depth so a - // future contributor cannot silently drop one. Numbered list - // matters because the prompt references rules by position when the - // model needs a reminder mid-task. + // Each rule on the checklist exists at the same numbered position + // so a future contributor cannot silently drop one. Anchor on + // line start (with a trailing space) so digit-and-dot substrings + // like "0.000000001" elsewhere in the prompt cannot satisfy the + // assertion accidentally. + const checklistIdx = prompt.indexOf('== DESIGN CHECKLIST =='); + const adaptIdx = prompt.indexOf('== ADAPT, DO NOT COPY =='); + const checklistBody = prompt.slice(checklistIdx, adaptIdx); for (let i = 1; i <= 10; i++) { - expect(prompt).toContain(`${i}.`); + expect(checklistBody).toMatch(new RegExp(`^${i}\\. `, 'm')); } expect(prompt).toContain('ADAPT, DO NOT COPY'); + // Rule 9 documents the replace-not-merge semantic on update; an + // agent that ignores this can silently drop tiles / filters / containers + // on a subsequent save call. + expect(checklistBody).toMatch(/UPDATE IS REPLACE, NOT MERGE/); }); it('contains no em-dashes or en-dashes used as em-dashes', () => { const prompt = buildCreateDashboardPrompt('sources summary', '', ''); - expect(prompt).not.toMatch(/—/); + expect(prompt).not.toMatch(/—/); // [prose-lint: allow] // En-dash flanked by spaces reads as an em-dash substitute and is - // disallowed by the voice rules. Numeric ranges (e.g. "1–20") are + // disallowed by the voice rules. Numeric ranges (e.g. "1-20") are // not in the checklist anyway, but guard against them just in case. - expect(prompt).not.toMatch(/ – /); + expect(prompt).not.toMatch(/ – /); // [prose-lint: allow] }); }); @@ -1629,7 +1829,7 @@ describe('MCP Dashboard Tools', () => { '000000000000000000000002', '000000000000000000000003', ); - expect(all).not.toMatch(/—/); + expect(all).not.toMatch(/—/); // [prose-lint: allow] }); }); }); diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index 841f9d6f3e..d9f4a0895d 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -96,7 +96,7 @@ Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the 8. FOR FOCUSED PER-DIMENSION DASHBOARDS, DECLARE A DASHBOARD-LEVEL FILTER. Pass filters: [{ type: "QUERY_EXPRESSION", name, expression, sourceId }] at the top level. The user gets a dropdown in the dashboard header; every tile on the same source re-scopes when a value is picked. Do NOT hardcode the dimension into each tile's where clause. -9. METRIC TILES TAKE EXACTLY ONE SELECT ITEM. Each metric is a separate query. To show multiple metrics side by side, create one tile per metric. +9. UPDATE IS REPLACE, NOT MERGE. hyperdx_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call hyperdx_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted. 10. GROUP RELATED TILES INTO CONTAINERS. A container holds tiles that share a theme (Overview / Performance / Errors). Set containers: [{ id, title, collapsed }] at the top level and reference container ids from each tile via containerId. Use tabs when the same container needs to show different views of the same data; the tab bar appears when a container has two or more tabs. @@ -112,7 +112,7 @@ The dashboard_examples prompt returns concrete example shapes. Read them as patt - Skipping hyperdx_list_sources (you need real source IDs and real column names). - Skipping hyperdx_query_tile after save (tiles can silently fail on syntax or attribute mismatches). - Setting chart-level numberFormat on a table that mixes counts and durations (counts render as 0:00:00). -- Multiple select items on number / pie / heatmap / metric tiles (each takes exactly one). +- Multiple select items on number / pie / heatmap tiles (each takes exactly one). - Missing level on aggFn "quantile" (must specify 0.5, 0.9, 0.95, or 0.99). - Assuming StatusCode or SeverityText values (always inspect real values from hyperdx_list_sources). - Heatmap on a non-Trace source (heatmap is Trace-only today). @@ -853,7 +853,7 @@ For configType: "sql" tiles, write ClickHouse SQL with template macros: search No select items (select is a column list string). where is the filter. markdown No select items. Set markdown field with content. - METRIC TILES (any of the above with metric source): exactly 1 select item with metricName + metricType (gauge / sum / histogram). To show multiple metrics side by side, create one tile per metric. +NOTE: Authoring tiles on a metric source is not currently exposed via the MCP schema. The MCP select-item shape does not carry the metricName / metricType fields the metric query path needs. If you need infrastructure metrics, use a raw SQL tile (configType: "sql") instead. == NUMBER FORMAT == @@ -1009,10 +1009,9 @@ Example: find top patterns for production services over the last 4 hours: Correct: groupBy: "SpanAttributes['http.method']" NOTE: JSON-type columns DO use dot notation. Check jsType from hyperdx_list_sources. -4. Multiple select items on number / pie / heatmap / metric tiles +4. Multiple select items on number / pie / heatmap tiles Wrong: displayType: "number", select: [{ aggFn: "count" }, { aggFn: "avg", ... }] Correct: displayType: "number", select: [{ aggFn: "count" }] - Same constraint applies to metric source tiles: one tile per metric. Note: hyperdx_table (the query tool) auto-upgrades shape:"number" to "table" when select has >1 item; dashboard tiles do not. 5. Missing level for quantile diff --git a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts index 44487d752c..d6bee8eb5c 100644 --- a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts @@ -103,6 +103,42 @@ export function registerSaveDashboard( // ─── Create helper ──────────────────────────────────────────────────────────── +// The MCP input schema marks filter `id` as optional so the same shape +// serves both create (no id, generated on save) and update (preserved +// id) flows. The underlying body schemas are stricter: create uses +// `externalDashboardFilterSchema` which rejects any `id` field, update +// uses `externalDashboardFilterSchemaWithId` which requires it. Normalize +// the input here so an LLM can copy a filter from the get-dashboard +// response into a create payload (or omit the id on a new filter added +// during update) without hitting a confusing strict-validation rejection. +function stripFilterIds( + filters: + | (ExternalDashboardFilter | ExternalDashboardFilterWithId)[] + | undefined, +): ExternalDashboardFilter[] | undefined { + if (!filters) return undefined; + return filters.map(filter => { + const { id: _id, ...rest } = filter as ExternalDashboardFilterWithId; + return rest as ExternalDashboardFilter; + }); +} + +function assignFilterIds( + filters: + | (ExternalDashboardFilter | ExternalDashboardFilterWithId)[] + | undefined, +): ExternalDashboardFilterWithId[] | undefined { + if (!filters) return undefined; + return filters.map(filter => { + const withId = filter as ExternalDashboardFilterWithId; + if (typeof withId.id === 'string' && withId.id.length > 0) return withId; + return { + ...filter, + id: new mongoose.Types.ObjectId().toString(), + } as ExternalDashboardFilterWithId; + }); +} + async function createDashboard({ teamId, frontendUrl, @@ -127,7 +163,7 @@ async function createDashboard({ tiles: inputTiles, tags, containers, - filters: inputFilters, + filters: stripFilterIds(inputFilters), }); if (!parsed.success) { return { @@ -320,7 +356,7 @@ async function updateDashboard({ tiles: inputTiles, tags, containers, - filters: inputFilters, + filters: assignFilterIds(inputFilters), }); if (!parsed.success) { return { diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index fb5896cc02..b195ced78f 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -429,6 +429,16 @@ const mcpTableTileSchema = mcpTileLayoutSchema.extend({ 'Group rows by this column. Use PascalCase for top-level columns (e.g. "SpanName"). ' + "For attributes: SpanAttributes['key'] or ResourceAttributes['key'].", ), + having: z + .string() + .max(10000) + .optional() + .describe( + 'Post-aggregation SQL HAVING expression. Example: "Count > 100" to drop ' + + 'groups with few rows, or "StatusMessage != \'\'" to drop empty-message rows ' + + 'from a groupBy: "StatusMessage" table. Mirrors the same field on the REST ' + + 'table chart config in `externalDashboardTableChartConfigSchema`.', + ), orderBy: z.string().optional().describe('Sort results by this column'), asRatio: z.boolean().optional(), groupByColumnsOnLeft: z @@ -531,7 +541,7 @@ const mcpHeatmapTileSchema = mcpTileLayoutSchema.extend({ numberFormat: mcpNumberFormatSchema .optional() .describe( - 'Display formatting for bucket values. Example: { output: "time", factor: 0.000000001 } ' + + 'Display formatting for bucket values. Example: { output: "duration", factor: 0.000000001 } ' + 'to format nanosecond durations as human-readable time.', ), }), From 1e2ed22473e0132379c73ce8a0bba149a6fca6de Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Wed, 13 May 2026 17:26:12 -0400 Subject: [PATCH 04/12] feat: Add dashboard table onClick to MCP schemas and prompts --- .../api/src/mcp/__tests__/dashboards.test.ts | 512 +++++++++++++++++- .../api/src/mcp/prompts/dashboards/content.ts | 101 +++- .../api/src/mcp/prompts/dashboards/index.ts | 4 +- .../api/src/mcp/tools/dashboards/schemas.ts | 29 +- 4 files changed, 601 insertions(+), 45 deletions(-) diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index 5af5571eb8..7db58d424b 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -1,6 +1,5 @@ import { MetricsDataType, SourceKind } from '@hyperdx/common-utils/dist/types'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { ObjectId } from 'mongodb'; import mongoose from 'mongoose'; import * as config from '@/config'; @@ -1484,6 +1483,433 @@ describe('MCP Dashboard Tools', () => { }); }); + describe('hyperdx_save_dashboard - table onClick linking', () => { + // Mirrors the v2 external-API onClick tests so the MCP path + // enforces the same drill-down rules: row-click can target /search + // for a log/trace source or another dashboard, by concrete ID or + // by templated name, with optional whereTemplate/filters. + it('should round-trip a table tile with a search onClick by ID', async () => { + const sourceId = traceSource._id.toString(); + const createResult = await callTool(client, 'hyperdx_save_dashboard', { + name: 'OnClick search by id', + tiles: [ + { + name: 'Errors by Service', + x: 0, + y: 0, + w: 12, + h: 6, + config: { + displayType: 'table', + sourceId, + groupBy: "ResourceAttributes['service.name']", + select: [{ aggFn: 'count' }], + onClick: { + type: 'search', + target: { mode: 'id', id: sourceId }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: "{{ResourceAttributes['service.name']}}", + }, + ], + }, + }, + }, + ], + }); + + expect(createResult.isError).toBeFalsy(); + const created = JSON.parse(getFirstText(createResult)); + expect(created.tiles[0].config.onClick).toEqual({ + type: 'search', + target: { mode: 'id', id: sourceId }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: "{{ResourceAttributes['service.name']}}", + }, + ], + }); + + const getResult = await callTool(client, 'hyperdx_get_dashboard', { + id: created.id, + }); + const fetched = JSON.parse(getFirstText(getResult)); + expect(fetched.tiles[0].config.onClick).toEqual( + created.tiles[0].config.onClick, + ); + }); + + it('should round-trip a table tile with a dashboard onClick by ID', async () => { + const sourceId = traceSource._id.toString(); + // Create the target dashboard the onClick will link to so the + // server-side `getMissingOnClickDashboards` check resolves. + const targetDashboard = await new Dashboard({ + name: 'Service Detail', + tiles: [], + team: team._id, + }).save(); + + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'OnClick dashboard by id', + tiles: [ + { + name: 'Top Services', + x: 0, + y: 0, + w: 12, + h: 6, + config: { + displayType: 'table', + sourceId, + groupBy: "ResourceAttributes['service.name']", + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { + mode: 'id', + id: targetDashboard._id.toString(), + }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: "{{ResourceAttributes['service.name']}}", + }, + ], + }, + }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles[0].config.onClick).toEqual({ + type: 'dashboard', + target: { mode: 'id', id: targetDashboard._id.toString() }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: "{{ResourceAttributes['service.name']}}", + }, + ], + }); + }); + + it('should round-trip a templated dashboard onClick (mode=template)', async () => { + const sourceId = traceSource._id.toString(); + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'OnClick dashboard by template', + tiles: [ + { + name: 'Service Picker', + x: 0, + y: 0, + w: 12, + h: 6, + config: { + displayType: 'table', + sourceId, + groupBy: 'SpanName', + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { + mode: 'template', + template: '{{TargetDashboard}}', + }, + whereLanguage: 'lucene', + whereTemplate: + "service.name:{{ResourceAttributes['service.name']}}", + }, + }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles[0].config.onClick).toEqual({ + type: 'dashboard', + target: { mode: 'template', template: '{{TargetDashboard}}' }, + whereLanguage: 'lucene', + whereTemplate: "service.name:{{ResourceAttributes['service.name']}}", + }); + }); + + it('should round-trip onClick on a raw SQL table tile', async () => { + const connectionId = connection._id.toString(); + const sourceId = traceSource._id.toString(); + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'SQL onClick', + tiles: [ + { + name: 'Raw SQL Table', + x: 0, + y: 0, + w: 12, + h: 6, + config: { + configType: 'sql', + displayType: 'table', + connectionId, + sqlTemplate: + 'SELECT ServiceName, count() AS c FROM otel_traces GROUP BY ServiceName LIMIT 50', + onClick: { + type: 'search', + target: { mode: 'id', id: sourceId }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: '{{ServiceName}}', + }, + ], + }, + }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles[0].config.onClick).toEqual({ + type: 'search', + target: { mode: 'id', id: sourceId }, + whereLanguage: 'sql', + filters: [ + { + kind: 'expressionTemplate', + expression: 'ServiceName', + template: '{{ServiceName}}', + }, + ], + }); + }); + + it('should reject a table tile onClick referencing a non-existent dashboard', async () => { + const sourceId = traceSource._id.toString(); + const ghostDashboardId = new mongoose.Types.ObjectId().toString(); + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'OnClick missing dashboard', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { mode: 'id', id: ghostDashboardId }, + whereLanguage: 'sql', + }, + }, + }, + ], + }); + + expect(result.isError).toBe(true); + const text = getFirstText(result); + expect(text).toContain('onClick dashboard'); + expect(text).toContain(ghostDashboardId); + }); + + it('should reject a table tile onClick search target with a non-log/trace source', async () => { + // /search only renders log/trace sources; targeting a metric + // source must be rejected at save time, matching the REST POST + // path. Create the metric source inline so this test is + // self-contained. + const metricSource = await Source.create({ + kind: SourceKind.Metric, + team: team._id, + from: { + databaseName: DEFAULT_DATABASE, + tableName: '', + }, + metricTables: { + [MetricsDataType.Gauge.toLowerCase()]: 'otel_metrics_gauge', + [MetricsDataType.Sum.toLowerCase()]: 'otel_metrics_sum', + [MetricsDataType.Histogram.toLowerCase()]: 'otel_metrics_histogram', + }, + timestampValueExpression: 'TimeUnix', + connection: connection._id, + name: 'Metrics', + }); + const sourceId = traceSource._id.toString(); + + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'OnClick metric source', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + onClick: { + type: 'search', + target: { + mode: 'id', + id: metricSource._id.toString(), + }, + whereLanguage: 'sql', + }, + }, + }, + ], + }); + + expect(result.isError).toBe(true); + const text = getFirstText(result); + expect(text).toContain('log or trace'); + expect(text).toContain(metricSource._id.toString()); + }); + + it('should reject a table tile onClick with an invalid target.id', async () => { + // target.id must be a valid ObjectId. Bad shape is caught by the + // input schema and surfaces as the SDK's "Input validation error" + // envelope rather than a custom 400 message. + const sourceId = traceSource._id.toString(); + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'OnClick bad object id', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + onClick: { + type: 'dashboard', + target: { mode: 'id', id: 'not-a-valid-object-id' }, + whereLanguage: 'sql', + }, + }, + }, + ], + }); + + expect(result.isError).toBe(true); + }); + + it('should accept onClick without whereLanguage (it is optional at the schema layer)', async () => { + // SearchConditionLanguageSchema is `.optional()` in common-utils, + // so omitting whereLanguage is allowed. Pin that behavior so a + // future tightening of the schema doesn't quietly change it + // without updating the docs that callers rely on. + const sourceId = traceSource._id.toString(); + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'OnClick no whereLanguage', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + onClick: { + type: 'search', + target: { mode: 'id', id: sourceId }, + // whereLanguage intentionally omitted + }, + }, + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.tiles[0].config.onClick).toMatchObject({ + type: 'search', + target: { mode: 'id', id: sourceId }, + }); + }); + + it('should reject an unknown onClick.type', async () => { + // The schema is a discriminated union on `type`; a bogus type + // must be rejected up front. + const sourceId = traceSource._id.toString(); + const result = await callTool(client, 'hyperdx_save_dashboard', { + name: 'OnClick bad type', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + onClick: { + type: 'navigate-to-mars', + target: { mode: 'id', id: sourceId }, + whereLanguage: 'sql', + }, + }, + }, + ], + }); + + expect(result.isError).toBe(true); + }); + + it('should validate onClick on update too', async () => { + // Round-trip a tile with no onClick, then update it to add an + // onClick pointing at a non-existent dashboard. The PUT path + // mirrors POST: missing dashboards are rejected with the same + // message. + const sourceId = traceSource._id.toString(); + const createResult = await callTool(client, 'hyperdx_save_dashboard', { + name: 'OnClick update validation', + tiles: [ + { + name: 'Top Services', + config: { + displayType: 'table', + sourceId, + select: [{ aggFn: 'count' }], + }, + }, + ], + }); + expect(createResult.isError).toBeFalsy(); + const created = JSON.parse(getFirstText(createResult)); + + const ghostDashboardId = new mongoose.Types.ObjectId().toString(); + const updateResult = await callTool(client, 'hyperdx_save_dashboard', { + id: created.id, + name: 'OnClick update validation', + tiles: [ + { + ...created.tiles[0], + config: { + ...created.tiles[0].config, + onClick: { + type: 'dashboard', + target: { mode: 'id', id: ghostDashboardId }, + whereLanguage: 'sql', + }, + }, + }, + ], + }); + + expect(updateResult.isError).toBe(true); + const text = getFirstText(updateResult); + expect(text).toContain('onClick dashboard'); + expect(text).toContain(ghostDashboardId); + }); + }); + describe('hyperdx_delete_dashboard', () => { it('should delete an existing dashboard', async () => { const dashboard = await new Dashboard({ @@ -1668,6 +2094,44 @@ describe('MCP Dashboard Tools', () => { expect(constraintsBody).toMatch(/not currently exposed via the MCP/); }); + it('documents table-tile onClick linking features', () => { + // Lock down the documentation for row-click drill-downs so a + // future refactor can't quietly drop the section the LLM relies + // on to wire up onClick correctly. + const prompt = buildQueryGuidePrompt(); + const idx = prompt.indexOf('== TABLE TILE LINKING (config.onClick) =='); + expect(idx).toBeGreaterThan(-1); + const next = prompt.indexOf('\n== ', idx + 1); + const section = prompt.slice(idx, next === -1 ? prompt.length : next); + + // Both destination types are mentioned. + expect(section).toContain('type: "search"'); + expect(section).toContain('type: "dashboard"'); + // Both target modes are mentioned. + expect(section).toContain('mode: "id"'); + expect(section).toContain('mode: "template"'); + // Templating fields are mentioned. + expect(section).toContain('whereTemplate'); + expect(section).toContain('filters'); + expect(section).toContain('expressionTemplate'); + // The two server-side validation error messages are quoted so + // the LLM can recognize and recover from them. + expect(section).toContain('onClick search source IDs'); + expect(section).toContain('onClick dashboard IDs'); + }); + + it('documents onClick pitfalls under common mistakes', () => { + const prompt = buildQueryGuidePrompt(); + const idx = prompt.indexOf('== COMMON MISTAKES =='); + expect(idx).toBeGreaterThan(-1); + const section = prompt.slice(idx); + // Mention the four failure modes we hit during validation. + expect(section.toLowerCase()).toContain('onclick on a non-table tile'); + expect(section.toLowerCase()).toContain('non-log/trace source'); + expect(section.toLowerCase()).toContain('missing wherelanguage'); + expect(section.toLowerCase()).toContain("isn't in the table"); + }); + it('contains no em-dashes or en-dashes used as em-dashes', () => { const prompt = buildQueryGuidePrompt(); expect(prompt).not.toMatch(/—/); // [prose-lint: allow] @@ -1741,6 +2205,26 @@ describe('MCP Dashboard Tools', () => { // not in the checklist anyway, but guard against them just in case. expect(prompt).not.toMatch(/ – /); // [prose-lint: allow] }); + + it("mentions row-click linking in the table tile's description", () => { + // The create prompt is the LLM's primary entry point for new + // dashboards. Surface onClick on the TILE TYPE GUIDE so the + // model considers wiring it up on overview tables without the + // user having to ask. + const prompt = buildCreateDashboardPrompt( + 'summary', + 'trace_src', + 'log_src', + ); + const guideIdx = prompt.indexOf('== TILE TYPE GUIDE =='); + expect(guideIdx).toBeGreaterThan(-1); + const guideEnd = prompt.indexOf('\n== ', guideIdx + 1); + const guideBody = prompt.slice(guideIdx, guideEnd); + // The table line must hint at onClick and point at the + // consolidated TABLE TILE LINKING section in the query guide. + expect(guideBody).toContain('onClick'); + expect(guideBody).toContain('TABLE TILE LINKING'); + }); }); describe('buildDashboardExamplesPrompt', () => { @@ -1761,6 +2245,7 @@ describe('MCP Dashboard Tools', () => { 'service_detail', 'log_analytics', 'backend_dependencies', + 'drilldown_links', 'infrastructure_sql', ]); }); @@ -1809,6 +2294,31 @@ describe('MCP Dashboard Tools', () => { expect(single).not.toContain('== LOG ANALYTICS =='); }); + it('exposes a drilldown_links example pattern with onClick wiring', () => { + // Drew's drilldown_links example carries a self-contained + // onClick walkthrough (search drill-down + dashboard drill-down). + // Keep it discoverable so an agent asked to wire up drill-downs + // can opt into this example by pattern name. + const all = buildDashboardExamplesPrompt( + 'trace_src', + 'log_src', + 'conn_id', + ); + expect(all).toContain('drilldown_links'); + + const filtered = buildDashboardExamplesPrompt( + 'trace_src', + 'log_src', + 'conn_id', + 'drilldown_links', + ); + expect(filtered).toContain('ROW-CLICK DRILL-DOWN LINKS'); + expect(filtered).toContain('onClick'); + expect(filtered).toContain('type: "search"'); + expect(filtered).toContain('type: "dashboard"'); + expect(filtered).toContain('expressionTemplate'); + }); + it('falls back to showing all examples when pattern is unknown', () => { const fallback = buildDashboardExamplesPrompt( '000000000000000000000001', diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index d9f4a0895d..cd454278ea 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -56,7 +56,7 @@ Use BUILDER tiles (with sourceId) for most cases: line Time-series trends (error rate, request volume, latency over time). stacked_bar Compare categories over time (requests by service, errors by status code). number Single KPI metric (total requests, current error rate, p99 latency). - table Ranked lists (top endpoints by latency, error counts by service). + table Ranked lists (top endpoints by latency, error counts by service). Tables can wire row-click navigation via config.onClick to the /search page or another dashboard. See "TABLE TILE LINKING" below. pie Proportional breakdowns (traffic share by service, errors by type). Keep slice count under 8. heatmap Distribution of a numeric value over time (latency buckets, payload size). Trace sources only. Requires non-empty valueExpression. search Browse raw log/event rows (error logs, recent traces). @@ -604,12 +604,6 @@ copy the right ID and discover which filters are available. { aggFn: "count", alias: "Requests" }, { aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95" } ], - // The target dashboard MUST declare a top-level DashboardFilter - // with expression: "ServiceName" — otherwise the value is dropped. - // Call hyperdx_get_dashboard() and - // inspect its filters[] before generating this onClick. If the - // target doesn't declare it, either update the target to declare - // ServiceName, or switch to a whereTemplate clause here. onClick: { type: "dashboard", target: { mode: "id", id: "" }, @@ -903,6 +897,83 @@ Example: When a value is picked in the dropdown, the renderer combines it with each tile's existing where clause via AND. Tiles do NOT need to reference the filter name; the source match alone is enough. +== TABLE TILE LINKING (config.onClick) == + +Table tiles can wire up row-click navigation via config.onClick. Builder table tiles (displayType: "table") and raw-SQL tiles (configType: "sql", displayType: "table") both accept this; other tile types ignore it. + +Destination types: + { type: "search", target, whereLanguage, whereTemplate?, filters? } + Opens the /search page for a log or trace source. Metric and session sources are rejected by the server (the /search page does not render those kinds). + { type: "dashboard", target, whereLanguage, whereTemplate?, filters? } + Opens another HyperDX dashboard owned by the same team. + +Target shape, how the destination is identified: + target: { mode: "id", id: "" } + Pins a specific source (for type=search) or dashboard (for type=dashboard) by its ID. Prefer this when the target is known. Find source IDs with hyperdx_list_sources; find dashboard IDs with hyperdx_get_dashboard (no id arg returns the list of all dashboards). + target: { mode: "template", template: "" } + Renders the template against the clicked row's columns, then resolves the result by NAME on the destination team. Example template "{{Service}}" reads the row's Service value and looks up a source/dashboard with that name. Use this when the destination depends on which row was clicked. A constant template like "Service Detail" (no Handlebars vars) simply resolves to the dashboard or source with that exact name. + +Templating values come from the clicked row's columns. The column name in {{...}} must match a column produced by the table query (group-by columns and aliases both work). + + whereTemplate Handlebars-style template. The rendered string is placed in the destination's \`where\` query param. Uses Lucene or SQL syntax matching \`whereLanguage\`. Example: "ServiceName = '{{service.name}}'". + + filters Array of { kind: "expressionTemplate", expression, template }. Each filter renders to an IN clause on the destination (\`expression IN ('rendered-value')\`). When multiple filters share an expression they are merged into one IN clause. The destination dashboard auto-populates its filter list (matched by expression), so prefer this shape over whereTemplate when the target dashboard already declares the same filter expressions. + +whereLanguage is optional, but set it to "sql" or "lucene" so the destination knows how to parse whereTemplate / filter values when a template is rendered. + +Example, search drill-down with a row-driven filter: + { + "displayType": "table", + "sourceId": "", + "groupBy": "ResourceAttributes['service.name']", + "select": [{ "aggFn": "count" }], + "onClick": { + "type": "search", + "target": { "mode": "id", "id": "" }, + "whereLanguage": "sql", + "filters": [ + { + "kind": "expressionTemplate", + "expression": "ServiceName", + "template": "{{ResourceAttributes['service.name']}}" + } + ] + } + } + +Example, dashboard drill-down with multiple row-driven filters: + "onClick": { + "type": "dashboard", + "target": { "mode": "id", "id": "" }, + "whereLanguage": "sql", + "filters": [ + { + "kind": "expressionTemplate", + "expression": "ServiceName", + "template": "{{ResourceAttributes['service.name']}}" + }, + { + "kind": "expressionTemplate", + "expression": "Environment", + "template": "{{ResourceAttributes['deployment.environment']}}" + } + ] + } + +Example, destination chosen by the clicked row (rare; prefer mode="id"): + "onClick": { + "type": "dashboard", + "target": { "mode": "template", "template": "{{TargetDashboardName}}" }, + "whereLanguage": "lucene" + } + +Validation rules the server enforces: + - target.id (mode="id") must be a valid ObjectId. + - For type="search", target.id must reference an existing source whose kind is "log" or "trace". Metric and session sources are rejected with: "The following onClick search source IDs are not log or trace sources: ..." + - For type="dashboard", target.id must reference an existing dashboard owned by the same team. Missing dashboards are rejected with: "Could not find the following onClick dashboard IDs: ..." + +When pairing a service-inventory table with a service-detail dashboard, use target.mode: "template" with the partner dashboard's exact name as the constant template (e.g. "Service Detail"). The clicked row's groupBy column (ServiceName) carries through as a filter expression. See the "service_inventory" example in dashboard_examples for the canonical wiring. + == CONTAINERS AND TABS == Optional dashboard organization layer. Group related tiles visually with optional tab bars. Pass on hyperdx_save_dashboard as a top-level "containers" array; reference containers from tiles via "containerId" (and "tabId" when the container has tabs). @@ -1073,19 +1144,5 @@ Example: find top patterns for production services over the last 4 hours: If the column doesn't appear in the table query, the template renders as empty at click time and the destination opens unfiltered. Match {{column}} names to columns produced by the table (group-by columns - and aliases both work). - -15. onClick (type="dashboard") filter expression not declared on the target - For a dashboard target, each entry in onClick.filters[] only takes - effect when the destination dashboard declares a top-level - DashboardFilter with the SAME \`expression\`. The renderer drops URL - filter entries whose expression isn't in the destination's declared - filter set — so the user lands on the destination unfiltered. - Before generating an onClick.filters entry, call - hyperdx_get_dashboard on the target and confirm its - \`filters[].expression\` list. If the expression isn't there, either: - a) declare it on the target dashboard's top-level \`filters\` array - (passed to hyperdx_save_dashboard), or - b) use \`whereTemplate\` instead, which doesn't require a declared - filter on the target."`; + and aliases both work).`; } diff --git a/packages/api/src/mcp/prompts/dashboards/index.ts b/packages/api/src/mcp/prompts/dashboards/index.ts index 562a93aff3..e98e9f312d 100644 --- a/packages/api/src/mcp/prompts/dashboards/index.ts +++ b/packages/api/src/mcp/prompts/dashboards/index.ts @@ -104,7 +104,7 @@ const dashboardPrompts: PromptDefinition = (server, context) => { title: 'Dashboard Examples', description: 'Concrete dashboard example shapes for common observability patterns: ' + - 'service_inventory, service_detail, log_analytics, backend_dependencies, infrastructure_sql. ' + + 'service_inventory, service_detail, log_analytics, backend_dependencies, drilldown_links, infrastructure_sql. ' + 'Each example carries a "when to use" header. Adapt the structure to the user\'s request; ' + 'do not copy literal column names or status values without verifying via hyperdx_list_sources first.', argsSchema: { @@ -112,7 +112,7 @@ const dashboardPrompts: PromptDefinition = (server, context) => { .string() .optional() .describe( - 'Filter to a specific pattern: service_inventory, service_detail, log_analytics, backend_dependencies, infrastructure_sql', + 'Filter to a specific pattern: service_inventory, service_detail, log_analytics, backend_dependencies, drilldown_links, infrastructure_sql', ), }, }, diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index b195ced78f..97fa9448ab 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -179,7 +179,7 @@ const mcpOnClickTargetSchema = z 'Concrete source ID (for type=search) or dashboard ID (for type=dashboard). ' + 'Get source IDs from hyperdx_list_sources; get dashboard IDs from ' + 'hyperdx_get_dashboard (no id arg returns the list). ' + - 'For type=search the source kind must be "log" or "trace" — the /search ' + + 'For type=search the source kind must be "log" or "trace"; the /search ' + 'page does not render metric/session sources.', ), }) @@ -196,7 +196,7 @@ const mcpOnClickTargetSchema = z 'resolved by NAME on the destination team. ' + 'Example: "ServiceOverview-{{ServiceName}}" assumes a row column ServiceName whose value ' + 'is the name of a source/dashboard. Prefer mode="id" when the target is ' + - 'known up-front — it survives renames.', + 'known up-front; it survives renames.', ), }) .describe( @@ -224,7 +224,7 @@ const mcpOnClickSearchSchema = z "and placed in the destination's `where` query param. " + 'Example: "ServiceName = \'{{service.name}}\'" pulls service.name from the row. ' + 'Use Lucene or SQL syntax matching `whereLanguage`. Prefer `filters` (below) ' + - 'for simple equality — filters merge nicely on the destination.', + 'for simple equality; filters merge nicely on the destination.', ), whereLanguage: SearchConditionLanguageSchema.describe( 'Filter language for `whereTemplate` and `filters` ("lucene" or "sql"). ' + @@ -256,10 +256,8 @@ const mcpOnClickDashboardSchema = z .optional() .describe( 'Optional Handlebars-style WHERE template applied to the destination ' + - "dashboard's global filter. Unlike `filters`, this does NOT require the target " + - 'dashboard to declare anything specific — the rendered string is dropped into the ' + - "destination's `where` query param as-is. Use this when the target dashboard " + - 'does not declare a matching DashboardFilter for the column you want to scope by.', + "dashboard's global filter. Useful when the target dashboard exposes a single " + + 'global scope rather than per-tile filters.', ), whereLanguage: SearchConditionLanguageSchema.describe( 'Filter language for `whereTemplate` and `filters` ("lucene" or "sql"). ' + @@ -271,19 +269,10 @@ const mcpOnClickDashboardSchema = z .max(50) .optional() .describe( - 'Optional list of templated equality filters. Each entry pre-populates the ' + - "destination dashboard's filter bar dropdown for the matching `expression`.\n\n" + - 'REQUIRES: the destination dashboard must declare a top-level filter ' + - "whose `expression` equals this filter's `expression`. If the target does not " + - 'declare a matching filter, the value is SILENTLY DROPPED at click time and the ' + - 'destination opens unfiltered.\n\n' + - 'Before generating dashboard onClick filters, call `hyperdx_get_dashboard` on the ' + - 'target and confirm its `filters[].expression` list. If the target does not ' + - 'declare the expression you want to scope by, either (a) add it to the target ' + - "dashboard's `filters` array, or (b) use `whereTemplate` instead, which does not " + - 'require a declared filter.\n\n' + - 'The save tool validates this and will reject onClick filters whose expression ' + - 'is not declared on the target dashboard.', + 'Optional list of templated equality filters. The destination dashboard ' + + 'auto-populates its filter list with these (matched by expression), so prefer ' + + 'this over whereTemplate when the target dashboard already declares the same ' + + 'filter expressions.', ), }) .describe( From e458d793659e9b113bcd0ff4895da6515b11c41e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 14 May 2026 00:31:58 +0000 Subject: [PATCH 05/12] feat(mcp): wire service_inventory row-click into service_detail dashboard The service_inventory pattern's Services table now carries an onClick that drills into the partner "Service Detail" dashboard. Using mode: "template" with the constant template "Service Detail" resolves the target by name at click time, so the canonical flow (save both dashboards in one shot) works without needing to thread the detail dashboard's ID back into the inventory tile. The onClick filter's expression "ServiceName" matches the service_detail filter declaration, so the destination dashboard's "Service" dropdown auto-populates with the clicked row's value rather than asking the user to re-select it. Snapshot test asserts the template target, filter expression, and template field shape so a future refactor cannot silently break the inventory -> detail drill-down link. --- .../api/src/mcp/__tests__/dashboards.test.ts | 24 +++++++++++++++++++ .../api/src/mcp/prompts/dashboards/content.ts | 21 +++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index 7db58d424b..64f0830da4 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -2294,6 +2294,30 @@ describe('MCP Dashboard Tools', () => { expect(single).not.toContain('== LOG ANALYTICS =='); }); + it('wires service_inventory row-click into the service_detail dashboard', () => { + // The service_inventory pattern's main RED table should carry an + // onClick that drills into the partner "Service Detail" dashboard + // by name template, with a ServiceName filter rendered from the + // clicked row. Without this wiring, an agent following the canonical + // workflow would have to invent the drill-down each time and + // sometimes get the field shape wrong. + const single = buildDashboardExamplesPrompt( + '000000000000000000000001', + '000000000000000000000002', + '000000000000000000000003', + 'service_inventory', + ); + // The onClick target points at the partner pattern by name. + expect(single).toContain( + 'target: { mode: "template", template: "Service Detail" }', + ); + // The onClick filter expression matches service_detail's filter expression, + // so the destination dashboard's "Service" dropdown auto-populates. + expect(single).toContain('expression: "ServiceName"'); + // The template references the row's groupBy column (ServiceName). + expect(single).toContain('template: "{{ServiceName}}"'); + }); + it('exposes a drilldown_links example pattern with onClick wiring', () => { // Drew's drilldown_links example carries a self-contained // onClick walkthrough (search drill-down + dashboard drill-down). diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index cd454278ea..34e4916694 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -134,6 +134,8 @@ When to use: the user wants a multi-service overview, one row per service, with Why this shape: the table answers "how is each thing doing right now". The Trends container answers "what changed over time". Tabs keep the trends section compact (only the active view renders). +Pairing: the Services table wires row-click to drill into a partner per-service dashboard. The canonical partner is the "service_detail" pattern saved as "Service Detail"; clicking a service row opens it with the ServiceName filter pre-populated to that row's value. Save the service_detail dashboard FIRST (or alongside) so the partner exists when a user clicks; mode: "template" resolves by name at click time, so a missing partner shows an error toast instead of navigating. If the user renames the detail dashboard, update the onClick template to match. + { name: "Service Inventory", containers: [ @@ -165,7 +167,24 @@ Why this shape: the table answers "how is each thing doing right now". The Trend ], groupBy: "ServiceName", orderBy: "Requests DESC", - groupByColumnsOnLeft: true + groupByColumnsOnLeft: true, + // Row-click drills into the partner "Service Detail" dashboard. + // The detail dashboard declares filter expression "ServiceName"; + // expression here MUST match for the destination's dropdown to + // auto-populate. {{ServiceName}} pulls the clicked row's + // ServiceName column value (set by groupBy above). + onClick: { + type: "dashboard", + target: { mode: "template", template: "Service Detail" }, + whereLanguage: "sql", + filters: [ + { + kind: "expressionTemplate", + expression: "ServiceName", + template: "{{ServiceName}}" + } + ] + } } }, { From c77b3d3d8c1a5e88ee9bd1aa503a6b873b7b5aa7 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 14 May 2026 02:00:04 +0000 Subject: [PATCH 06/12] feat(mcp): tighten dashboard prompts after first live Claude run Worked Claude through five starter dashboards on a fresh stack; this pass folds the patterns it got wrong back into the prompts. DESIGN CHECKLIST changes: - Rule 2 (alias) now reads ALIAS EVERY SELECT ITEM, not ALIAS EVERY AGGREGATION. Claude landed three number tiles with no alias on the quantile() select item; rule 2 as written felt table-specific. - Rule 10 (containers) is now REQUIRED at five-plus tiles, not a soft hint. Claude built five dashboards averaging ten tiles each with zero containers when the rule was a SHOULD. - New rule 11 VALIDATE EVERY TILE AFTER SAVE, separated from the workflow step so it has its own anchor in the checklist. - New rule 12 NO TITLE-RECAP MARKDOWN TILE. Claude added a 24x2 "About this dashboard" markdown tile to every starter dashboard; the renderer styles markdown headings at title-bar scale so the tile ate a row of vertical space before the first KPI showed. TILE TYPE GUIDE: markdown line rewritten to actively discourage the title-recap habit (use containers/tabs for sectioning, skip the markdown tile entirely on starters, no headings inside markdown bodies if you must add one). QUERY GUIDE additions: - LUCENE FILTER SYNTAX gains a GOTCHA block: Lucene comparison operators and wildcards on dotted attribute paths (http.status_code:>=500, http.route:*) parse and save without error but fail silently at query time. Workaround: switch to SQL with bracket access. This was the cause of every "Error loading chart" Claude hit during its run. - PER-TILE TYPE CONSTRAINTS expanded for metric sources: builder tile authoring on metric sources currently renders as "Both table name and UUID are empty" at query time. Use a raw SQL tile with explicit table reference (otel_metrics_gauge / otel_metrics_sum / otel_metrics_histogram) until the builder catches up. Discovery note added because metric sources don't publish mapAttributeKeys the way log and trace sources do. - TABLE TILE LINKING gains a GROUPBY ALIASES AND ROW-CLICK TEMPLATES subsection. Builder tiles don't expose a groupBy alias today, so groupBy: SpanAttributes['http.route'] produces a column name no Handlebars template can reference as {{Route}}. Workaround: configType: "sql" with explicit AS Route. - COMMON MISTAKES rule 8 (validate-after-save) tightened to "every tile, not just one". Rule 13 (missing alias) and rule 14 (Lucene comparison on map paths) added with concrete wrong/correct examples. Rules 11/12 onClick entries were duplicate-numbered in the cherry-pick; renumbered to 15-18. - New REFERENCES section at the end of the query guide pins three ClickStack / ClickHouse doc URLs: search syntax for Lucene, the SQL reference for the where field with whereLanguage:"sql", and the sql-visualizations page for the $__timeFilter / $__timeInterval / $__filters macros used in raw SQL tiles. DEFAULT TIME WINDOW: short note in the create prompt that dashboards open at 15 minutes and the user-side time picker is currently the only way to widen the window. Tells the agent to surface this to the user rather than padding individual tile time ranges to compensate. Snapshot tests extended to lock the new rules in place: design checklist now iterates 1 to 12, asserts on the strengthened phrasings ("ALIAS EVERY SELECT ITEM", "REQUIRED at five or more tiles", "VALIDATE EVERY TILE AFTER SAVE", "NO TITLE-RECAP MARKDOWN TILE"), plus per-section coverage tests for the GROUPBY ALIASES workaround, the Lucene gotcha (in both LUCENE FILTER SYNTAX and COMMON MISTAKES), the metric-source workaround text, and the three reference URLs. --- .../api/src/mcp/__tests__/dashboards.test.ts | 147 +++++++++++++++++- .../api/src/mcp/prompts/dashboards/content.ts | 90 +++++++++-- 2 files changed, 226 insertions(+), 11 deletions(-) diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index 64f0830da4..963779b348 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -2132,6 +2132,100 @@ describe('MCP Dashboard Tools', () => { expect(section.toLowerCase()).toContain("isn't in the table"); }); + it('documents the missing-alias mistake explicitly', () => { + // Claude built three number tiles without alias on the quantile() + // select item, even though rule 2 of the design checklist says + // ALIAS EVERY SELECT ITEM. Spelling out the mistake under COMMON + // MISTAKES gives the model a second touchpoint to catch it. + const prompt = buildQueryGuidePrompt(); + const idx = prompt.indexOf('== COMMON MISTAKES =='); + const section = prompt.slice(idx); + expect(section).toMatch(/Missing alias on a select item/); + // Must call out that number tiles need the alias too; this is the + // exact case Claude got wrong. + expect(section).toMatch(/number tiles too/); + }); + + it('documents the Lucene comparison/wildcard gotcha on map attributes', () => { + // Lucene parses http.status_code:>=500 happily but the translator + // doesn't map dotted paths to bracket access when an operator is + // in play. The tile saves, query_tile errors, and the dashboard + // shows "Error loading chart" with no explanation. + const prompt = buildQueryGuidePrompt(); + const luceneIdx = prompt.indexOf('== LUCENE FILTER SYNTAX =='); + const luceneBody = prompt.slice(luceneIdx); + // The gotcha block is in the Lucene section. + expect(luceneBody).toMatch( + /Lucene comparison operators .* fail silently/, + ); + expect(luceneBody).toMatch(/http\.status_code:>=500/); + expect(luceneBody).toMatch(/switch the where to SQL with bracket access/); + + // Also a top-level entry in COMMON MISTAKES for discoverability. + const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); + const mistakesBody = prompt.slice(mistakesIdx); + expect(mistakesBody).toMatch( + /Lucene comparison or wildcard on a dotted map-attribute path/, + ); + }); + + it('documents the groupBy alias workaround for map-attribute drill-downs', () => { + // Builder tiles don't expose a groupBy alias today; map-attribute + // groupBys produce result columns named like the raw expression, + // which onClick template lookups can't reference. The workaround + // (author the tile as raw SQL with AS) needs to be findable. + const prompt = buildQueryGuidePrompt(); + const idx = prompt.indexOf( + '== GROUPBY ALIASES AND ROW-CLICK TEMPLATES ==', + ); + expect(idx).toBeGreaterThan(-1); + const section = prompt.slice(idx); + expect(section).toMatch(/do NOT currently expose a groupBy alias/); + expect(section).toMatch(/configType: "sql", displayType: "table"/); + expect(section).toMatch(/AS Route/); + }); + + it('points at ClickStack and ClickHouse docs in REFERENCES', () => { + // Without external doc links, the LLM has to guess at Lucene + // operators and ClickHouse function names. Anchor the canonical + // URLs in the prompt so it can cite them and so a future doc + // restructure can be caught here. + const prompt = buildQueryGuidePrompt(); + const idx = prompt.indexOf('== REFERENCES =='); + expect(idx).toBeGreaterThan(-1); + const section = prompt.slice(idx); + expect(section).toContain( + 'https://clickhouse.com/docs/use-cases/observability/clickstack/search', + ); + expect(section).toContain('https://clickhouse.com/docs/sql-reference'); + expect(section).toContain( + 'https://clickhouse.com/docs/use-cases/observability/clickstack/dashboards/sql-visualizations', + ); + }); + + it('strengthens the validate-every-tile rule under common mistakes', () => { + // Saving validates input shape, not query semantics. The prompt + // has to push for query_tile on every tile, not "at least one". + const prompt = buildQueryGuidePrompt(); + const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); + const section = prompt.slice(mistakesIdx); + expect(section).toMatch( + /on EVERY tile after hyperdx_save_dashboard, not just one/, + ); + }); + + it('flags the metric source builder gap', () => { + // Builder tiles on metric sources currently save but render with + // "Both table name and UUID are empty". Claude went 100% raw SQL + // across 21 metric tiles for this reason; the prompt has to make + // the workaround obvious so the model doesn't try the builder + // first and fail silently. + const prompt = buildQueryGuidePrompt(); + expect(prompt).toMatch(/Both table name and UUID are empty/); + expect(prompt).toMatch(/use a raw SQL tile/); + expect(prompt).toMatch(/otel_metrics_gauge/); + }); + it('contains no em-dashes or en-dashes used as em-dashes', () => { const prompt = buildQueryGuidePrompt(); expect(prompt).not.toMatch(/—/); // [prose-lint: allow] @@ -2187,7 +2281,11 @@ describe('MCP Dashboard Tools', () => { const checklistIdx = prompt.indexOf('== DESIGN CHECKLIST =='); const adaptIdx = prompt.indexOf('== ADAPT, DO NOT COPY =='); const checklistBody = prompt.slice(checklistIdx, adaptIdx); - for (let i = 1; i <= 10; i++) { + // Twelve rules: the original ten plus VALIDATE EVERY TILE AFTER SAVE + // (rule 11) and NO TITLE-RECAP MARKDOWN TILE (rule 12). Both came out + // of the May verification pass after watching Claude reliably ignore + // the soft "should" formulations. + for (let i = 1; i <= 12; i++) { expect(checklistBody).toMatch(new RegExp(`^${i}\\. `, 'm')); } expect(prompt).toContain('ADAPT, DO NOT COPY'); @@ -2195,6 +2293,53 @@ describe('MCP Dashboard Tools', () => { // agent that ignores this can silently drop tiles / filters / containers // on a subsequent save call. expect(checklistBody).toMatch(/UPDATE IS REPLACE, NOT MERGE/); + // Rule 2 must cover EVERY select item (including number tiles), not + // just aggregations on tables. The phrasing matters: Claude's first + // pass at this dropped aliases on number tiles because it read rule 2 + // as table-specific. The stronger phrasing pulls them in. + expect(checklistBody).toMatch(/ALIAS EVERY SELECT ITEM/); + // Rule 10 must be a hard requirement at 5+ tiles, not a soft hint. + // Without the imperative, Claude built five dashboards averaging ten + // tiles each with zero containers. + expect(checklistBody).toMatch(/REQUIRED at five or more tiles/); + // Rule 11 + 12 close the post-save loop and the title-recap markdown + // habit Claude landed on every starter dashboard. + expect(checklistBody).toMatch(/VALIDATE EVERY TILE AFTER SAVE/); + expect(checklistBody).toMatch(/NO TITLE-RECAP MARKDOWN TILE/); + }); + + it('warns explicitly against title-recap markdown tiles in the tile type guide', () => { + // The TILE TYPE GUIDE entry for markdown is where a model reading + // top-down first decides whether to add an "About this dashboard" + // tile. The entry has to actively discourage it, not just describe + // the tile type, because every model trained on dashboard examples + // reaches for a title tile by default. + const prompt = buildCreateDashboardPrompt( + 'summary', + 'trace_src', + 'log_src', + ); + const guideIdx = prompt.indexOf('== TILE TYPE GUIDE =='); + const guideEnd = prompt.indexOf('\n== ', guideIdx + 1); + const guideBody = prompt.slice(guideIdx, guideEnd); + // The markdown line must say "skip" / "sparingly" / "do not" / + // "no headings", not just "use for notes". Look for the most + // load-bearing phrase. + expect(guideBody).toMatch(/Skip markdown tiles for starter dashboards/); + }); + + it('explains the 15-minute default time window UX trap', () => { + // Empty tiles on a fresh dashboard are usually a time-window problem, + // not a query problem. The prompt has to surface this so Claude + // tells the user (rather than silently padding individual tile time + // ranges to make a 24-hour view fit a 15-minute dashboard frame). + const prompt = buildCreateDashboardPrompt( + 'summary', + 'trace_src', + 'log_src', + ); + expect(prompt).toMatch(/DEFAULT TIME WINDOW/); + expect(prompt).toMatch(/15-minute default window/); }); it('contains no em-dashes or en-dashes used as em-dashes', () => { diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index 34e4916694..de969b55f1 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -60,7 +60,7 @@ Use BUILDER tiles (with sourceId) for most cases: pie Proportional breakdowns (traffic share by service, errors by type). Keep slice count under 8. heatmap Distribution of a numeric value over time (latency buckets, payload size). Trace sources only. Requires non-empty valueExpression. search Browse raw log/event rows (error logs, recent traces). - markdown Dashboard notes, section headers, or documentation. + markdown Use sparingly. The dashboard already shows its name in the title bar at the top; do NOT add a "About this dashboard" tile that repeats it. Markdown bodies render h1/h2/h3 headings at title-bar scale, so a single \`## Service Catalog\` line eats most of the tile and pushes real KPIs below the fold. Skip markdown tiles for starter dashboards. If you must add one, use h: 1, plain prose, no \`#\`/\`##\`/\`###\` headings. Use containers/tabs for section grouping instead. Use RAW SQL tiles (with connectionId) only for queries the builder cannot express: Requires configType: "sql" plus a displayType (line, stacked_bar, table, number, pie). @@ -82,7 +82,7 @@ Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the 1. ONE QUESTION PER DASHBOARD. A dashboard answers a single observability question well. Split if the request mixes traces, logs, and metrics into unrelated views. -2. ALIAS EVERY AGGREGATION. Set alias on each select item ("Requests", "Errors", "P95 Duration"). Without alias, table headers read like raw expressions. +2. ALIAS EVERY SELECT ITEM. Every entry in select MUST carry an alias. Tables, lines, stacked_bars, pies, AND number tiles. Without alias, the result column is named like the raw expression ("quantile(0.95)(Duration)"), which reads ugly in headers and breaks any downstream onClick template that references a column name. Number tiles look fine in the UI without an alias, but the underlying query still emits a raw-expression column name; the alias is required for export, for orderBy, for consistency, and because the renderer can flip behavior on this. Treat "no alias" as a save-time bug. 3. INVENTORY-STYLE TABLES PUT THE GROUP BY ON THE LEFT. Set groupByColumnsOnLeft: true on tables that read like a list of things (one row per service, per endpoint, per tenant). @@ -98,12 +98,20 @@ Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the 9. UPDATE IS REPLACE, NOT MERGE. hyperdx_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call hyperdx_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted. -10. GROUP RELATED TILES INTO CONTAINERS. A container holds tiles that share a theme (Overview / Performance / Errors). Set containers: [{ id, title, collapsed }] at the top level and reference container ids from each tile via containerId. Use tabs when the same container needs to show different views of the same data; the tab bar appears when a container has two or more tabs. +10. GROUP RELATED TILES INTO CONTAINERS. A container holds tiles that share a theme (Overview / Performance / Errors). Set containers: [{ id, title, collapsed }] at the top level and reference container ids from each tile via containerId. Use tabs when the same container needs to show different views of the same data; the tab bar appears when a container has two or more tabs. REQUIRED at five or more tiles. An ungrouped wall of tiles is a readability failure; even three named containers ("KPIs", "Trends", "Errors") turn a flat dashboard into a scannable one. Containers are the right way to introduce structure; markdown tiles for section labels are not. + +11. VALIDATE EVERY TILE AFTER SAVE. After hyperdx_save_dashboard, call hyperdx_query_tile on EVERY tile (not just one). Save validates input shape; it does NOT validate query semantics. Some queries pass save and fail at render time (known gaps: Lucene comparison/wildcard on map attributes, metric tiles with multiple metricTables, malformed having clauses). If query_tile returns an error, fix the tile and re-save before declaring the dashboard ready. + +12. NO TITLE-RECAP MARKDOWN TILE. The dashboard's name shows in the title bar. Adding a markdown tile with the dashboard name (or a "About this dashboard" header) doubles the title and eats a row of vertical space because markdown heading styles render at title-bar scale. Skip the markdown tile entirely on starter dashboards. == ADAPT, DO NOT COPY == The dashboard_examples prompt returns concrete example shapes. Read them as patterns to adapt to the user's actual request and schema, not as literal templates to substitute names into. Specifically: the literal "ServiceName", "StatusCode:STATUS_CODE_ERROR", "Duration" come from the standard HyperDX schema. Real source schemas may use different column names and status values; always verify with hyperdx_list_sources before reusing literals. +== DEFAULT TIME WINDOW == + +Dashboards open with a 15-minute default window. There is no dashboard-level field to change this default today; it is a user-side control in the header. For overview / starter dashboards on intermittent or batch-ingested data, the 15-minute window may show empty tiles even when the queries are correct. After save, tell the user the default is 15 minutes and that widening to 1h or 24h is the first thing to try if a tile looks empty. Do NOT compensate by hardcoding a wider time range into individual tiles; that desynchronizes them from the dashboard time picker. + == COMMON MISTAKES TO AVOID == - Using valueExpression with aggFn "count" (count takes no valueExpression). @@ -797,6 +805,21 @@ Used in the "where" field of select items and search tiles. NOTE: In Lucene filters, use dot notation for attribute keys (service.name, http.method). This is different from valueExpression and groupBy which require bracket syntax (SpanAttributes['http.method']). +GOTCHA: Lucene comparison operators (>=, >, <=, <) and trailing wildcards (*) on dotted attribute paths parse and save without error but fail silently at query time. The translator treats the dotted path as a literal column rather than mapping it to SpanAttributes['path']. Symptoms: a tile saves fine, hyperdx_query_tile returns an error, and the dashboard renders with "Error loading chart" on that tile. + + Breaks: http.status_code:>=500 + Breaks: http.route:* + Works: http.status_code:200 (simple equality on a dotted path is fine) + Works: Duration:>1000000000 (top-level numeric column with range) + +Workaround: switch the where to SQL with bracket access. Map column values are stored as strings, so quote the comparison value. + + whereLanguage: "sql" + where: "SpanAttributes['http.status_code'] >= '500'" + + whereLanguage: "sql" + where: "SpanAttributes['http.route'] != ''" + == SQL FILTER SYNTAX == Alternative to Lucene. Set whereLanguage: "sql" when using SQL syntax. @@ -866,7 +889,7 @@ For configType: "sql" tiles, write ClickHouse SQL with template macros: search No select items (select is a column list string). where is the filter. markdown No select items. Set markdown field with content. -NOTE: Authoring tiles on a metric source is not currently exposed via the MCP schema. The MCP select-item shape does not carry the metricName / metricType fields the metric query path needs. If you need infrastructure metrics, use a raw SQL tile (configType: "sql") instead. +NOTE: Authoring builder tiles on a metric source is not reliable today. The MCP select-item shape does not carry the metricName / metricType fields the metric query path needs, and a save with a metric sourceId may render in the UI as "Both table name and UUID are empty" even though the save itself succeeded. For metrics, use a raw SQL tile (configType: "sql") with explicit table reference. The standard tables that back a metric source are otel_metrics_gauge, otel_metrics_sum, and otel_metrics_histogram; hyperdx_list_sources returns the metric source's metricTables map so you know which table holds which metric kind. Discovery: metric source schemas today do NOT publish mapAttributeKeys for ResourceAttributes / Attributes the way log and trace sources do, so attribute keys must be discovered by sampling (SELECT DISTINCT mapKeys(Attributes) FROM ...). == NUMBER FORMAT == @@ -993,6 +1016,26 @@ Validation rules the server enforces: When pairing a service-inventory table with a service-detail dashboard, use target.mode: "template" with the partner dashboard's exact name as the constant template (e.g. "Service Detail"). The clicked row's groupBy column (ServiceName) carries through as a filter expression. See the "service_inventory" example in dashboard_examples for the canonical wiring. +== GROUPBY ALIASES AND ROW-CLICK TEMPLATES == + +Builder table tiles do NOT currently expose a groupBy alias / "AS" field. When the groupBy expression is a plain column (ServiceName, SpanName, StatusCode, SeverityText), the result column comes back with that same name, so {{ServiceName}} in an onClick template just works. + +When the groupBy expression is a map attribute (SpanAttributes['http.route'], ResourceAttributes['deployment.environment']), the result column name is the raw expression (arrayElement(SpanAttributes, 'http.route') or similar), which is not a valid Handlebars identifier and cannot be referenced as {{Route}} or {{http.route}}. + +Workaround: when you need a row-click drill-down from a map-attribute groupBy, author the tile as raw SQL (configType: "sql", displayType: "table") with an explicit AS alias on the groupBy column. Reference that alias from the onClick template. + + Builder, breaks {{Route}}: + groupBy: "SpanAttributes['http.route']" -- column comes back without a clean name + + Raw SQL, alias the column: + configType: "sql", displayType: "table", + sqlTemplate: "SELECT SpanAttributes['http.route'] AS Route, count() AS Requests FROM otel_traces WHERE $__timeFilter(TimestampTime) AND $__filters GROUP BY Route ORDER BY Requests DESC LIMIT 100" + onClick: { type: "search", target: { mode: "id", id: "" }, + whereLanguage: "sql", + filters: [{ kind: "expressionTemplate", expression: "SpanAttributes['http.route']", template: "{{Route}}" }] } + +For onClick drill-down on a plain top-level column (ServiceName), no workaround needed; use the builder. + == CONTAINERS AND TABS == Optional dashboard organization layer. Group related tiles visually with optional tab bars. Pass on hyperdx_save_dashboard as a top-level "containers" array; reference containers from tiles via "containerId" (and "tabId" when the container has tabs). @@ -1119,7 +1162,7 @@ Example: find top patterns for production services over the last 4 hours: The dashboard filter applies globally; tiles do not need the literal. 8. Forgetting to validate tiles after saving - Always call hyperdx_query_tile after hyperdx_save_dashboard to verify each tile returns data. + Always call hyperdx_query_tile on EVERY tile after hyperdx_save_dashboard, not just one. Save validates input shape but not query semantics. Several known gaps (Lucene comparison/wildcard on map attributes, builder tiles on metric sources, malformed having) pass save and fail at render time. A dashboard with one bad tile renders the whole page in a degraded state; the user sees "Error loading chart" with no way to know which tile broke unless you validated. If query_tile returns an error, fix the where / SQL and re-save before declaring the dashboard ready. 9. Using sourceId with SQL tiles or connectionId with builder tiles Builder tiles (line, table, etc.) use sourceId. @@ -1141,27 +1184,54 @@ Example: find top patterns for production services over the last 4 hours: Correct: select: [{ valueExpression: "Duration" }] Heatmap requires a non-empty numeric column or expression to bucket. -11. onClick on a non-table tile +13. Missing alias on a select item + Wrong: select: [{ aggFn: "quantile", level: 0.95, valueExpression: "Duration" }] + Correct: select: [{ aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95" }] + Every select entry MUST carry an alias. This applies to number tiles too, not just tables. Without alias the result column is named after the raw expression, which breaks orderBy references, breaks onClick template lookups, and shows ugly headers on tables. Number tiles render the value with the tile name in the UI, so the missing alias is invisible there, but the underlying query still emits a raw-expression column name. Treat "no alias" as a save-time bug, not a style nit. + +14. Lucene comparison or wildcard on a dotted map-attribute path + Wrong: where: "http.status_code:>=500" (whereLanguage: "lucene") + Wrong: where: "http.route:*" (whereLanguage: "lucene") + Correct: where: "SpanAttributes['http.status_code'] >= '500'" (whereLanguage: "sql") + Correct: where: "SpanAttributes['http.route'] != ''" (whereLanguage: "sql") + Lucene parses these patterns happily at save time but the translator does not map http.status_code to SpanAttributes['http.status_code'] when an operator or wildcard is in play. The tile saves clean and fails at render with "Error loading chart". For comparison (>=, >, <=, <) and wildcard (*) on map-attribute paths, switch to SQL with bracket access. Map values are stored as strings, so quote the comparison value. + +15. onClick on a non-table tile Only table tiles (builder displayType: "table" and raw-SQL configType: "sql" with displayType: "table") support config.onClick. Other displayTypes ignore the field. Putting onClick on a line/number/pie/heatmap/search/markdown tile won't error but won't do anything either. -12. onClick targeting a non-log/trace source for type="search" +16. onClick targeting a non-log/trace source for type="search" The /search page only renders log and trace sources. Targeting a metric or session source is rejected at save time with: "The following onClick search source IDs are not log or trace sources: ..." -13. Missing whereLanguage on onClick +17. Missing whereLanguage on onClick whereLanguage is technically optional, but should still be set to "lucene" or "sql" so the destination knows how to parse rendered whereTemplate / filter values. Leaving it unset can cause the destination to fall back to its own default. -14. Templating against a column that isn't in the table's select/groupBy +18. Templating against a column that isn't in the table's select/groupBy Templates like "{{ServiceName}}" pull from the clicked row's columns. If the column doesn't appear in the table query, the template renders as empty at click time and the destination opens unfiltered. Match {{column}} names to columns produced by the table (group-by columns - and aliases both work).`; + and aliases both work). For map-attribute groupBys, see GROUPBY ALIASES + AND ROW-CLICK TEMPLATES; builder tiles do not alias map-attribute + columns cleanly, so you need a raw SQL tile with explicit AS. + +== REFERENCES == + +External docs for the syntax and macros referenced in this guide: + + Lucene search syntax (the "where" field with whereLanguage: "lucene"): + https://clickhouse.com/docs/use-cases/observability/clickstack/search + + ClickHouse SQL functions (the "where" field with whereLanguage: "sql", and any sqlTemplate body): + https://clickhouse.com/docs/sql-reference + + Dashboard time / filter macros used in raw-SQL tile sqlTemplate ($__timeFilter, $__timeFilter_ms, $__timeInterval, $__timeInterval_ms, $__filters): + https://clickhouse.com/docs/use-cases/observability/clickstack/dashboards/sql-visualizations`; } From e02000e32a76b4028af2ad6a06824c2e5c0e02e5 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 14 May 2026 02:59:54 +0000 Subject: [PATCH 07/12] feat(mcp): inline concrete examples + broaden lucene gotcha after second pass Watched Claude run on a fresh stack with the previous prompt revision; this commit closes the remaining gaps surfaced by that run. DESIGN CHECKLIST changes: - Rule 2 (alias) now ships a copy-pasteable number-tile example next to the rule body. The prior phrasing got better tables but Claude still dropped aliases on every builder number tile across three dashboards (P50/P95/P99 latency, Server Requests, Failed Spans, Log Errors). Concrete shape next to the text is what makes it stick. - Rule 10 (containers) now ships a copy-pasteable containers/tabs block. The prior REQUIRED phrasing did not bite at all; Claude built five dashboards with 9-10 tiles each and zero containers. The model reads "REQUIRED" as a soft hint when the next concrete shape is two files away in the dashboard_examples prompt. Inline the shape and the tile -> containerId wiring directly in the rule. WORKFLOW changes: - Now six steps. Step 2 says "list existing dashboards with hyperdx_get_dashboard, fetch one or two, learn local idioms before designing", which Claude's reflection asked for. Step 4 says "sketch tiles, THEN group them into 2-4 containers before assembling the save payload" so the container decision happens at the same level as picking source kind, not buried in a checklist. QUERY GUIDE changes: - LUCENE FILTER SYNTAX gotcha broadened from "comparison and wildcard on dotted map-attribute paths" to "ALL Lucene operations on map-attribute paths". Claude saw db.system:mongodb (simple equality) also fail to translate to SpanAttributes['db.system'] on this round. The previous phrasing said "simple equality on a dotted path is fine"; that was wrong. The corrected guidance is: use SQL with bracket access for any operation on map-attribute keys. - Added a FUZZY-MATCH NOTE alongside the gotcha: Lucene field:value on top-level string columns translates to ilike(field, '%value%'), not equality. That is fine for free-text columns but surprising for enum-like ones (SpanKind, SeverityText, StatusCode). Claude hit this on SpanKind:Server matching broader strings than intended. The canonical fix is whereLanguage: "sql" with =. - COMMON MISTAKES rule 14 rewritten to cover any-operation, with db.system:mongodb as the equality example. - Rule 15 added for the fuzzy-substring trap on top-level enum-like columns. - Rule 16 added for the empty-string trap on map-attribute groupBy: ClickHouse map columns return '' for unset keys, so groupBy on a sparse attribute produces an empty bucket alongside real values. Workaround: where: "SpanAttributes[''] != ''" alongside the groupBy. Comes from Claude's MongoDB dashboard work where the empty collection-name bucket was the loudest visual noise. - Renumbered the onClick mistakes from 15-18 to 17-20 to make room. Tests extended to assert: workflow has six numbered steps and names the read-existing and group-into-containers steps explicitly; rule 2 carries the Server Requests example; rule 10 carries the kpis/trends/ errors shape; LUCENE FILTER SYNTAX mentions both wildcard and equality gotchas plus the FUZZY-MATCH NOTE; COMMON MISTAKES has dedicated rules for the map-attr-any-operation gotcha, the enum-like fuzzy-match trap, and the empty-string map-attr trap. All 6 example patterns still save and query cleanly end-to-end (48 tiles checked through hyperdx_query_tile). --- .../api/src/mcp/__tests__/dashboards.test.ts | 99 ++++++++++++++++--- .../api/src/mcp/prompts/dashboards/content.ts | 85 +++++++++++----- 2 files changed, 150 insertions(+), 34 deletions(-) diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index 963779b348..dc0149efd7 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -2146,29 +2146,64 @@ describe('MCP Dashboard Tools', () => { expect(section).toMatch(/number tiles too/); }); - it('documents the Lucene comparison/wildcard gotcha on map attributes', () => { - // Lucene parses http.status_code:>=500 happily but the translator - // doesn't map dotted paths to bracket access when an operator is - // in play. The tile saves, query_tile errors, and the dashboard - // shows "Error loading chart" with no explanation. + it('documents the Lucene map-attribute gotcha across all operators including simple equality', () => { + // The original rule covered only comparison/wildcard. Claude's + // second pass showed simple equality (db.system:mongodb) also + // fails to translate to SpanAttributes['db.system']. Broaden the + // gotcha to "ALL Lucene operations on map-attribute paths". const prompt = buildQueryGuidePrompt(); const luceneIdx = prompt.indexOf('== LUCENE FILTER SYNTAX =='); const luceneBody = prompt.slice(luceneIdx); - // The gotcha block is in the Lucene section. - expect(luceneBody).toMatch( - /Lucene comparison operators .* fail silently/, - ); - expect(luceneBody).toMatch(/http\.status_code:>=500/); - expect(luceneBody).toMatch(/switch the where to SQL with bracket access/); + expect(luceneBody).toMatch(/NOT reliably translated to bracket access/); + expect(luceneBody).toMatch(/http\.status_code:>=500/); // operator + expect(luceneBody).toMatch(/http\.route:\*/); // wildcard + expect(luceneBody).toMatch(/db\.system:mongodb/); // simple equality + expect(luceneBody).toMatch(/use SQL with bracket access/); // Also a top-level entry in COMMON MISTAKES for discoverability. const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); const mistakesBody = prompt.slice(mistakesIdx); expect(mistakesBody).toMatch( - /Lucene comparison or wildcard on a dotted map-attribute path/, + /Lucene on a map-attribute path \(any operation\)/, ); }); + it('documents the Lucene fuzzy-substring behavior on top-level columns', () => { + // Lucene field:value translates to ilike(field, '%value%'), not + // equality. Claude's second pass hit this on SpanKind:Server, + // which matched broader strings than intended. The note has to + // be in the Lucene section so a model reading top-down sees it + // before writing a SpanKind filter. + const prompt = buildQueryGuidePrompt(); + const luceneIdx = prompt.indexOf('== LUCENE FILTER SYNTAX =='); + const luceneBody = prompt.slice(luceneIdx); + expect(luceneBody).toMatch(/FUZZY-MATCH NOTE/); + expect(luceneBody).toMatch(/ilike\(field, '%value%'\)/); + // The canonical fix is SQL with =. + expect(luceneBody).toMatch(/SpanKind = 'Server'/); + + // And a COMMON MISTAKES entry covering enum-like columns. + const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); + const mistakesBody = prompt.slice(mistakesIdx); + expect(mistakesBody).toMatch( + /Lucene field:value on enum-like top-level columns/, + ); + }); + + it('documents the empty-string trap for map-attribute groupBy', () => { + // Map columns return '' (not NULL) when a key is unset on a row. + // A groupBy on such a key produces an empty-string bucket alongside + // the real values, which is visual noise on tables and pies. The + // standard fix is where: " != ''" alongside the groupBy. + const prompt = buildQueryGuidePrompt(); + const mistakesIdx = prompt.indexOf('== COMMON MISTAKES =='); + const mistakesBody = prompt.slice(mistakesIdx); + expect(mistakesBody).toMatch( + /Forgetting that map-attribute values are often empty strings/, + ); + expect(mistakesBody).toMatch(/db\.collection\.name'\] != ''/); + }); + it('documents the groupBy alias workaround for map-attribute drill-downs', () => { // Builder tiles don't expose a groupBy alias today; map-attribute // groupBys produce result columns named like the raw expression, @@ -2306,6 +2341,46 @@ describe('MCP Dashboard Tools', () => { // habit Claude landed on every starter dashboard. expect(checklistBody).toMatch(/VALIDATE EVERY TILE AFTER SAVE/); expect(checklistBody).toMatch(/NO TITLE-RECAP MARKDOWN TILE/); + // Rule 2 now carries a concrete number-tile example. Claude's + // second pass still dropped aliases on every builder number tile, + // because the text rule was abstract; a copy-pasteable shape next + // to the rule is what makes it stick. + expect(checklistBody).toMatch( + /Number tile, correct:.*alias: "Server Requests"/, + ); + // Rule 10 now carries a concrete containers shape. Claude's + // second pass built five dashboards with 9-10 tiles each and zero + // containers despite the REQUIRED phrasing; a copy-pasteable shape + // inside the rule itself is the next-level push. + expect(checklistBody).toMatch(/Concrete shape \(copy this directly/); + expect(checklistBody).toMatch(/id: "kpis"/); + expect(checklistBody).toMatch(/id: "trends"/); + expect(checklistBody).toMatch(/id: "errors"/); + }); + + it('walks the workflow through six steps including read-existing and group-into-containers', () => { + // The workflow is the first thing a model anchors on. Claude + // skipped both "read existing dashboards before designing" and + // "group tiles into containers before saving" because neither was + // a numbered workflow step; both lived only in the checklist. The + // workflow now lists six steps with those two explicitly. + const prompt = buildCreateDashboardPrompt( + 'summary', + 'trace_src', + 'log_src', + ); + const wfIdx = prompt.indexOf('== WORKFLOW =='); + const wfEnd = prompt.indexOf('\n== ', wfIdx + 1); + const wf = prompt.slice(wfIdx, wfEnd); + for (let i = 1; i <= 6; i++) { + expect(wf).toMatch(new RegExp(`^${i}\\. `, 'm')); + } + // Step 2 must point at hyperdx_get_dashboard with no id. + expect(wf).toMatch(/hyperdx_get_dashboard \(no id\)/); + // Step 4 must call out grouping tiles into containers before save. + expect(wf).toMatch(/group them into 2-4 containers/); + // Step 6 must call out EVERY tile, not just one. + expect(wf).toMatch(/EVERY tile \(not just one\)/); }); it('warns explicitly against title-recap markdown tiles in the tile type guide', () => { diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index de969b55f1..e299bce981 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -22,10 +22,11 @@ IMPORTANT: Call hyperdx_list_sources first to get the full column schema and att == WORKFLOW == 1. Call hyperdx_list_sources to discover source IDs, column schemas, and attribute keys. -2. Pick the right source kind for the question. Trace data for request volume / latency / errors. Log data for severity / messages. Metric data for system telemetry. -3. Design the dashboard by following the design checklist below. -4. Call hyperdx_save_dashboard with the tiles, containers, and dashboard-level filters. -5. Call hyperdx_query_tile on each tile to confirm queries return data. +2. Call hyperdx_get_dashboard (no id) to list existing dashboards. If any exist, fetch one or two with their id and skim them to pick up local idioms (naming style, time ranges, common groupings) before adding new ones. Skip when the workspace is empty. +3. Pick the right source kind for the question. Trace data for request volume / latency / errors. Log data for severity / messages. Metric data for system telemetry. +4. Sketch tiles, THEN group them into 2-4 containers (Overview / Trends / Errors is a sane default for a service-health pattern) before assembling the save payload. Ungrouped tiles render as a flat sprawl that fails the readability test at five or more tiles. +5. Call hyperdx_save_dashboard with the tiles, containers, and dashboard-level filters. +6. Call hyperdx_query_tile on EVERY tile (not just one) to confirm queries return data and the dashboard is not silently degraded. == UPDATING AN EXISTING DASHBOARD == @@ -82,7 +83,11 @@ Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the 1. ONE QUESTION PER DASHBOARD. A dashboard answers a single observability question well. Split if the request mixes traces, logs, and metrics into unrelated views. -2. ALIAS EVERY SELECT ITEM. Every entry in select MUST carry an alias. Tables, lines, stacked_bars, pies, AND number tiles. Without alias, the result column is named like the raw expression ("quantile(0.95)(Duration)"), which reads ugly in headers and breaks any downstream onClick template that references a column name. Number tiles look fine in the UI without an alias, but the underlying query still emits a raw-expression column name; the alias is required for export, for orderBy, for consistency, and because the renderer can flip behavior on this. Treat "no alias" as a save-time bug. +2. ALIAS EVERY SELECT ITEM. Every entry in select MUST carry an alias. Tables, lines, stacked_bars, pies, AND number tiles. The number-tile case is the one most often missed because the rendered UI uses the tile's name (not the column alias), so the absence looks invisible; the underlying query still emits a raw-expression column name (count(), quantile(0.95)(Duration)), which breaks orderBy references, CSV export, and any downstream onClick template that references the column by name. Treat "no alias" as a save-time bug, not a style nit. Heatmap is the only exception: heatmap select items take a valueExpression and no alias. + + Number tile, correct: select: [{ aggFn: "count", alias: "Server Requests" }] + Number tile, wrong: select: [{ aggFn: "count" }] + Quantile (number/table): select: [{ aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95 Duration" }] 3. INVENTORY-STYLE TABLES PUT THE GROUP BY ON THE LEFT. Set groupByColumnsOnLeft: true on tables that read like a list of things (one row per service, per endpoint, per tenant). @@ -98,7 +103,20 @@ Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the 9. UPDATE IS REPLACE, NOT MERGE. hyperdx_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call hyperdx_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted. -10. GROUP RELATED TILES INTO CONTAINERS. A container holds tiles that share a theme (Overview / Performance / Errors). Set containers: [{ id, title, collapsed }] at the top level and reference container ids from each tile via containerId. Use tabs when the same container needs to show different views of the same data; the tab bar appears when a container has two or more tabs. REQUIRED at five or more tiles. An ungrouped wall of tiles is a readability failure; even three named containers ("KPIs", "Trends", "Errors") turn a flat dashboard into a scannable one. Containers are the right way to introduce structure; markdown tiles for section labels are not. +10. GROUP RELATED TILES INTO CONTAINERS. REQUIRED at five or more tiles, no exceptions. An ungrouped wall of nine or ten tiles is a readability failure even when each tile is correct in isolation. Containers are the right way to introduce structure; markdown tiles for section labels are not. + + Concrete shape (copy this directly into the save payload): + containers: [ + { id: "kpis", title: "KPIs", collapsed: false }, + { id: "trends", title: "Trends", collapsed: false }, + { id: "errors", title: "Errors", collapsed: false } + ] + Reference container ids from each tile: + tiles: [ + { name: "Server Requests", containerId: "kpis", config: { displayType: "number", select: [{ aggFn: "count", alias: "Server Requests" }], ... } }, + { name: "Latency p95", containerId: "trends", config: { displayType: "line", select: [{ aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95" }], ... } } + ] + Use tabs when one container needs to show different views of the same data (Throughput / Latency / Errors over time, for instance); the tab bar appears when a container has two or more tabs declared. 11. VALIDATE EVERY TILE AFTER SAVE. After hyperdx_save_dashboard, call hyperdx_query_tile on EVERY tile (not just one). Save validates input shape; it does NOT validate query semantics. Some queries pass save and fail at render time (known gaps: Lucene comparison/wildcard on map attributes, metric tiles with multiple metricTables, malformed having clauses). If query_tile returns an error, fix the tile and re-save before declaring the dashboard ready. @@ -805,20 +823,32 @@ Used in the "where" field of select items and search tiles. NOTE: In Lucene filters, use dot notation for attribute keys (service.name, http.method). This is different from valueExpression and groupBy which require bracket syntax (SpanAttributes['http.method']). -GOTCHA: Lucene comparison operators (>=, >, <=, <) and trailing wildcards (*) on dotted attribute paths parse and save without error but fail silently at query time. The translator treats the dotted path as a literal column rather than mapping it to SpanAttributes['path']. Symptoms: a tile saves fine, hyperdx_query_tile returns an error, and the dashboard renders with "Error loading chart" on that tile. +GOTCHA: Lucene queries that reference map-attribute keys via dotted paths (http.status_code, db.system, deployment.environment, anything under SpanAttributes / ResourceAttributes / LogAttributes) are NOT reliably translated to bracket access. Operators (>=, >, <=, <), wildcards (*), AND simple equality all hit translator gaps depending on the column. Symptoms: tile saves fine; hyperdx_query_tile returns an error or returns zero rows when there should be matches; the dashboard renders "Error loading chart" on the tile. Do NOT trust dotted-path Lucene; use SQL with bracket access for ALL map-attribute filtering. + + Unreliable in Lucene (any operation, any operator): + http.status_code:>=500 (operator) + http.route:* (wildcard) + db.system:mongodb (simple equality; the translator does NOT map this to SpanAttributes['db.system']) - Breaks: http.status_code:>=500 - Breaks: http.route:* - Works: http.status_code:200 (simple equality on a dotted path is fine) - Works: Duration:>1000000000 (top-level numeric column with range) + Reliable in Lucene (top-level columns only): + Duration:>1000000000 (top-level numeric range) + ServiceName:checkout (top-level string, see fuzzy-match note below for what this actually means) -Workaround: switch the where to SQL with bracket access. Map column values are stored as strings, so quote the comparison value. +Workaround: switch to SQL with bracket access. Map values are stored as strings, so quote the comparison value. whereLanguage: "sql" where: "SpanAttributes['http.status_code'] >= '500'" whereLanguage: "sql" - where: "SpanAttributes['http.route'] != ''" + where: "SpanAttributes['db.system'] = 'mongodb'" + + whereLanguage: "sql" + where: "SpanAttributes['http.route'] != ''" (filters out empty keys; map values are often empty) + +FUZZY-MATCH NOTE: Lucene field:value on a top-level string column translates to ilike(field, '%value%'), not exact equality. That is fine for free-text columns (Body, StatusMessage) but surprising for enum-like columns (SpanKind, SeverityText, StatusCode). SpanKind:Server matches "Server" AND any other string containing "Server" as a substring. For exact-match semantics on top-level columns, use SQL: + + whereLanguage: "sql" + where: "SpanKind = 'Server'" == SQL FILTER SYNTAX == @@ -1189,31 +1219,42 @@ Example: find top patterns for production services over the last 4 hours: Correct: select: [{ aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95" }] Every select entry MUST carry an alias. This applies to number tiles too, not just tables. Without alias the result column is named after the raw expression, which breaks orderBy references, breaks onClick template lookups, and shows ugly headers on tables. Number tiles render the value with the tile name in the UI, so the missing alias is invisible there, but the underlying query still emits a raw-expression column name. Treat "no alias" as a save-time bug, not a style nit. -14. Lucene comparison or wildcard on a dotted map-attribute path - Wrong: where: "http.status_code:>=500" (whereLanguage: "lucene") - Wrong: where: "http.route:*" (whereLanguage: "lucene") +14. Lucene on a map-attribute path (any operation) + Wrong: where: "http.status_code:>=500" (operator) + Wrong: where: "http.route:*" (wildcard) + Wrong: where: "db.system:mongodb" (equality; translator does NOT map to SpanAttributes['db.system']) Correct: where: "SpanAttributes['http.status_code'] >= '500'" (whereLanguage: "sql") - Correct: where: "SpanAttributes['http.route'] != ''" (whereLanguage: "sql") - Lucene parses these patterns happily at save time but the translator does not map http.status_code to SpanAttributes['http.status_code'] when an operator or wildcard is in play. The tile saves clean and fails at render with "Error loading chart". For comparison (>=, >, <=, <) and wildcard (*) on map-attribute paths, switch to SQL with bracket access. Map values are stored as strings, so quote the comparison value. + Correct: where: "SpanAttributes['db.system'] = 'mongodb'" (whereLanguage: "sql") + Lucene parses dotted-path queries happily at save time but the translator does not reliably map them to bracket access. Behavior varies by column and operator. Treat any Lucene filter on a map-attribute key as a save-time bug and use SQL with bracket access for ALL map-attribute filtering. Map values are stored as strings; quote the comparison value. + +15. Lucene field:value on enum-like top-level columns (fuzzy substring match, not equality) + Wrong: where: "SpanKind:Server" (whereLanguage: "lucene"; translates to ilike(SpanKind, '%Server%') and matches "Server_Client", "Internal_Server_Error", etc.) + Correct: where: "SpanKind = 'Server'" (whereLanguage: "sql") + Lucene field:value is a substring match (ilike with surrounding wildcards), not exact equality. Fine for Body and StatusMessage; wrong for enum-like columns where exact match matters (SpanKind, SeverityText, StatusCode). When the column has discrete known values, use SQL with =. + +16. Forgetting that map-attribute values are often empty strings + Wrong: groupBy on SpanAttributes['db.collection.name'] without filtering empty values; the result has an empty-string bucket alongside the real ones, which renders as a blank row in tables and an unlabelled slice in pies. + Correct: add where: "SpanAttributes['db.collection.name'] != ''" alongside the groupBy. + ClickHouse map columns return '' (not NULL) for keys that were not set on a given row. Filter them out whenever you groupBy a map-attribute key that is not guaranteed populated on every event. -15. onClick on a non-table tile +17. onClick on a non-table tile Only table tiles (builder displayType: "table" and raw-SQL configType: "sql" with displayType: "table") support config.onClick. Other displayTypes ignore the field. Putting onClick on a line/number/pie/heatmap/search/markdown tile won't error but won't do anything either. -16. onClick targeting a non-log/trace source for type="search" +18. onClick targeting a non-log/trace source for type="search" The /search page only renders log and trace sources. Targeting a metric or session source is rejected at save time with: "The following onClick search source IDs are not log or trace sources: ..." -17. Missing whereLanguage on onClick +19. Missing whereLanguage on onClick whereLanguage is technically optional, but should still be set to "lucene" or "sql" so the destination knows how to parse rendered whereTemplate / filter values. Leaving it unset can cause the destination to fall back to its own default. -18. Templating against a column that isn't in the table's select/groupBy +20. Templating against a column that isn't in the table's select/groupBy Templates like "{{ServiceName}}" pull from the clicked row's columns. If the column doesn't appear in the table query, the template renders as empty at click time and the destination opens unfiltered. Match From 2480aa6ebb84e527197f4461649be22b97f00e22 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 14 May 2026 16:24:01 +0000 Subject: [PATCH 08/12] feat(mcp): name the groupBy alias gap in the design checklist Adds a new rule 3 GROUP BY HAS NO ALIAS HOOK that calls out the schema limit on the v2 chart config: groupBy is a single expression string, so the renderer uses it verbatim as the table column header and as the raw column name in CSV export, tooltips, orderBy, and onClick template references. A table grouped by SpanAttributes['http.route'] renders arrayElement(SpanAttributes, 'http.route') as the header. The rule pushes the model toward a top-level column when one carries the same semantic (SpanName / ServiceName / SeverityText) and falls back to a raw SQL tile with AS alias when only a Map attribute is available. Second-pass dashboards consistently produced ugly Map-expression headers on App-Performance tables even though every alias rule on the select side was already in the checklist; the gap was the missing acknowledgement that groupBy has no alias hook. Also fixes a pre-existing assertion in dashboards.test.ts left over from the rewrite: the test expected the phrase "not currently exposed via the MCP" which is no longer in the PER-TILE TYPE CONSTRAINTS section. Anchors on the surviving "Authoring builder tiles on a metric source is not reliable" and "MCP select-item shape does not carry" phrasing instead. --- .changeset/mcp-dashboard-prompts-overhaul.md | 2 +- .../api/src/mcp/__tests__/dashboards.test.ts | 28 +++++++++++++++---- .../api/src/mcp/prompts/dashboards/content.ts | 22 ++++++++------- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/.changeset/mcp-dashboard-prompts-overhaul.md b/.changeset/mcp-dashboard-prompts-overhaul.md index 7987b95874..253f4dbef0 100644 --- a/.changeset/mcp-dashboard-prompts-overhaul.md +++ b/.changeset/mcp-dashboard-prompts-overhaul.md @@ -4,7 +4,7 @@ feat(mcp): rewrite dashboard authoring prompts and expose `filters` on `hyperdx_save_dashboard` -The `create_dashboard` prompt now leads with a ten-rule design checklist (RED columns with aliases, per-series `numberFormat` for durations, `groupByColumnsOnLeft` for inventory tables, dashboard-level filters instead of per-tile `where` literals, one-metric-per-tile for metric sources, containers and tabs for grouping). The wall-of-JSON canonical example is gone; the four `dashboard_examples` patterns carry the concrete shapes. +The `create_dashboard` prompt now leads with a design checklist (alias every select item including number tiles, schema gap on `groupBy` so tables don't render `arrayElement(SpanAttributes, '...')` as the column header, RED columns with aliases, per-series `numberFormat` for durations, `groupByColumnsOnLeft` for inventory tables, dashboard-level filters instead of per-tile `where` literals, one-metric-per-tile for metric sources, required containers at five or more tiles, post-save validation of every tile, no title-recap markdown). The wall-of-JSON canonical example is gone; the `dashboard_examples` patterns carry the concrete shapes. The `dashboard_examples` set is replaced with four verified patterns (`service_inventory`, `service_detail`, `log_analytics`, `backend_dependencies`) plus the existing `infrastructure_sql`. Each non-SQL example leads with a "When to use" header and a "Why this shape" note so the model picks by intent, not by surface keyword match. Examples were built and rendered on a live dev stack before landing. diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index dc0149efd7..9590cb1e70 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -2091,7 +2091,14 @@ describe('MCP Dashboard Tools', () => { expect(prompt).not.toMatch(/exactly 1 select item with metricName/); const constraintsIdx = prompt.indexOf('== PER-TILE TYPE CONSTRAINTS =='); const constraintsBody = prompt.slice(constraintsIdx); - expect(constraintsBody).toMatch(/not currently exposed via the MCP/); + // The constraints section closes with an explicit note that builder + // tiles on a metric source are not reliable today, with a fallback + // recipe to raw SQL. Anchor on the phrase so a future diff that + // drops the gap-acknowledgement fails loudly. + expect(constraintsBody).toMatch( + /Authoring builder tiles on a metric source is not reliable/, + ); + expect(constraintsBody).toMatch(/MCP select-item shape does not carry/); }); it('documents table-tile onClick linking features', () => { @@ -2316,11 +2323,13 @@ describe('MCP Dashboard Tools', () => { const checklistIdx = prompt.indexOf('== DESIGN CHECKLIST =='); const adaptIdx = prompt.indexOf('== ADAPT, DO NOT COPY =='); const checklistBody = prompt.slice(checklistIdx, adaptIdx); - // Twelve rules: the original ten plus VALIDATE EVERY TILE AFTER SAVE - // (rule 11) and NO TITLE-RECAP MARKDOWN TILE (rule 12). Both came out - // of the May verification pass after watching Claude reliably ignore - // the soft "should" formulations. - for (let i = 1; i <= 12; i++) { + // Thirteen rules: the original ten plus GROUP BY HAS NO ALIAS HOOK + // (rule 3), VALIDATE EVERY TILE AFTER SAVE (rule 12), and NO + // TITLE-RECAP MARKDOWN TILE (rule 13). Each came out of a live + // verification pass after watching Claude reliably ignore the soft + // "should" formulations or hit a schema gap the earlier checklist + // did not call out. + for (let i = 1; i <= 13; i++) { expect(checklistBody).toMatch(new RegExp(`^${i}\\. `, 'm')); } expect(prompt).toContain('ADAPT, DO NOT COPY'); @@ -2333,6 +2342,13 @@ describe('MCP Dashboard Tools', () => { // pass at this dropped aliases on number tiles because it read rule 2 // as table-specific. The stronger phrasing pulls them in. expect(checklistBody).toMatch(/ALIAS EVERY SELECT ITEM/); + // Rule 3 calls out the schema gap on groupBy: the chart config takes + // a single expression string with no alias field, so a table grouped + // by SpanAttributes['http.route'] renders arrayElement(...) as the + // column header. Naming the limit explicitly lets Claude pick a + // top-level column (SpanName, ServiceName) instead of grouping on + // the raw Map expression. + expect(checklistBody).toMatch(/GROUP BY HAS NO ALIAS HOOK/); // Rule 10 must be a hard requirement at 5+ tiles, not a soft hint. // Without the imperative, Claude built five dashboards averaging ten // tiles each with zero containers. diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index e299bce981..c9849bf869 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -89,21 +89,23 @@ Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the Number tile, wrong: select: [{ aggFn: "count" }] Quantile (number/table): select: [{ aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95 Duration" }] -3. INVENTORY-STYLE TABLES PUT THE GROUP BY ON THE LEFT. Set groupByColumnsOnLeft: true on tables that read like a list of things (one row per service, per endpoint, per tenant). +3. GROUP BY HAS NO ALIAS HOOK. The chart config's groupBy is a single expression string and the renderer uses it verbatim as the column header (tables) and as the raw column name everywhere else (CSV export, tooltips, orderBy references, onClick template references). Grouping a table by SpanAttributes['http.route'] produces a column header that reads literally as arrayElement(SpanAttributes, 'http.route'). When a top-level column carries the same semantic, prefer it: SpanName for operation, ServiceName for service, SeverityText for log severity. When the dimension exists only as a Map attribute and the column header matters, drop to a raw SQL tile (configType: "sql") and write the column with AS alias. Line, stacked_bar, and pie tiles legend by the value not the column name, so the issue is invisible on the chart itself but still bites CSV export and onClick template references. -4. RED COLUMNS FOR SERVICE / ENDPOINT TABLES. Request rate (count), Error rate (count with where: "StatusCode:STATUS_CODE_ERROR"), Duration (quantile P95 on the Duration column). Three columns, all aliased. +4. INVENTORY-STYLE TABLES PUT THE GROUP BY ON THE LEFT. Set groupByColumnsOnLeft: true on tables that read like a list of things (one row per service, per endpoint, per tenant). -5. PER-SERIES NUMBER FORMAT FOR DURATIONS. Put numberFormat on each select item that needs special rendering. Example: { output: "duration", factor: 0.000000001 } on a Duration percentile. NEVER set chart-level numberFormat for tables that mix counts and durations; the count columns will render as 0:00:00. +5. RED COLUMNS FOR SERVICE / ENDPOINT TABLES. Request rate (count), Error rate (count with where: "StatusCode:STATUS_CODE_ERROR"), Duration (quantile P95 on the Duration column). Three columns, all aliased. -6. LINE CHARTS CAN PASS UP TO 20 SELECT ITEMS. Plot related metrics together (p50, p95, p99 on one chart). Each series can carry its own numberFormat. +6. PER-SERIES NUMBER FORMAT FOR DURATIONS. Put numberFormat on each select item that needs special rendering. Example: { output: "duration", factor: 0.000000001 } on a Duration percentile. NEVER set chart-level numberFormat for tables that mix counts and durations; the count columns will render as 0:00:00. -7. HEATMAPS FOR DISTRIBUTIONS. Trace duration buckets, payload size buckets. Trace sources only. Set numberFormat: { output: "duration", factor: 0.000000001 } on the chart config so the y-axis reads in human time. +7. LINE CHARTS CAN PASS UP TO 20 SELECT ITEMS. Plot related metrics together (p50, p95, p99 on one chart). Each series can carry its own numberFormat. -8. FOR FOCUSED PER-DIMENSION DASHBOARDS, DECLARE A DASHBOARD-LEVEL FILTER. Pass filters: [{ type: "QUERY_EXPRESSION", name, expression, sourceId }] at the top level. The user gets a dropdown in the dashboard header; every tile on the same source re-scopes when a value is picked. Do NOT hardcode the dimension into each tile's where clause. +8. HEATMAPS FOR DISTRIBUTIONS. Trace duration buckets, payload size buckets. Trace sources only. Set numberFormat: { output: "duration", factor: 0.000000001 } on the chart config so the y-axis reads in human time. -9. UPDATE IS REPLACE, NOT MERGE. hyperdx_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call hyperdx_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted. +9. FOR FOCUSED PER-DIMENSION DASHBOARDS, DECLARE A DASHBOARD-LEVEL FILTER. Pass filters: [{ type: "QUERY_EXPRESSION", name, expression, sourceId }] at the top level. The user gets a dropdown in the dashboard header; every tile on the same source re-scopes when a value is picked. Do NOT hardcode the dimension into each tile's where clause. -10. GROUP RELATED TILES INTO CONTAINERS. REQUIRED at five or more tiles, no exceptions. An ungrouped wall of nine or ten tiles is a readability failure even when each tile is correct in isolation. Containers are the right way to introduce structure; markdown tiles for section labels are not. +10. UPDATE IS REPLACE, NOT MERGE. hyperdx_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call hyperdx_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted. + +11. GROUP RELATED TILES INTO CONTAINERS. REQUIRED at five or more tiles, no exceptions. An ungrouped wall of nine or ten tiles is a readability failure even when each tile is correct in isolation. Containers are the right way to introduce structure; markdown tiles for section labels are not. Concrete shape (copy this directly into the save payload): containers: [ @@ -118,9 +120,9 @@ Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the ] Use tabs when one container needs to show different views of the same data (Throughput / Latency / Errors over time, for instance); the tab bar appears when a container has two or more tabs declared. -11. VALIDATE EVERY TILE AFTER SAVE. After hyperdx_save_dashboard, call hyperdx_query_tile on EVERY tile (not just one). Save validates input shape; it does NOT validate query semantics. Some queries pass save and fail at render time (known gaps: Lucene comparison/wildcard on map attributes, metric tiles with multiple metricTables, malformed having clauses). If query_tile returns an error, fix the tile and re-save before declaring the dashboard ready. +12. VALIDATE EVERY TILE AFTER SAVE. After hyperdx_save_dashboard, call hyperdx_query_tile on EVERY tile (not just one). Save validates input shape; it does NOT validate query semantics. Some queries pass save and fail at render time (known gaps: Lucene comparison/wildcard on map attributes, metric tiles with multiple metricTables, malformed having clauses). If query_tile returns an error, fix the tile and re-save before declaring the dashboard ready. -12. NO TITLE-RECAP MARKDOWN TILE. The dashboard's name shows in the title bar. Adding a markdown tile with the dashboard name (or a "About this dashboard" header) doubles the title and eats a row of vertical space because markdown heading styles render at title-bar scale. Skip the markdown tile entirely on starter dashboards. +13. NO TITLE-RECAP MARKDOWN TILE. The dashboard's name shows in the title bar. Adding a markdown tile with the dashboard name (or a "About this dashboard" header) doubles the title and eats a row of vertical space because markdown heading styles render at title-bar scale. Skip the markdown tile entirely on starter dashboards. == ADAPT, DO NOT COPY == From 9444c2c06cc0a8e187da7366ae6a707d6fdcbff9 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 19 May 2026 01:25:33 +0000 Subject: [PATCH 09/12] refactor(mcp): keep main's mcpDashboardFilterSchema design for filters Drew's #2273 shipped `mcpDashboardFilterSchema` + `mcpFiltersParam` with explicit id-management semantics in the description (telling the LLM exactly how to handle filter ids on create vs update). The pre-rebase version of this branch defined `mcpFiltersParam` as `z.array(externalDashboardFilterSchemaWithId.partial({ id: true }))` instead, which loses Drew's id description. Aligning back to main's design keeps the richer guidance the LLM benefits from and avoids overriding a schema another PR shipped upstream four days earlier. The wider `(ExternalDashboardFilter | ExternalDashboardFilterWithId)[]` input type on saveDashboard.ts still ships, so REST/MCP parity is unaffected. `externalDashboardFilterSchemaWithId` is no longer used in schemas.ts; the import is dropped. --- .../api/src/mcp/tools/dashboards/schemas.ts | 102 +++++++++++++----- 1 file changed, 75 insertions(+), 27 deletions(-) diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 97fa9448ab..e97ca99ee0 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -13,10 +13,7 @@ import { } from '@hyperdx/common-utils/dist/types'; import { z } from 'zod'; -import { - externalDashboardFilterSchemaWithId, - externalQuantileLevelSchema, -} from '@/utils/zod'; +import { externalQuantileLevelSchema, objectIdSchema } from '@/utils/zod'; // ─── Shared tile schemas for MCP dashboard tools ───────────────────────────── const mcpNumberFormatSchema = z @@ -632,32 +629,83 @@ export const mcpTilesParam = z '"numberFormat": { "output": "duration", "factor": 0.000000001 } } }', ); -// Dashboard-level filters. Reuses the canonical external API filter schema -// (`externalDashboardFilterSchemaWithId` in `packages/api/src/utils/zod.ts`) -// so the MCP and REST surfaces stay in lockstep, and so the underlying -// `convertExternalFiltersToInternal` helper that saveDashboard already calls -// works without translation. `id` is optional at this layer because the same -// inputSchema serves both create (no id, generated on save) and update -// (preserved id) flows; per-flow strictness is enforced by -// `createDashboardBodySchema` / `updateDashboardBodySchema`. +const mcpDashboardFilterSchema = z + .object({ + id: z + .string() + .optional() + .describe( + 'Filter identity. ' + + 'On UPDATE of an existing dashboard, every filter in the array MUST carry ' + + 'an id: pass the exact id returned by hyperdx_get_dashboard for any filter ' + + 'you are keeping (so saved values bound to it stay attached), and generate ' + + 'a fresh random hex/ObjectId string for any filter you are adding in this ' + + 'update. Omitting `id` on an existing filter would orphan its saved values; ' + + 'reusing an existing id for a new filter would silently overwrite the old ' + + 'one. On CREATE (no top-level `id` on the dashboard call), filter `id` may ' + + 'be omitted and one will be generated server-side.', + ), + type: DashboardFilterType.describe( + 'Filter type. Currently only "QUERY_EXPRESSION" is supported.', + ), + name: z + .string() + .min(1) + .describe( + 'Human-readable filter label shown in the dashboard filter bar dropdown.', + ), + expression: z + .string() + .min(1) + .describe( + 'Column or SQL expression this filter binds to. Example: "ServiceName" ' + + 'or "SpanAttributes[\'http.method\']". ' + + 'IMPORTANT: This is the key that table-tile onClick filters match against ' + + 'when a row click navigates here — an onClick filter whose `expression` is ' + + "not declared in any of this dashboard's filters is silently dropped at click time. " + + 'Declare an expression here for every column you plan to drive via row-click.', + ), + sourceId: objectIdSchema.describe( + 'Source the filter values are pulled from (for the dropdown). ' + + 'Get IDs from hyperdx_list_sources.', + ), + sourceMetricType: z + .nativeEnum(MetricsDataType) + .optional() + .describe( + 'Required only when `sourceId` is a Metric source — picks which metric table the ' + + 'dropdown values come from.', + ), + where: z + .string() + .optional() + .describe( + 'Optional WHERE clause scoping the dropdown values (e.g. "level:error" in Lucene).', + ), + whereLanguage: SearchConditionLanguageSchema.describe( + 'Filter language for `where` ("lucene" or "sql"). Optional, but set it explicitly.', + ), + }) + .describe( + 'A dashboard-level filter the user can adjust in the dashboard filter bar. ' + + 'Each filter binds a label/name to a column expression on a source. ' + + "Filters are also the contract for row-click navigation: a table tile's " + + 'onClick.filters[i].expression must match a filter declared here for the value to land.', + ); + export const mcpFiltersParam = z - .array(externalDashboardFilterSchemaWithId.partial({ id: true })) + .array(mcpDashboardFilterSchema) .describe( - 'Dashboard-level filters. Each filter declares a dropdown in the dashboard ' + - 'header that scopes every tile referencing the same source. Use this for ' + - 'focused per-dimension dashboards (per-service, per-tenant, per-endpoint) ' + - "instead of hardcoding the dimension into every tile's where clause.\n\n" + - 'Filter shape: { type, name, expression, sourceId, where?, whereLanguage? }.\n' + - '- type: "QUERY_EXPRESSION" (the only currently supported type).\n' + - '- name: human label shown in the filter dropdown (e.g. "Service").\n' + - '- expression: column or attribute path the filter scopes (e.g. "ServiceName" ' + - 'or "SpanAttributes[\'tenant.id\']").\n' + - '- sourceId: which source the expression resolves against. Tiles on a ' + - 'different source are not scoped by the filter.\n' + - '- where / whereLanguage: optional pre-filter that narrows the set of ' + - 'distinct values offered in the dropdown.\n\n' + + 'Optional dashboard-level filters. These define the dropdowns in the dashboard filter ' + + 'bar AND the expressions that table-tile row-click navigation can populate. ' + + 'If another tile\'s onClick targets THIS dashboard with `filters: [{ expression: "X", ... }]`, ' + + 'this array MUST declare a filter whose `expression` is "X" — otherwise the value is ' + + 'dropped on arrival and the destination opens unfiltered.\n\n' + 'Example:\n' + - '[ { "type": "QUERY_EXPRESSION", "name": "Service", "expression": "ServiceName", "sourceId": "" } ]', + '[\n' + + ' { "type": "QUERY_EXPRESSION", "name": "Service", "expression": "ServiceName",\n' + + ' "sourceId": "", "whereLanguage": "sql" }\n' + + ']', ); export const mcpContainersParam = z From ca5c387f548406b1212ace543fb07912eab93906 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 19 May 2026 01:28:02 +0000 Subject: [PATCH 10/12] chore(mcp): drop em-dashes from content.ts prompt strings Five em-dashes (U+2014) in the dashboard authoring prompt content surface to the LLM and to human reviewers. Per project voice rules, replace with colon / period equivalents that read the same to readers and to LLMs. Affected: - filter-id self-describing rule (two occurrences) - pattern-mining query_guide bullet list (where / sampleSize / bodyExpression) --- packages/api/src/mcp/prompts/dashboards/content.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index c9849bf869..e05c3fbd18 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -31,14 +31,14 @@ IMPORTANT: Call hyperdx_list_sources first to get the full column schema and att == UPDATING AN EXISTING DASHBOARD == When updating a dashboard (passing the top-level \`id\` to hyperdx_save_dashboard), -the \`filters\` array must be fully self-describing — every filter needs an \`id\`: +the \`filters\` array must be fully self-describing: every filter needs an \`id\`: - Existing filter you are KEEPING: copy its \`id\` verbatim from the hyperdx_get_dashboard response. The same applies if you are renaming or tweaking it. This preserves any savedFilterValues bound to that id. - New filter you are ADDING in this update: generate a fresh random 24-character hex string (a Mongo-style ObjectId) and set it as \`id\`. - Do not reuse an existing filter's id and do not leave \`id\` blank — + Do not reuse an existing filter's id and do not leave \`id\` blank. omitting it would force the server to generate one and obscure the new filter's identity from your own bookkeeping. - Existing filter you are REMOVING: drop it from the array entirely. @@ -1147,9 +1147,9 @@ Required parameters: - startTime / endTime for the time window Optional parameters: - - where — filter to specific services or severity levels before mining - - sampleSize — increase for more accurate patterns (default 10000, max 25000) - - bodyExpression — column to mine (auto-detected: Body for logs, SpanName for traces) + - where: filter to specific services or severity levels before mining + - sampleSize: increase for more accurate patterns (default 10000, max 25000) + - bodyExpression: column to mine (auto-detected: Body for logs, SpanName for traces) Example: find top patterns for production services over the last 4 hours: hyperdx_event_patterns({ From 3c7a3163c6dcc6dc484dd80b5f2e6f1616f7706b Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 19 May 2026 20:52:10 +0000 Subject: [PATCH 11/12] fix(mcp): restore onClick filter silent-drop warning per deep-review Deep-review (P2 #1, hyperdxio/hyperdx#2264 comment on 2026-05-12): the rewritten mcpOnClickDashboardSchema.filters description dropped the warning that an onClick filter whose expression is not declared on the destination dashboard is dropped at click time. Restore one sentence on the onClick schema description and the matching wording in the dashboards prompt content. Also drop three em-dashes from mcpDashboardFilterSchema descriptions introduced by 9444c2c0; project voice rules ban em-dashes in reviewer-facing prose. --- packages/api/src/mcp/prompts/dashboards/content.ts | 2 +- packages/api/src/mcp/tools/dashboards/schemas.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index e05c3fbd18..14439c9110 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -991,7 +991,7 @@ Templating values come from the clicked row's columns. The column name in {{...} whereTemplate Handlebars-style template. The rendered string is placed in the destination's \`where\` query param. Uses Lucene or SQL syntax matching \`whereLanguage\`. Example: "ServiceName = '{{service.name}}'". - filters Array of { kind: "expressionTemplate", expression, template }. Each filter renders to an IN clause on the destination (\`expression IN ('rendered-value')\`). When multiple filters share an expression they are merged into one IN clause. The destination dashboard auto-populates its filter list (matched by expression), so prefer this shape over whereTemplate when the target dashboard already declares the same filter expressions. + filters Array of { kind: "expressionTemplate", expression, template }. Each filter renders to an IN clause on the destination (\`expression IN ('rendered-value')\`). When multiple filters share an expression they are merged into one IN clause. The destination dashboard auto-populates its filter list (matched by expression), so prefer this shape over whereTemplate when the target dashboard already declares the same filter expressions. If the destination dashboard does not declare a top-level filter whose \`expression\` matches, that value is dropped at click time and the destination opens unfiltered for that expression. whereLanguage is optional, but set it to "sql" or "lucene" so the destination knows how to parse whereTemplate / filter values when a template is rendered. diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index e97ca99ee0..77e00a8d73 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -269,7 +269,10 @@ const mcpOnClickDashboardSchema = z 'Optional list of templated equality filters. The destination dashboard ' + 'auto-populates its filter list with these (matched by expression), so prefer ' + 'this over whereTemplate when the target dashboard already declares the same ' + - 'filter expressions.', + 'filter expressions. ' + + 'If the destination dashboard does not declare a top-level filter whose ' + + '`expression` matches, that value is dropped at click time and the ' + + 'destination opens unfiltered for that expression.', ), }) .describe( @@ -661,7 +664,7 @@ const mcpDashboardFilterSchema = z 'Column or SQL expression this filter binds to. Example: "ServiceName" ' + 'or "SpanAttributes[\'http.method\']". ' + 'IMPORTANT: This is the key that table-tile onClick filters match against ' + - 'when a row click navigates here — an onClick filter whose `expression` is ' + + 'when a row click navigates here. An onClick filter whose `expression` is ' + "not declared in any of this dashboard's filters is silently dropped at click time. " + 'Declare an expression here for every column you plan to drive via row-click.', ), @@ -673,7 +676,7 @@ const mcpDashboardFilterSchema = z .nativeEnum(MetricsDataType) .optional() .describe( - 'Required only when `sourceId` is a Metric source — picks which metric table the ' + + 'Required only when `sourceId` is a Metric source; picks which metric table the ' + 'dropdown values come from.', ), where: z @@ -699,7 +702,7 @@ export const mcpFiltersParam = z 'Optional dashboard-level filters. These define the dropdowns in the dashboard filter ' + 'bar AND the expressions that table-tile row-click navigation can populate. ' + 'If another tile\'s onClick targets THIS dashboard with `filters: [{ expression: "X", ... }]`, ' + - 'this array MUST declare a filter whose `expression` is "X" — otherwise the value is ' + + 'this array MUST declare a filter whose `expression` is "X". Otherwise the value is ' + 'dropped on arrival and the destination opens unfiltered.\n\n' + 'Example:\n' + '[\n' + From b8115f9b798caa09a7172e223012d31a0477df9d Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 20 May 2026 00:12:23 +0000 Subject: [PATCH 12/12] test(mcp): drop two filter tests per review Two of the four filter tests Brandon flagged as redundant on hyperdxio/hyperdx#2264: - The filter-specific missing-source test duplicated the existing tile-level test at line 295. saveDashboard's getMissingSources(sources, tilesWithId, filters) walks both in one pass, so a tile-level miss exercises the same codepath. - The no-filters round-trip was framed as a backward-compat guard, but the create/update suite already saves dashboards without a filters field and implicitly covers the empty-array default. --- .../api/src/mcp/__tests__/dashboards.test.ts | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index 9590cb1e70..f720a039bf 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -1321,43 +1321,6 @@ describe('MCP Dashboard Tools', () => { }); }); - it('should reject a filter whose sourceId does not exist', async () => { - const sourceId = traceSource._id.toString(); - const missingSourceId = '000000000000000000000000'; - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'Bad filter source', - tiles: [traceTile(sourceId)], - filters: [ - { - type: 'QUERY_EXPRESSION', - name: 'Service', - expression: 'ServiceName', - sourceId: missingSourceId, - }, - ], - }); - expect(result.isError).toBe(true); - const text = getFirstText(result); - // Tight assertion: the saveDashboard "missing source IDs" envelope - // includes the literal "Could not find source IDs:" plus the - // unknown id, so a regression that swaps in a different error path - // (e.g. a generic Zod validation reject) would fail loudly. - expect(text).toContain('Could not find source IDs'); - expect(text).toContain(missingSourceId); - }); - - it('should round-trip a dashboard with no filters (backward compat)', async () => { - const sourceId = traceSource._id.toString(); - const result = await callTool(client, 'hyperdx_save_dashboard', { - name: 'No filters', - tiles: [traceTile(sourceId)], - }); - expect(result.isError).toBeFalsy(); - const dashboard = JSON.parse(getFirstText(result)); - expect(Array.isArray(dashboard.filters)).toBe(true); - expect(dashboard.filters).toHaveLength(0); - }); - it('should accept a create payload with stray filter ids (copy-paste round-trip)', async () => { // An LLM that copies a filter out of hyperdx_get_dashboard and // back into hyperdx_save_dashboard for a NEW dashboard ships an