Skip to content

Commit 12c3e6b

Browse files
Merge pull request #9 from thelastbackspace/feat/evidence-links
Link findings to their evidence in the HTML report
2 parents 847dc91 + 8af8b07 commit 12c3e6b

9 files changed

Lines changed: 116 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- **`traceperf run`:** starts an app with `traceperf/auto`, sends concurrent traffic (`--path`, `-c`, `-d`/`-n`, `--method`; warm-up requests first), then prints statistics and explanations. Without `--path`, use the app yourself and press Ctrl-C. Works with a script (`traceperf run server.js`) or any command (`traceperf run -- npm start`). `--html`, `--output`, `--json`.
12+
- **Evidence links in the HTML report:** the report now opens with the findings ("Why it was slow"); click one to highlight its time range on the flame chart (no scripts, works from disk). Each finding carries `evidence: { start, end, labels }` in `explain()` and `--json` output.
1213
- **Known-slow example apps** (`examples/slow-apps/`): one planted cause each; a test checks that `explain` names it.
1314
- **Blocking code is named even when untracked:** `traceperf run` (and `TRACEPERF_PROFILE_BLOCKING=1` with `traceperf/auto`) samples the CPU during the synchronous part of blocking calls, and `explain` names the function and line (`CPU time: buildReport (server.js:10) 100%`). Native work is named by the calling app function (`hashPassword → pbkdf2Sync`), code V8 inlined into its caller is credited to the inlined function, and garbage-collection pauses are reported as such.
1415
- `traceperf run` reports **queueing the app can't see**: clients' latency compared with the app's own timings, per path.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ render 1.886 1.966 +4.2% unchanged
584584
### Reports and live view
585585

586586
```bash
587-
npx traceperf report trace.json --html report.html # one self-contained file: flame chart, stats, slowest calls
587+
npx traceperf report trace.json --html report.html # one self-contained file: findings linked to the flame chart, stats, slowest calls
588588
npx traceperf watch calls.jsonl # live top-style view while the app writes the log
589589
```
590590

‎src/__tests__/cli.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,25 @@ test('--html writes a self-contained report with one bar per call', async () =>
9595
}
9696
});
9797

