|
| 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 | +}); |
0 commit comments