From 7679830b02300a2d58e6b5402b1e84f14a2935cb Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Wed, 13 May 2026 17:26:12 -0400 Subject: [PATCH 1/3] feat: Add dashboard table onClick to MCP schemas and prompts --- .changeset/strange-tigers-relate.md | 5 + .../api/src/mcp/__tests__/dashboards.test.ts | 537 +++++++++++++++++- .../api/src/mcp/prompts/dashboards/content.ts | 289 +++++++++- .../api/src/mcp/prompts/dashboards/index.ts | 4 +- .../src/mcp/tools/dashboards/saveDashboard.ts | 71 ++- .../api/src/mcp/tools/dashboards/schemas.ts | 175 ++++++ 6 files changed, 1073 insertions(+), 8 deletions(-) create mode 100644 .changeset/strange-tigers-relate.md diff --git a/.changeset/strange-tigers-relate.md b/.changeset/strange-tigers-relate.md new file mode 100644 index 0000000000..ae87eb1704 --- /dev/null +++ b/.changeset/strange-tigers-relate.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/api": patch +--- + +feat: Add dashboard table onClick to MCP schemas and prompts diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index 851b67f65e..782837f96b 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -1,5 +1,6 @@ -import { SourceKind } from '@hyperdx/common-utils/dist/types'; +import { MetricsDataType, SourceKind } from '@hyperdx/common-utils/dist/types'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import mongoose from 'mongoose'; import * as config from '@/config'; import { @@ -13,7 +14,11 @@ import Dashboard from '@/models/dashboard'; import { Source } from '@/models/source'; import type { ExternalDashboardTileWithId } from '@/utils/zod'; -import { buildQueryGuidePrompt } from '../prompts/dashboards/content'; +import { + buildCreateDashboardPrompt, + buildDashboardExamplesPrompt, + buildQueryGuidePrompt, +} from '../prompts/dashboards/content'; import { McpContext } from '../tools/types'; import { callTool, createTestClient, getFirstText } from './mcpTestUtils'; @@ -1208,6 +1213,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({ @@ -1356,5 +1788,106 @@ describe('MCP Dashboard Tools', () => { expect(body.toLowerCase()).toContain('heatmap'); } }); + + 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"); + }); + }); + + 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. + const prompt = buildCreateDashboardPrompt( + 'summary', + 'trace_src', + 'log_src', + ); + 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'); + }); + + 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'); + }); + }); + + describe('buildDashboardExamplesPrompt', () => { + it('exposes a drilldown_links example pattern', () => { + const fullPrompt = buildDashboardExamplesPrompt( + 'trace_src', + 'log_src', + 'conn_id', + ); + expect(fullPrompt).toContain('drilldown_links'); + + // Requesting the specific pattern should return that example. + const patternPrompt = buildDashboardExamplesPrompt( + 'trace_src', + 'log_src', + 'conn_id', + 'drilldown_links', + ); + 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'); + }); }); }); diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index 7c9763cf0f..df93c42262 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -35,7 +35,9 @@ 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 "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. @@ -158,6 +160,92 @@ hyperdx_save_dashboard({ ] }) +== 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. + - Filters for dashboard targets should be used when the expression is + already defined as a filter on the target dashboard, otherwise it will + not be applied. + +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: + "onClick": { + "type": "dashboard", + "target": { "mode": "id", "id": "" }, + "whereLanguage": "sql", + "filters": [ + { + "kind": "expressionTemplate", + "expression": "ServiceName", + "template": "{{ResourceAttributes['service.name']}}" + } + ] + } + == STATUS CODE & SEVERITY VALUES == IMPORTANT: The exact values for StatusCode and SeverityText vary by deployment. @@ -461,6 +549,75 @@ Analyze log volume, severity distribution, and browse log events. ] }`; + examples['drilldown_links'] = ` +== ROW-CLICK DRILL-DOWN LINKS == + +A dashboard whose tables are wired up to drill down into either /search +or a more detailed dashboard. Useful when the dashboard answers +"which service?" and the next click should answer "what's happening +inside that service?". + +Replace with the ID of an existing service-detail +dashboard. Use hyperdx_get_dashboard (no id) to list dashboards and +copy the right ID and discover which filters are available. + +{ + name: "Service Drill-Down", + tags: ["overview", "drilldown"], + tiles: [ + { + name: "Errors by Service (click a row to search traces)", + x: 0, y: 0, w: 12, h: 6, + config: { + displayType: "table", + sourceId: "${traceSourceId}", + groupBy: "ResourceAttributes['service.name']", + select: [ + { aggFn: "count", alias: "Errors", where: "StatusCode:STATUS_CODE_ERROR" }, + { aggFn: "count", alias: "Total" } + ], + onClick: { + type: "search", + target: { mode: "id", id: "${traceSourceId}" }, + whereLanguage: "sql", + filters: [ + { + kind: "expressionTemplate", + expression: "ServiceName", + template: "{{ResourceAttributes['service.name']}}" + } + ] + } + } + }, + { + name: "Top Endpoints (click a row to open service-detail dashboard)", + x: 12, y: 0, w: 12, h: 6, + config: { + displayType: "table", + sourceId: "${traceSourceId}", + groupBy: "ResourceAttributes['service.name']", + select: [ + { aggFn: "count", alias: "Requests" }, + { aggFn: "quantile", level: 0.95, valueExpression: "Duration", alias: "P95" } + ], + onClick: { + type: "dashboard", + target: { mode: "id", id: "" }, + whereLanguage: "sql", + filters: [ + { + kind: "expressionTemplate", + expression: "ServiceName", + template: "{{ResourceAttributes['service.name']}}" + } + ] + } + } + } + ] +}`; + examples['infrastructure_sql'] = ` == INFRASTRUCTURE MONITORING (Raw SQL) == @@ -699,6 +856,110 @@ 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. 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 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: ..." + == CONTAINERS AND TABS == Optional dashboard organization layer. Group related tiles visually with @@ -811,5 +1072,29 @@ containers[i].tabs[j].id). Read the path to know which input to fix. Wrong: select: [{}] Wrong: select: [{ valueExpression: "" }] Correct: select: [{ valueExpression: "Duration" }] - Heatmap requires a non-empty numeric column or expression to bucket.`; + Heatmap requires a non-empty numeric column or expression to bucket. + +11. 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" + 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 + 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 + 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).`; } diff --git a/packages/api/src/mcp/prompts/dashboards/index.ts b/packages/api/src/mcp/prompts/dashboards/index.ts index aec7fa310a..a76fddedcd 100644 --- a/packages/api/src/mcp/prompts/dashboards/index.ts +++ b/packages/api/src/mcp/prompts/dashboards/index.ts @@ -104,13 +104,13 @@ 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, infrastructure_sql.', + 'service_overview, error_tracking, latency, log_analysis, drilldown_links, infrastructure_sql.', argsSchema: { pattern: z .string() .optional() .describe( - 'Filter to a specific pattern: service_overview, error_tracking, latency, log_analysis, infrastructure_sql', + 'Filter to a specific pattern: service_overview, error_tracking, latency, log_analysis, drilldown_links, infrastructure_sql', ), }, }, diff --git a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts index 4bea04ba69..b03fa590ff 100644 --- a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts @@ -16,7 +16,9 @@ import { fetchSourcesForValidation, filterChangedHeatmapTiles, getHeatmapTilesWithIncompatibleSources, + getInvalidOnClickSearchSources, getMissingConnections, + getMissingOnClickDashboards, getMissingSources, resolveSavedQueryLanguage, updateDashboardBodySchema, @@ -151,9 +153,16 @@ async function createDashboard({ // Hoist the source fetch so missing-source and heatmap-source-kind // checks share a single DB round-trip, mirroring the REST POST path. - const [sources, missingConnections] = await Promise.all([ + const [ + sources, + missingConnections, + missingOnClickDashboards, + invalidOnClickSearchSources, + ] = await Promise.all([ fetchSourcesForValidation(teamId), getMissingConnections(teamId, tilesWithId), + getMissingOnClickDashboards(teamId, tilesWithId), + getInvalidOnClickSearchSources(teamId, tilesWithId), ]); const missingSources = getMissingSources(sources, tilesWithId, filters); @@ -199,6 +208,31 @@ async function createDashboard({ }; } + // Validate that a table tile's row-click will not land on a + // missing dashboard or a non-log/trace source. + if (missingOnClickDashboards.length > 0) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join(', ')}`, + }, + ], + }; + } + if (invalidOnClickSearchSources.length > 0) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `The following onClick search source IDs are not log or trace sources: ${invalidOnClickSearchSources.join(', ')}`, + }, + ], + }; + } + const internalTiles = convertExternalTilesToInternal(tilesWithId); const filtersWithIds = convertExternalFiltersToInternal(filters ?? []); @@ -292,9 +326,17 @@ async function updateDashboard({ // the REST PUT path. `containers` is in the projection so the // container/tab ref check can fall back to the persisted containers // when the payload omits them. - const [sources, missingConnections, existingDashboard] = await Promise.all([ + const [ + sources, + missingConnections, + missingOnClickDashboards, + invalidOnClickSearchSources, + existingDashboard, + ] = await Promise.all([ fetchSourcesForValidation(teamId), getMissingConnections(teamId, tilesWithId), + getMissingOnClickDashboards(teamId, tilesWithId), + getInvalidOnClickSearchSources(teamId, tilesWithId), Dashboard.findOne( { _id: dashboardId, team: teamId }, { tiles: 1, filters: 1, containers: 1 }, @@ -374,6 +416,31 @@ async function updateDashboard({ }; } + // Validate that a table tile's row-click will not land on a + // missing dashboard or a non-log/trace source. + if (missingOnClickDashboards.length > 0) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join(', ')}`, + }, + ], + }; + } + if (invalidOnClickSearchSources.length > 0) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `The following onClick search source IDs are not log or trace sources: ${invalidOnClickSearchSources.join(', ')}`, + }, + ], + }; + } + const existingTileIds = new Set( (existingDashboard.tiles ?? []).map((t: { id: string }) => t.id), ); diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 1d52250f63..92174f02c6 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -130,6 +130,179 @@ const mcpTileSelectItemSchema = z } }); +// ─── OnClick (link-out) schemas for table tiles ────────────────────────────── +const mcpOnClickFilterTemplateSchema = z + .object({ + kind: z + .literal('expressionTemplate') + .describe('Literal "expressionTemplate"; only kind supported today.'), + expression: z + .string() + .min(1) + .max(10000) + .describe( + 'Column or SQL expression the rendered template is matched against. ' + + 'Example: "ServiceName" or "SpanAttributes[\'http.route\']". Filters that ' + + 'share an expression are merged into a single IN clause on the destination.', + ), + template: z + .string() + .min(1) + .max(10000) + .describe( + "Handlebars-style template rendered with the clicked row's columns. " + + 'Example: "{{ServiceName}}" pulls the row\'s ServiceName column value. ' + + 'Columns can be referenced by series alias.', + ), + }) + .describe( + 'One templated equality filter applied on the destination page. ' + + 'Use filters to pre-populate the destination search/dashboard with a row-driven ' + + 'IN clause (e.g. "ServiceName IN (\'api\')") rather than dropping the user into ' + + 'an unscoped view.', + ); + +const mcpOnClickTargetSchema = z + .discriminatedUnion('mode', [ + z + .object({ + mode: z.literal('id'), + id: z + .string() + .min(1) + .describe( + '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 ' + + 'page does not render metric/session sources.', + ), + }) + .describe('Link to a specific source or dashboard by its concrete ID.'), + z + .object({ + mode: z.literal('template'), + template: z + .string() + .min(1) + .max(10000) + .describe( + 'Handlebars-style template rendered against the clicked row, then ' + + '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.', + ), + }) + .describe( + 'Link to a source/dashboard whose name comes from a column on the clicked row.', + ), + ]) + .describe( + 'Destination resolver. mode="id" pins a specific source/dashboard; ' + + 'mode="template" picks one at click time from a row column.' + + "Linking by ID is preferred, but using a template is valid when the destination dashboard depends on the clicked row's content", + ); + +const mcpOnClickSearchSchema = z + .object({ + type: z + .literal('search') + .describe('Link to the /search page for a log or trace source.'), + target: mcpOnClickTargetSchema, + whereTemplate: z + .string() + .max(10000) + .optional() + .describe( + 'Optional Handlebars-style WHERE template rendered against the clicked row ' + + "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.', + ), + whereLanguage: SearchConditionLanguageSchema.describe( + 'Filter language for `whereTemplate` and `filters` ("lucene" or "sql"). ' + + 'Optional, but set it explicitly so the destination knows how to parse rendered ' + + 'whereTemplate / filter values.', + ), + filters: z + .array(mcpOnClickFilterTemplateSchema) + .max(50) + .optional() + .describe( + 'Optional list of templated equality filters. Each filter becomes an ' + + "`expression IN ('rendered-value')` clause on the destination; filters that " + + 'share an expression are merged into one IN clause.', + ), + }) + .describe( + 'Row-click handler that opens the /search page. Use this to drill from an ' + + 'aggregated table row down to the underlying log/trace events for that row.', + ); + +const mcpOnClickDashboardSchema = z + .object({ + type: z.literal('dashboard').describe('Link to another HyperDX dashboard.'), + target: mcpOnClickTargetSchema, + whereTemplate: z + .string() + .max(10000) + .optional() + .describe( + 'Optional Handlebars-style WHERE template applied to the destination ' + + "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"). ' + + 'Optional, but set it explicitly so the destination knows how to parse rendered ' + + 'whereTemplate / filter values.', + ), + filters: z + .array(mcpOnClickFilterTemplateSchema) + .max(50) + .optional() + .describe( + '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( + 'Row-click handler that opens another dashboard. Use this to drill from a ' + + 'high-level overview table down to a per-service or per-endpoint dashboard.', + ); + +const mcpOnClickSchema = z + .discriminatedUnion('type', [ + mcpOnClickSearchSchema, + mcpOnClickDashboardSchema, + ]) + .describe( + 'Row-click navigation for tiles that render as tables (Table tiles always; ' + + 'SQL tiles only when displayType is "table"). ' + + 'type="search" links to the /search page for a log/trace source; ' + + 'type="dashboard" links to another dashboard. ' + + 'Both support Handlebars `{{column}}` templating against the clicked row ' + + 'for the target, whereTemplate, and filter values.\n\n' + + 'Examples:\n' + + '1. Drill into search for the clicked service: \n' + + ' { "type": "search", "target": { "mode": "id", "id": "" }, ' + + '"whereLanguage": "sql", ' + + '"filters": [{ "kind": "expressionTemplate", "expression": "ServiceName", ' + + '"template": "{{ServiceName}}" }] }\n' + + '2. Drill into a per-service dashboard: \n' + + ' { "type": "dashboard", "target": { "mode": "id", "id": "" }, ' + + '"whereLanguage": "sql", ' + + '"filters": [{ "kind": "expressionTemplate", "expression": "ServiceName", ' + + '"template": "{{ServiceName}}" }] }\n' + + '3. Resolve the destination from the row (rare; prefer mode="id"): \n' + + ' { "type": "dashboard", "target": { "mode": "template", "template": ' + + '"{{TargetDashboardName}}" }, "whereLanguage": "lucene" }', + ); + const mcpTileLayoutSchema = z.object({ name: z.string().describe('Tile title shown on the dashboard'), x: z @@ -249,6 +422,7 @@ const mcpTableTileSchema = mcpTileLayoutSchema.extend({ 'Render Group By columns on the left side of the table, before the series columns. ' + 'Default false (Group By columns on the right).', ), + onClick: mcpOnClickSchema.optional(), }), }); @@ -405,6 +579,7 @@ const mcpSqlTileSchema = mcpTileLayoutSchema.extend({ ), fillNulls: z.boolean().optional(), alignDateRangeToGranularity: z.boolean().optional(), + onClick: mcpOnClickSchema.optional(), }), }); From 32fbc93223ca14e6c2555e8e4434c80a310a7ffb Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Wed, 13 May 2026 18:01:43 -0400 Subject: [PATCH 2/3] chore: Remove IS_DASHBOARD_LINKING_ENABLED toggle --- packages/app/.env.development | 1 - packages/app/scripts/run-e2e.js | 5 ----- .../OnClickForm/OnClickFormButton.tsx | 6 ------ packages/app/src/components/DBTableChart.tsx | 12 +++++------- packages/app/src/config.ts | 2 -- 5 files changed, 5 insertions(+), 21 deletions(-) diff --git a/packages/app/.env.development b/packages/app/.env.development index a429cc8e83..a673fdd0d9 100644 --- a/packages/app/.env.development +++ b/packages/app/.env.development @@ -8,7 +8,6 @@ PORT=${HYPERDX_APP_PORT} NODE_OPTIONS="--max-http-header-size=131072" NEXT_PUBLIC_HYPERDX_BASE_PATH= NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:${HDX_DEV_OTEL_HTTP_PORT:-4318}" -NEXT_PUBLIC_IS_DASHBOARD_LINKING_ENABLED=true # Per-signal OTLP endpoints (override the base endpoint for individual signals) # NEXT_PUBLIC_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT= # NEXT_PUBLIC_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT= diff --git a/packages/app/scripts/run-e2e.js b/packages/app/scripts/run-e2e.js index ae34ab4806..1fb67bf640 100755 --- a/packages/app/scripts/run-e2e.js +++ b/packages/app/scripts/run-e2e.js @@ -58,11 +58,6 @@ const env = { ...process.env, ...(!useLocal && { E2E_FULLSTACK: 'true' }), ...(useDev && { E2E_USE_DEV: 'true' }), - // Feature flags that should always be enabled during E2E runs. NEXT_PUBLIC_* - // vars are baked into the build, so they must be present in env at `next - // build` time — not just in .env.development (which isn't loaded in prod - // builds). - NEXT_PUBLIC_IS_DASHBOARD_LINKING_ENABLED: 'true', }; // Port configuration from HDX_E2E_* env vars (set by scripts/test-e2e.sh) diff --git a/packages/app/src/components/DBEditTimeChartForm/OnClickForm/OnClickFormButton.tsx b/packages/app/src/components/DBEditTimeChartForm/OnClickForm/OnClickFormButton.tsx index 8734e81493..b11fd8b90b 100644 --- a/packages/app/src/components/DBEditTimeChartForm/OnClickForm/OnClickFormButton.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/OnClickForm/OnClickFormButton.tsx @@ -3,7 +3,6 @@ import { Button } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { ChartEditorFormState } from '@/components/ChartEditor/types'; -import { IS_DASHBOARD_LINKING_ENABLED } from '@/config'; import OnClickDrawer from './OnClickDrawer'; @@ -31,11 +30,6 @@ export function OnClickFormButton({ ? 'Dashboard' : 'Default'; - // TODO: Remove once feature flag is permanently enabled - if (!IS_DASHBOARD_LINKING_ENABLED) { - return null; - } - return ( <>