98+
test('the HTML report lists findings, each linked to its time range on the chart', async () => {
99+
const { trace, jsonl } = await sample();
100+
for (const input of [trace, jsonl]) {
101+
const html = report(input, { html: true });
102+
expect(html).toContain('Why it was slow');
103+
// the failing cache.get is a finding with evidence: link and band share an id
104+
const link = html.match(/<a href="#(ev-\d+)">✖ cache\.get in GET \/users failed/);
105+
expect(link).not.toBeNull();
106+
expect(html).toContain(`<rect id="${link![1]}" class="ev"`);
107+
expect(html).toMatch(/rect\.ev:target/);
108+
expect(html).not.toMatch(/<script/);
109+
}
110+
// Findings passed in (as traceperf run does) replace the computed ones; no evidence, no link
111+
const html = report(jsonl, { html: true, findings: [{ kind: 'dominant', root: 'x', ms: 1, message: 'given <finding>' }] });
112+
expect(html).toContain('<li>● given &lt;finding&gt;</li>');
113+
expect(html).not.toContain('class="ev"');
114+
expect(report(jsonl, { html: true, findings: [] })).not.toContain('Why it was slow');
115+
});
116+
98117
function runLog(durations: Record<string, number[]>): string {
99118
// Synthetic JSON lines: one root per call
100119
return Object.entries(durations).flatMap(([label, ds]) => ds.map(d => JSON.stringify({

‎src/__tests__/explain-auto-profile.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@ describe('explain()', () => {
4545
expect(findings.filter(f => f.kind === 'blocked').map(f => f.root)).toEqual(['req2']);
4646
});
4747

48+
test('each finding points at the time range and calls that show it', () => {
49+
const cpu = node('cpu', 50, 30, { async: false, syncDuration: 30 });
50+
const req1 = node('req1', 0, 100, { children: [node('db', 0, 50), cpu], selfDuration: 20 });
51+
const req2 = node('req2', 10, 100, { children: [node('db', 10, 80)], selfDuration: 20 });
52+
const findings = explain([req1, req2]);
53+
expect(findings.find(f => f.kind === 'blocking')!.evidence).toEqual({ start: 50, end: 80, labels: ['cpu'] });
54+
expect(findings.find(f => f.kind === 'blocked')!.evidence).toEqual({ start: 10, end: 110, labels: ['req2', 'cpu'] });
55+
const seq = node('page', 0, 30, { children: [node('a', 0, 10), node('b', 10, 10), node('c', 20, 10)] });
56+
expect(explain([seq]).find(f => f.kind === 'sequential')!.evidence).toEqual({ start: 0, end: 30, labels: ['a', 'b', 'c'] });
57+
const n1 = node('list', 0, 60, { children: Array.from({ length: 6 }, (_, i) => node('q', 5 + i * 9, 9)) });
58+
expect(explain([n1]).find(f => f.kind === 'repeated')!.evidence).toEqual({ start: 5, end: 59, labels: ['q'] });
59+
});
60+
4861
test('blocking is attributed to the deepest call that did it', () => {
4962
const inner = node('parseJson', 0, 40, { async: false, syncDuration: 40 });
5063
const outer = node('handler', 0, 42, { async: false, syncDuration: 42, children: [inner] });

‎src/__tests__/run.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ describe('runApp', () => {
146146
expect(text).toContain('load: 20 requests');
147147
expect(text).toContain('Why it was slow:');
148148
expect(text).toContain('Promise.all');
149-
expect(fs.readFileSync(html, 'utf8')).toContain('<html');
149+
expect(fs.readFileSync(html, 'utf8')).toMatch(/<html[\s\S]*Why it was slow/);
150150
expect(fs.readFileSync(output, 'utf8').trim().split('\n')).toHaveLength(20);
151151
fs.rmSync(dir, { recursive: true, force: true });
152152
}, 30000);

‎src/cli.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111
import { ExecutionRecord } from './core/records';
1212
import { LabelStats, StatsCollector, formatStatsTable } from './core/stats';
13-
import { explain, ExplainNode, ExplainOptions, formatFindings } from './core/explain';
13+
import { explain, ExplainNode, ExplainOptions, Finding, formatFindings } from './core/explain';
1414
import { renderHtmlReport } from './report-html';
1515
import { createWatcher, WatchOptions } from './watch';
1616
import { parseRunArgs, runApp, RUN_USAGE } from './run';
@@ -22,6 +22,8 @@ export interface ReportOptions {
2222
title?: string;
2323
sort?: 'total' | 'mean' | 'p95' | 'max' | 'count';
2424
json?: boolean;
25+
/** Findings for the HTML report (default: explain() on the input) */
26+
findings?: Finding[];
2527
}
2628

2729
export interface Call {
@@ -46,6 +48,10 @@ function record(label: string, duration: number, selfDuration: number, error?: s
4648
};
4749
}
4850

51+
/** Start in epoch ms: exact startMs when present (ISO strings are rounded to the ms) */
52+
const startOf = (node: any): number =>
53+
typeof node.startMs === 'number' ? node.startMs : typeof node.start === 'string' ? Date.parse(node.start) : 0;
54+
4955
/** Parse a Chrome trace or JSON-lines document into flat calls */
5056
export function parseCalls(text: string): Call[] {
5157
const trimmed = text.trim();
@@ -88,7 +94,7 @@ export function parseCalls(text: string): Call[] {
8894
const laneEnds: number[] = [];
8995
const visit = (node: any, parents: string[], lane: number) => {
9096
const path = [...parents, node.label].join(' > ');
91-
const start = typeof node.start === 'string' ? Date.parse(node.start) : 0;
97+
const start = startOf(node);
9298
calls.push({
9399
label: node.label,
94100
path,
@@ -122,7 +128,7 @@ export function parseCalls(text: string): Call[] {
122128
if (typeof node.label !== 'string' || typeof node.durationMs !== 'number') {
123129
throw new Error(`line ${i + 1} is not a traceperf record`);
124130
}
125-
const start = typeof node.start === 'string' ? Date.parse(node.start) : 0;
131+
const start = startOf(node);
126132
visit(node, [], pickLane(start, start + node.durationMs));
127133
});
128134
return calls;
@@ -166,7 +172,7 @@ export function parseTrees(text: string): ExplainNode[] {
166172
}
167173
const convert = (n: any): ExplainNode => ({
168174
label: n.label,
169-
startTime: typeof n.startMs === 'number' ? n.startMs : typeof n.start === 'string' ? Date.parse(n.start) : 0,
175+
startTime: startOf(n),
170176
duration: n.durationMs,
171177
selfDuration: typeof n.selfMs === 'number' ? n.selfMs : n.durationMs,
172178
syncDuration: typeof n.syncMs === 'number' ? n.syncMs : undefined,
@@ -233,7 +239,15 @@ export function report(text: string, options: ReportOptions = {}): string {
233239
const slowest = calls.slice().sort((a, b) => b.duration - a.duration).slice(0, top);
234240

235241
if (options.html) {
236-
return renderHtmlReport(calls, stats, { title: options.title, top: options.top ?? 20 });
242+
let findings = options.findings;
243+
if (!findings) {
244+
try {
245+
findings = explain(parseTrees(text), { max: 10 });
246+
} catch {
247+
findings = [];
248+
}
249+
}
250+
return renderHtmlReport(calls, stats, { title: options.title, top: options.top ?? 20, findings });
237251
}
238252
if (options.json) {
239253
return JSON.stringify({ calls: calls.length, stats, slowest }, null, 2);

‎src/core/explain.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,22 @@ export interface Finding {
3535
ms: number;
3636
/** For blocking / blocked: the call whose synchronous code blocked */
3737
call?: string;
38+
/** Where to look: the time range (same clock as the input) and the calls involved */
39+
evidence?: FindingEvidence;
3840
}
3941

42+
export interface FindingEvidence {
43+
start: number;
44+
end: number;
45+
labels: string[];
46+
}
47+
48+
const span = (nodes: ExplainNode[], labels: string[]): FindingEvidence => ({
49+
start: Math.min(...nodes.map(n => n.startTime)),
50+
end: Math.max(...nodes.map(n => n.startTime + n.duration)),
51+
labels: [...new Set(labels)],
52+
});
53+
4054
/** Where a blocking call spent its CPU time (from the sampling profiler) */
4155
export interface BlockingHotspot {
4256
functionName: string;
@@ -142,18 +156,19 @@ export function explain(roots: ExplainNode[], options: ExplainOptions = {}): Fin
142156
const rootEnd = root.startTime + root.duration;
143157

144158
// 1. Delayed by blocking code in other top-level calls
145-
const byLabel = new Map<string, { ms: number; roots: Set<string> }>();
159+
const byLabel = new Map<string, { ms: number; roots: Set<string>; ranges: Blocker[] }>();
146160
for (const b of blockers) {
147161
if (b.root === i) continue;
148162
const ms = overlap(root.startTime, rootEnd, b.start, b.end);
149163
if (ms <= 0) continue;
150-
const entry = byLabel.get(b.label) ?? { ms: 0, roots: new Set<string>() };
164+
const entry = byLabel.get(b.label) ?? { ms: 0, roots: new Set<string>(), ranges: [] };
151165
entry.ms += ms;
166+
entry.ranges.push(b);
152167
entry.roots.add(roots[b.root].label);
153168
byLabel.set(b.label, entry);
154169
}
155170
let blockedTotal = 0;
156-
byLabel.forEach(({ ms, roots: from }, label) => {
171+
byLabel.forEach(({ ms, roots: from, ranges }, label) => {
157172
blockedTotal += ms;
158173
if (ms < minMs) return;
159174
const others = [...from].filter(r => r !== label);
@@ -164,6 +179,11 @@ export function explain(roots: ExplainNode[], options: ExplainOptions = {}): Fin
164179
root: rootLabel,
165180
ms,
166181
call: label,
182+
evidence: {
183+
start: Math.min(root.startTime, ...ranges.map(r => r.start)),
184+
end: Math.max(rootEnd, ...ranges.map(r => r.end)),
185+
labels: [rootLabel, label],
186+
},
167187
message: `${rootLabel} waited ~${fmt(ms)} (${pct(ms, root.duration)} of its ${fmt(root.duration)}) while ${label}${where} blocked the event loop${culprit(label, true)}`,
168188
});
169189
});
@@ -178,6 +198,7 @@ export function explain(roots: ExplainNode[], options: ExplainOptions = {}): Fin
178198
root: rootLabel,
179199
ms,
180200
call: b.label,
201+
evidence: { start: b.start, end: b.end, labels: [b.label] },
181202
message: `${b.label} ran ${fmt(ms)} of synchronous code, blocking everything else in the process for that long${culprit(b.label, false)}`,
182203
});
183204
}
@@ -206,6 +227,7 @@ export function explain(roots: ExplainNode[], options: ExplainOptions = {}): Fin
206227
root: rootLabel,
207228
ms: saving,
208229
group: node.label,
230+
evidence: span(run, run.map(k => k.label)),
209231
message: `${node.label} awaited ${run.length} calls one after another (${shown}: ${fmt(total)}); if they are independent, Promise.all could save up to ~${fmt(saving)}`,
210232
});
211233
}
@@ -237,6 +259,7 @@ export function explain(roots: ExplainNode[], options: ExplainOptions = {}): Fin
237259
kind: 'repeated',
238260
root: rootLabel,
239261
ms,
262+
evidence: span(kids.filter(k => k.label === label), [label]),
240263
message: `${node.label} called ${label} ${n} times (${fmt(ms)} total); batching them into one call may help (N+1 pattern)`,
241264
});
242265
}
@@ -256,6 +279,7 @@ export function explain(roots: ExplainNode[], options: ExplainOptions = {}): Fin
256279
kind: 'dominant',
257280
root: rootLabel,
258281
ms: k.duration - waited,
282+
evidence: span([k], [k.label]),
259283
message: `${k.label} took ${pct(k.duration, root.duration)} of ${rootLabel} (${fmt(k.duration)} of ${fmt(root.duration)})${note}`,
260284
});
261285
}
@@ -267,6 +291,7 @@ export function explain(roots: ExplainNode[], options: ExplainOptions = {}): Fin
267291
kind: 'dominant',
268292
root: rootLabel,
269293
ms: own,
294+
evidence: span([root], [rootLabel]),
270295
message: `${pct(own, root.duration)} of ${rootLabel} (${fmt(own)}) is outside any tracked call; track the code inside it to see where`,
271296
});
272297
}
@@ -281,6 +306,7 @@ export function explain(roots: ExplainNode[], options: ExplainOptions = {}): Fin
281306
kind: 'error',
282307
root: rootLabel,
283308
ms: node.duration,
309+
evidence: span([node], [node.label]),
284310
message: `${node === root ? node.label : `${node.label} in ${rootLabel}`} failed after ${fmt(node.duration)}: ${msg}`,
285311
});
286312
});
@@ -298,7 +324,7 @@ export function explain(roots: ExplainNode[], options: ExplainOptions = {}): Fin
298324
else {
299325
g.times++;
300326
g.roots.add(f.root);
301-
if (f.ms > g.ms) Object.assign(g, { ms: f.ms, message: f.message });
327+
if (f.ms > g.ms) Object.assign(g, { ms: f.ms, message: f.message, evidence: f.evidence });
302328
}
303329
}
304330
const order: Record<FindingKind, number> = { error: 0, blocked: 1, blocking: 2, repeated: 3, sequential: 4, dominant: 5 };

