Skip to content

Commit 81e151c

Browse files
feat(hdx-eval): add dashboard-build eval scenario (#2571)
## Summary Adds a `dashboard-build` eval scenario that tests an agent's ability to create multi-tile observability dashboards via MCP tools. Unlike the existing investigation scenarios (error-root-cause, latency-spike, etc.) which evaluate text answers, this evaluates **created artifacts** — the dashboards themselves are inspected post-run via the API. The scenario is intentionally hard. The agent must handle vague user prompts, distractor services, impossible metric requests, messy severity data, and cross-dashboard drill-downs — all within a 15-turn budget. Current baseline: **75% combined score** (92% programmatic, 78% judge), leaving room for improvement as the MCP tools and prompts evolve. ## What the scenario tests The agent receives a realistic user request for two dashboards ("Service Health Overview" + "Service Detail") and must: - Discover sources and schemas via `list_sources` / `describe_source` - Create dashboards with 18-22 tiles across 8 display types (line, stacked_bar, table, number, heatmap, pie, search, markdown, raw SQL) - Wire cross-dashboard onClick drill-downs with ServiceName filters - Handle data traps: 4 of 7 services are internal infrastructure (not user-facing), debug-proxy has a fake 15% error rate, inventory-service has a misleading latency red herring, SeverityText has mixed casing, and CPU/memory metrics don't exist - Verify tiles return data via `query_tile` - Report what was built and flag data quality caveats ## What's in this PR - **Scenario generator** (`dashboard-build/generate.ts`) — seeds 2M traces + 4M logs across 7 services (3 user-facing + 4 distractors) with deliberate data traps - **Post-run inspection** (`dashboardInspection.ts`) — fetches created dashboards via the v2 API, queries every tile, and formats structured evidence for the LLM judge including heuristic distractor-awareness signals - **Ground truth + rubric** (`ground-truth.json`) — 24 programmatic regex checks and 7 weighted judge criteria - **HyperDX API client extensions** (`api.ts`) — dashboard CRUD, tile querying, and MCP JSON-RPC for post-run inspection - **System prompt** — minimal dashboard-building instructions with DATA REVIEW guidance and conciseness constraint - **Unit tests** — volume targets, determinism, service distribution, error spikes, messy severity, latency red herring Uses the scenario hooks framework merged in PR #2547 (`buildSystemPrompt`, `allowedToolPatterns`, `judgeSystemPreamble`, `postRunInspection`). ## Eval results (75% baseline) | Metric | Value | |---|---| | Combined score | 75% | | Programmatic score | 92% | | Judge mean | 78% | | Tool calls (mean) | 23 | | Tool-error penalty | 8pp | | Duration (mean) | 282s | | Completion rate | 3/3 final_answer | | Judge Criterion | Score | |---|---| | tool_efficiency | 5.0/5 | | cross_dashboard_drill | 4.7/5 | | computed_expressions | 4.0/5 | | structure_and_design | 4.0/5 | | verification | 4.0/5 | | tile_correctness | 3.7/5 | | data_awareness | 2.7/5 | `data_awareness` (2.7/5) is the main gap — the agent partially flags data traps but doesn't consistently scope dashboards to user-facing services or handle all the planted red herrings. This is by design: the scenario is meant to be hard enough that MCP prompt improvements show measurable gains. Co-authored-by: Cursor Agent <199161495+cursoragent@users.noreply.github.com>
1 parent e2f750e commit 81e151c

15 files changed

Lines changed: 2071 additions & 24 deletions

File tree

‎.changeset/dashboard-build-eval.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/hdx-eval": patch
3+
---
4+
5+
Add dashboard-build eval scenario with post-run artifact inspection

‎packages/hdx-eval/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ mirrors HyperDX's `otel_traces` / `otel_logs` exactly.
264264
| `noisy-signals` | "We want to cut log ingest cost..." | ~16M logs across composite cells. Each noisy cell has a load-bearing pattern mixed in. |
265265
| `segmented-regression` | "API error rate has gone up..." | Enterprise x cache-miss errors at ~12%. Single-axis aggregates dilute the signal. |
266266
| `service-health-check` | "Generate a service health report..." | Peace-time report — no incident, but 4 novel signals to call out. |
267+
| `dashboard-build` | "We need dashboards to monitor our microservices..." | 3 user-facing + 4 distractor services. Dashboard creation via MCP tools, cross-dashboard drill-down, messy severity, latency red herring, impossible metric requests. |
267268

268269
Use `--volume-factor` to scale background row counts for faster iteration.
269270
Planted anomaly counts stay fixed.
@@ -409,6 +410,21 @@ the agent's system prompt. It is persisted in `eval.config.json` under
409410
- **`--reseed`**: force re-seed even when data already exists (e.g. after
410411
changing the seed value or anchor time).
411412

413+
The anchor is intentionally allowed to age. It is **not** auto-refreshed when
414+
wall-clock time advances, and stale data does **not** trigger an automatic
415+
reseed. This keeps seeded data stable across runs (important for CI, where a
416+
fresh reseed is much slower than reusing cached seed data).
417+
418+
> **Note on `clickstack_describe_source`:** that tool samples its value-related
419+
> fields (`lowCardinalityValues`, `mapAttributeValues`, `mapAttributeKeys`)
420+
> over a fixed **last-24h-of-wall-clock** window, independent of the anchor.
421+
> Once seeded data ages past that window those fields come back empty/partial.
422+
> Rather than refreshing the anchor and reseeding to keep them populated, the
423+
> system prompt instructs the agent to treat those samples as unreliable and to
424+
> discover real values via anchored queries instead (see
425+
> `SAMPLING_CAVEAT_BLOCK` in `src/harness/systemPrompt.ts`). The `columns`
426+
> schema from `describe_source` is unaffected and remains reliable.
427+
412428
## Tests
413429

414430
```bash
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { normalizeSeverityText } from '@/generators/templates';
2+
import { mulberry32 } from '@/rng/seeded';
3+
import { dashboardBuildScenario } from '@/scenarios/dashboard-build/generate';
4+
import { collectScenario } from '@/scenarios/types';
5+
6+
const NOW_MS = Date.parse('2026-05-10T20:00:00.000Z');
7+
8+
// 1% volume keeps the test cheap (~20K traces, ~40K logs) while preserving
9+
// the structural invariants.
10+
const TEST_VOLUME_FACTOR = 0.01;
11+
12+
const ONE_HOUR_MS = 60 * 60 * 1000;
13+
const FIFTEEN_MIN_MS = 15 * 60 * 1000;
14+
const WINDOW_START_MS = NOW_MS - ONE_HOUR_MS;
15+
const ERROR_SPIKE_START_MS = NOW_MS - FIFTEEN_MIN_MS;
16+
17+
function run(seed: number) {
18+
return collectScenario(
19+
dashboardBuildScenario.generate({
20+
rng: mulberry32(seed),
21+
nowMs: NOW_MS,
22+
volumeFactor: TEST_VOLUME_FACTOR,
23+
}),
24+
);
25+
}
26+
27+
describe('dashboard-build scenario', () => {
28+
const result = run(42);
29+
30+
it('produces traces and logs over the 1-hour window', () => {
31+
// 2M × 0.01 = 20K traces target; 4M × 0.01 = 40K logs.
32+
expect(result.traces.length).toBeGreaterThan(15_000);
33+
expect(result.traces.length).toBeLessThan(25_000);
34+
expect(result.logs.length).toBeGreaterThan(35_000);
35+
expect(result.logs.length).toBeLessThan(45_000);
36+
});
37+
38+
it('covers the full 1-hour window', () => {
39+
let minTs = Infinity;
40+
let maxTs = -Infinity;
41+
for (const t of result.traces) {
42+
if (t.timestampMs < minTs) minTs = t.timestampMs;
43+
if (t.timestampMs > maxTs) maxTs = t.timestampMs;
44+
}
45+
// First sample within 1 min of window start; last within 1 min of now.
46+
expect(minTs - WINDOW_START_MS).toBeLessThan(60_000);
47+
expect(NOW_MS - maxTs).toBeLessThan(60_000);
48+
});
49+
50+
it('is deterministic for a fixed seed', () => {
51+
const b = run(42);
52+
expect(b.traces.length).toBe(result.traces.length);
53+
expect(b.logs.length).toBe(result.logs.length);
54+
expect(b.traces[100].spanId).toBe(result.traces[100].spanId);
55+
expect(b.logs[100].body).toBe(result.logs[100].body);
56+
});
57+
58+
describe('service distribution', () => {
59+
it('has all seven services (3 primary + 4 distractors)', () => {
60+
const services = new Set(result.traces.map(t => t.serviceName));
61+
expect(services).toContain('web-gateway');
62+
expect(services).toContain('order-service');
63+
expect(services).toContain('inventory-service');
64+
expect(services).toContain('health-checker');
65+
expect(services).toContain('cron-scheduler');
66+
expect(services).toContain('internal-metrics');
67+
expect(services).toContain('debug-proxy');
68+
expect(services.size).toBe(7);
69+
});
70+
71+
it('web-gateway has the most traffic (~40%)', () => {
72+
const webGateway = result.traces.filter(
73+
t => t.serviceName === 'web-gateway',
74+
).length;
75+
const ratio = webGateway / result.traces.length;
76+
expect(ratio).toBeGreaterThan(0.33);
77+
expect(ratio).toBeLessThan(0.47);
78+
});
79+
80+
it('distractor services collectively have ~32% of traffic', () => {
81+
const distractors = result.traces.filter(t =>
82+
[
83+
'health-checker',
84+
'cron-scheduler',
85+
'internal-metrics',
86+
'debug-proxy',
87+
].includes(t.serviceName),
88+
).length;
89+
const ratio = distractors / result.traces.length;
90+
expect(ratio).toBeGreaterThan(0.25);
91+
expect(ratio).toBeLessThan(0.4);
92+
});
93+
94+
it('debug-proxy has elevated error rate (~15%)', () => {
95+
const debugTraces = result.traces.filter(
96+
t => t.serviceName === 'debug-proxy',
97+
);
98+
const debugErrors = debugTraces.filter(
99+
t => t.statusCode === 'STATUS_CODE_ERROR',
100+
).length;
101+
const rate = debugErrors / debugTraces.length;
102+
expect(rate).toBeGreaterThan(0.1);
103+
expect(rate).toBeLessThan(0.22);
104+
});
105+
});
106+
107+
describe('order-service error spike', () => {
108+
it('error rate is elevated in the last 15 minutes', () => {
109+
const spikeTraces = result.traces.filter(
110+
t =>
111+
t.serviceName === 'order-service' &&
112+
t.timestampMs >= ERROR_SPIKE_START_MS,
113+
);
114+
const spikeErrors = spikeTraces.filter(
115+
t => t.statusCode === 'STATUS_CODE_ERROR',
116+
).length;
117+
const spikeRate = spikeErrors / spikeTraces.length;
118+
// Target is ~8% but there's also 0.3% background, so expect ~8-11%
119+
expect(spikeRate).toBeGreaterThan(0.05);
120+
expect(spikeRate).toBeLessThan(0.15);
121+
});
122+
123+
it('error rate is low outside the spike window for order-service', () => {
124+
const normalTraces = result.traces.filter(
125+
t =>
126+
t.serviceName === 'order-service' &&
127+
t.timestampMs < ERROR_SPIKE_START_MS,
128+
);
129+
const normalErrors = normalTraces.filter(
130+
t => t.statusCode === 'STATUS_CODE_ERROR',
131+
).length;
132+
const normalRate = normalErrors / normalTraces.length;
133+
// Should be ~0.3% background only
134+
expect(normalRate).toBeLessThan(0.02);
135+
});
136+
});
137+
138+
describe('trace attributes', () => {
139+
it('traces have http.method and http.route span attributes', () => {
140+
const sample = result.traces.slice(0, 100);
141+
const withMethod = sample.filter(
142+
t => t.spanAttributes['http.method'],
143+
).length;
144+
expect(withMethod).toBeGreaterThan(90);
145+
});
146+
147+
it('traces have realistic endpoint names as spanName', () => {
148+
const spanNames = new Set(result.traces.map(t => t.spanName));
149+
expect(spanNames).toContain('GET /api/products');
150+
expect(spanNames).toContain('POST /api/orders');
151+
expect(spanNames).toContain('GET /inventory/check');
152+
});
153+
});
154+
155+
describe('log distribution', () => {
156+
it('covers all severity families once normalized', () => {
157+
const families = new Set(
158+
result.logs.map(l => normalizeSeverityText(l.severityText)),
159+
);
160+
expect(families).toContain('INFO');
161+
expect(families).toContain('DEBUG');
162+
expect(families).toContain('WARN');
163+
expect(families).toContain('ERROR');
164+
});
165+
166+
it('INFO family is the most common (~50%) once normalized', () => {
167+
const infoLogs = result.logs.filter(
168+
l => normalizeSeverityText(l.severityText) === 'INFO',
169+
).length;
170+
const ratio = infoLogs / result.logs.length;
171+
expect(ratio).toBeGreaterThan(0.4);
172+
expect(ratio).toBeLessThan(0.6);
173+
});
174+
175+
// ── Misleading-data trap: messy severity casing/aliases ──────────
176+
it('stores SeverityText with mixed case + OTel aliases (verbatim)', () => {
177+
const raw = new Set(result.logs.map(l => l.severityText));
178+
// Lowercase variants must be present (naive ERROR exact-match misses these)
179+
expect(raw).toContain('error');
180+
expect(raw).toContain('info');
181+
// The canonical uppercase variants also appear (collector inconsistency)
182+
expect([...raw].some(s => s === 'ERROR')).toBe(true);
183+
// Aliases like `fatal`/`warning`/`information` appear in the error/warn/info families
184+
const hasAlias = [...raw].some(s =>
185+
['fatal', 'warning', 'information'].includes(s),
186+
);
187+
expect(hasAlias).toBe(true);
188+
});
189+
190+
it('a naive exact-match SeverityText=ERROR under-counts error rows', () => {
191+
const errorFamily = result.logs.filter(
192+
l => normalizeSeverityText(l.severityText) === 'ERROR',
193+
).length;
194+
const exactUppercaseOnly = result.logs.filter(
195+
l => l.severityText === 'ERROR',
196+
).length;
197+
// Exact uppercase match should miss a meaningful fraction of real errors.
198+
expect(exactUppercaseOnly).toBeLessThan(errorFamily);
199+
expect(exactUppercaseOnly / errorFamily).toBeLessThan(0.6);
200+
});
201+
202+
it('severityNumber is the true OTel number for the (messy) text', () => {
203+
// Each variant carries its real OTel severity number — including
204+
// `fatal` (21), which normalizes to the ERROR text family but keeps a
205+
// distinct number. This is exactly the kind of inconsistency a robust
206+
// dashboard must tolerate.
207+
const expectedByText: Record<string, number> = {
208+
trace: 1,
209+
TRACE: 1,
210+
debug: 5,
211+
DEBUG: 5,
212+
info: 9,
213+
INFO: 9,
214+
information: 9,
215+
warn: 13,
216+
WARN: 13,
217+
warning: 13,
218+
error: 17,
219+
ERROR: 17,
220+
fatal: 21,
221+
FATAL: 21,
222+
};
223+
for (const l of result.logs.slice(0, 500)) {
224+
expect(l.severityNumber).toBe(expectedByText[l.severityText]);
225+
}
226+
});
227+
});
228+
229+
describe('latency red herring', () => {
230+
it('inventory-service aggregate P95 is dominated by the admin endpoint', () => {
231+
const inv = result.traces.filter(
232+
t => t.serviceName === 'inventory-service',
233+
);
234+
const levels = inv.filter(t => t.spanName === 'GET /inventory/levels');
235+
const userPaths = inv.filter(t => t.spanName !== 'GET /inventory/levels');
236+
237+
const p95 = (rows: typeof inv) => {
238+
const sorted = rows.map(t => t.durationNs).sort((a, b) => a - b);
239+
return sorted[Math.floor(sorted.length * 0.95)] ?? 0;
240+
};
241+
242+
// The admin endpoint is much slower than the user-facing paths.
243+
expect(levels.length).toBeGreaterThan(0);
244+
const levelsP95 = p95(levels);
245+
const userP95 = p95(userPaths);
246+
// Admin export P95 is in the multi-second range (>= ~2s in ns).
247+
expect(levelsP95).toBeGreaterThan(2_000_000_000);
248+
// User paths stay sub-second.
249+
expect(userP95).toBeLessThan(1_000_000_000);
250+
// Aggregate P95 is pulled up well above the user-path P95.
251+
expect(p95(inv)).toBeGreaterThan(userP95);
252+
});
253+
});
254+
255+
describe('scenario metadata', () => {
256+
it('has dashboard hooks configured', () => {
257+
expect(dashboardBuildScenario.buildSystemPrompt).toBeDefined();
258+
expect(dashboardBuildScenario.allowedToolPatterns).toBeDefined();
259+
expect(
260+
dashboardBuildScenario.allowedToolPatterns!.length,
261+
).toBeGreaterThan(0);
262+
expect(dashboardBuildScenario.judgeSystemPreamble).toBeDefined();
263+
expect(dashboardBuildScenario.postRunInspection).toBeDefined();
264+
});
265+
266+
it('has a ground truth with rubric and 2-dashboard spec', () => {
267+
const gt = dashboardBuildScenario.groundTruth as {
268+
expected?: { totalTileCount?: number; dashboardCount?: number };
269+
rubric?: { programmatic?: unknown[]; judge?: { criteria?: unknown[] } };
270+
};
271+
expect(gt.expected?.dashboardCount).toBe(2);
272+
expect(gt.rubric).toBeDefined();
273+
expect(gt.rubric?.programmatic).toBeDefined();
274+
expect(gt.rubric?.judge?.criteria).toBeDefined();
275+
expect(gt.rubric!.programmatic!.length).toBeGreaterThan(15);
276+
expect(gt.rubric!.judge!.criteria!.length).toBeGreaterThanOrEqual(7);
277+
});
278+
});
279+
});

‎packages/hdx-eval/src/__tests__/systemPrompt.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,30 @@ describe('buildSystemPrompt', () => {
3737
expect(p).toContain('2026-01-15T10:00:00.000Z');
3838
});
3939

40+
it('warns that describe_source value samples may be stale (with anchor)', () => {
41+
const p = buildSystemPrompt(
42+
getScenario('error-root-cause'),
43+
'2026-01-15T10:00:00.000Z',
44+
);
45+
expect(p).toContain('DATA DISCOVERY CAVEAT');
46+
expect(p).toContain('clickstack_describe_source');
47+
expect(p).toContain('lowCardinalityValues');
48+
});
49+
50+
it('omits the sampling caveat when no anchor is provided', () => {
51+
const p = buildSystemPrompt(getScenario('error-root-cause'));
52+
expect(p).not.toContain('DATA DISCOVERY CAVEAT');
53+
});
54+
55+
it('includes the sampling caveat in the dashboard prompt when anchored', () => {
56+
const p = buildSystemPrompt(
57+
getScenario('dashboard-build'),
58+
'2026-01-15T10:00:00.000Z',
59+
);
60+
expect(p).toContain('DATA DISCOVERY CAVEAT');
61+
expect(p).toContain('lowCardinalityValues');
62+
});
63+
4064
it('includes hypothesis playbook when variant is hypothesis', () => {
4165
const p = buildSystemPrompt(
4266
getScenario('error-root-cause'),
@@ -58,4 +82,18 @@ describe('buildSystemPrompt', () => {
5882
// Should NOT contain investigation-specific content
5983
expect(p).not.toContain("What's not the cause");
6084
});
85+
86+
it('uses custom system prompt for dashboard scenarios', () => {
87+
const p = buildSystemPrompt(getScenario('dashboard-build'));
88+
expect(p).toContain('building');
89+
expect(p).toContain('dashboards');
90+
expect(p).toContain('TURN BUDGET');
91+
// Should NOT contain investigation-specific content
92+
expect(p).not.toContain("What's not the cause");
93+
// Should be much shorter than the investigation prompt
94+
const investigationPrompt = buildSystemPrompt(
95+
getScenario('error-root-cause'),
96+
);
97+
expect(p.length).toBeLessThan(investigationPrompt.length);
98+
});
6199
});

0 commit comments

Comments
 (0)