‎src/report-html.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
*/
66
import type { Call } from './cli';
77
import type { LabelStats } from './core/stats';
8+
import type { Finding } from './core/explain';
9+
10+
const ICON: Record<string, string> = { error: '✖', blocked: '⏳', blocking: '🔥', repeated: '🔁', sequential: '⇉', dominant: '●', queued: '⏳' };
811

912
const esc = (s: unknown): string => String(s)
1013
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -20,7 +23,7 @@ function colorFor(label: string, failed: boolean): string {
2023

2124
const ms = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(2)} s` : `${n.toFixed(n < 10 ? 3 : 1)} ms`);
2225

23-
export function renderHtmlReport(calls: Call[], stats: Record<string, LabelStats>, options: { title?: string; top?: number } = {}): string {
26+
export function renderHtmlReport(calls: Call[], stats: Record<string, LabelStats>, options: { title?: string; top?: number; findings?: Finding[] } = {}): string {
2427
const title = options.title ?? 'traceperf report';
2528
const top = options.top ?? 20;
2629
const width = 1200;
@@ -55,6 +58,26 @@ export function renderHtmlReport(calls: Call[], stats: Record<string, LabelStats
5558
return `<g class="call"><title>${esc(tip)}</title><rect x="${rx.toFixed(1)}" y="${ry}" width="${rw.toFixed(1)}" height="${rowH - 2}" rx="2" fill="${colorFor(c.label, !!c.error)}"/>${text}</g>`;
5659
}).join('\n');
5760

61+
// Findings: each links to a band over the chart showing its time range
62+
const findings = options.findings || [];
63+
const clampX = (t: number) => Math.min(width, Math.max(0, x(t)));
64+
const bands = findings.map((f, i) => {
65+
const ev = f.evidence;
66+
if (!ev || !calls.length || ev.end < start || ev.start > end) return '';
67+
const bx = clampX(ev.start);
68+
const bw = Math.max(3, clampX(ev.end) - bx);
69+
return `<rect id="ev-${i}" class="ev" x="${bx.toFixed(1)}" y="${axisH - 4}" width="${bw.toFixed(1)}" height="${height - axisH + 4}"><title>${esc(`${ev.labels.join(', ')}\n${ms(ev.end - ev.start)}`)}</title></rect>`;
70+
});
71+
const findingItems = findings.map((f, i) => {
72+
const text = `${ICON[f.kind] || '•'} ${esc(f.message)}`;
73+
return bands[i]
74+
? `<li><a href="#ev-${i}">${text}</a> <span class="at">at ${esc(ms(f.evidence!.start - start))}, ${esc(f.evidence!.labels.slice(0, 3).join(', '))}</span></li>`
75+
: `<li>${text}</li>`;
76+
}).join('\n');
77+
const findingsSection = findings.length
78+
? `<h2>Why it was slow</h2>\n<ol class="findings">\n${findingItems}\n</ol>\n<p class="meta">Click a finding to highlight its time range on the flame chart.</p>`
79+
: '';
80+
5881
const ticks = Array.from({ length: 6 }, (_, i) => {
5982
const tx = (i / 5) * width;
6083
return `<line x1="${tx}" x2="${tx}" y1="${axisH - 4}" y2="${height}" class="grid"/><text x="${Math.min(tx + 3, width - 60)}" y="14" class="axis">${esc(ms((span * i) / 5))}</text>`;
@@ -92,16 +115,22 @@ export function renderHtmlReport(calls: Call[], stats: Record<string, LabelStats
92115
th:first-child, td:first-child { text-align: left; } tbody tr:nth-child(odd) { background: var(--row); }
93116
.slow td:first-child { width: 1%; } .slow td:last-child, .slow th:last-child { text-align: left; white-space: normal; width: auto; } .err { color: #d73a49; }
94117
.wrap { overflow-x: auto; }
118+
ol.findings { padding-left: 20px; margin: 0; } ol.findings li { margin: 4px 0; } ol.findings a { color: inherit; }
119+
.at { color: var(--muted); font-size: 12px; }
120+
rect.ev { fill: #f0b400; fill-opacity: 0; stroke: none; pointer-events: none; }
121+
rect.ev:target { fill-opacity: 0.22; stroke: #f0b400; stroke-width: 2; }
95122
</style>
96123
</head>
97124
<body>
98125
<main>
99126
<h1>${esc(title)}</h1>
100127
<p class="meta">${calls.length} calls · ${Object.keys(stats).length} labels · ${lanes.length} lane${lanes.length === 1 ? '' : 's'} · span ${esc(ms(span))}. Hover a bar for details.</p>
101-
<h2>Flame chart</h2>
128+
${findingsSection}
129+
<h2 id="chart">Flame chart</h2>
102130
<div class="chart"><svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="flame chart">
103131
${ticks}
104132
${rects}
133+
${bands.join('\n')}
105134
</svg></div>
106135
<h2>Statistics (ms)</h2>
107136
<div class="wrap"><table><thead><tr><th>label</th><th>calls</th><th>errors</th><th>total</th><th>mean</th><th>p50</th><th>p95</th><th>p99</th><th>max</th><th>self mean</th></tr></thead>

‎src/run.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ export async function runApp(args: RunArgs, deps: RunDeps): Promise<number> {
351351
summary.findings.splice(after, 0, ...queued);
352352
}
353353
if (args.html && summary.trees) {
354-
fs.writeFileSync(args.html, deps.report(text, { html: true, title: `traceperf run: ${args.command.join(' ')}` }));
354+
fs.writeFileSync(args.html, deps.report(text, { html: true, findings: summary.findings, title: `traceperf run: ${args.command.join(' ')}` }));
355355
}
356356
if (args.json) {
357357
deps.out(JSON.stringify({ load, trees: summary.trees, findings: summary.findings }, null, 2));

0 commit comments

Comments
 (0)