Skip to content

Commit 0cce4e6

Browse files
Merge pull request #20 from thelastbackspace/feat/baseline
run: count-based baselines for a deterministic PR check
2 parents 70e3450 + 7de9f17 commit 0cce4e6

4 files changed

Lines changed: 159 additions & 1 deletion

File tree

‎CHANGELOG.md‎

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

88
## [Unreleased]
99

10+
### Added
11+
- **A PR check that isn't flaky:** `traceperf run --save-baseline perf-baseline.json` records how many calls of each kind every route makes per request; `--baseline perf-baseline.json` compares and exits 1 when a route makes more (`GET /orders: pg SELECT items 1 → 12 per request (an N+1?)`) or a new N+1 / error appears. Counts, not timings, so the result is the same on any machine.
12+
1013
### Added
1114
- **Fastify route labels:** with `traceperf/auto` or `traceperf run`, Fastify (3–5) requests are labelled by route, `GET /api/articles/:slug`, instead of one label per URL. `traceperf/auto` wraps the `fastify()` factory to add an `onRequest` hook (`wrapFastifyFactory` for doing it by hand). Any framework can do the same by setting the `ROUTE` symbol on the raw request.
1215
- **Koa route labels:** with `@koa/router` or `koa-router`, Koa requests are labelled by the matched route (`GET /api/users/:id`), auto-detected; `labelKoaRoutes(require('koa'))` by hand.

‎README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,30 @@ Checked on real apps, not only on the planted ones: Prisma's REST example, the R
106106
- `--no-profile`: don't sample the CPU.
107107
- Any start command works: `npx traceperf run -- npm start`.
108108

109+
### In CI: fail a PR that adds queries
110+
111+
Timings are too noisy to gate a PR on (the same run can be twice as fast on an idle machine), but how many queries a route makes per request is exact. Save a baseline once, commit it, and compare on every PR:
112+
113+
<!-- docs:skip -->
114+
```bash
115+
npx traceperf run server.js --path /orders --path /users/1 --save-baseline perf-baseline.json
116+
npx traceperf run server.js --path /orders --path /users/1 --baseline perf-baseline.json # exit 1 on regressions
117+
```
118+
119+
```
120+
Compared with perf-baseline.json:
121+
✖ GET /orders: pg SELECT items 1 → 12 per request (an N+1?)
122+
✖ new problem: N+1 in GET /orders
123+
```
124+
125+
It fails when a route makes more calls of any kind per request (database queries, HTTP calls, Redis commands) or an N+1 or error appears; fewer calls are reported as improvements. Counts come from the one-at-a-time requests, so they don't depend on the machine. In GitHub Actions, with a service container for the database:
126+
127+
<!-- docs:skip -->
128+
```yaml
129+
- run: npm ci && npm run db:seed
130+
- run: npx traceperf run dist/server.js --path /orders --path /users/1 --baseline perf-baseline.json
131+
```
132+
109133
`examples/slow-apps/` has small apps with one planted problem each (blocking in your code, blocking in built-ins, sequential awaits, N+1, a slow dependency, errors); CI checks that `run` names each one.
110134

111135
The same naming works without `run`: `TRACEPERF_PROFILE_BLOCKING=1 node --require traceperf/auto server.js` adds the function names to the exit summary (sampling costs a few percent CPU, so use it while investigating). In code: `profileSlowCalls({ select: root => blockingRanges(root), mergeInlined: true, onProfile })`, then `explain(roots, { hotspots })`.

‎src/__tests__/run.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,32 @@ describe('naming blocking code from CPU profiles', () => {
259259
});
260260
});
261261

262+
describe('baselines (calls per request per route)', () => {
263+
const { baselineOf, compareBaseline } = jest.requireActual('../run');
264+
const n = (label: string, children: any[] = []) => ({ label, children });
265+
const orders = (items: number) => n('GET /orders', [n('pg SELECT orders'), ...Array.from({ length: items }, () => n('pg SELECT items'))]);
266+
267+
it('takes the median count of each call per request', () => {
268+
const b = baselineOf([orders(1), orders(1), orders(3), n('GET /health')], []);
269+
expect(b.routes).toEqual({
270+
'GET /health': { requests: 1, calls: {} },
271+
'GET /orders': { requests: 3, calls: { 'pg SELECT items': 1, 'pg SELECT orders': 1 } },
272+
});
273+
const withN1 = baselineOf([orders(12)], [{ kind: 'repeated', root: 'GET /orders', ms: 50, message: '' }, { kind: 'blocking', root: 'GET /x', ms: 20, message: '' }]);
274+
expect(withN1.problems).toEqual(['N+1 in GET /orders']); // blocking is timing, not in the baseline
275+
});
276+
277+
it('flags more calls per request and new problems; reports improvements', () => {
278+
const before = baselineOf([orders(1), n('GET /old')], []);
279+
const after = baselineOf([orders(12), n('GET /new')], [{ kind: 'repeated', root: 'GET /orders', ms: 50, message: '' }]);
280+
const r = compareBaseline(before, after);
281+
expect(r.regressions).toEqual(['GET /orders: pg SELECT items 1 → 12 per request (an N+1?)', 'new problem: N+1 in GET /orders']);
282+
expect(r.notes).toEqual(['GET /new: new route (not in the baseline)', 'GET /old: not requested in this run']);
283+
expect(compareBaseline(after, before).improvements).toEqual(['GET /orders: pg SELECT items 12 → 1 per request', 'fixed: N+1 in GET /orders']);
284+
expect(compareBaseline(before, before)).toEqual({ regressions: [], improvements: [], notes: [] });
285+
});
286+
});
287+
262288
describe('statusFinding', () => {
263289
const { statusFinding } = jest.requireActual('../run');
264290
const load = (statuses: Record<string, number>, errors = 0) => ({ requests: 10, errors, statuses, durationMs: 1, latency: {} });

‎src/run.ts‎

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export interface RunArgs {
3434
/** Request headers, e.g. { authorization: 'Bearer ...' } */
3535
headers?: Record<string, string>;
3636
html?: string;
37+
/** Write call counts per route here (a baseline for --baseline) */
38+
saveBaseline?: string;
39+
/** Compare call counts per route with this baseline; exit 1 on more calls or new problems */
40+
baseline?: string;
3741
output?: string;
3842
json: boolean;
3943
max: number;
@@ -59,6 +63,10 @@ export const RUN_USAGE = ` traceperf run <app.js> [app args] [options]
5963
--html out.html write an HTML report
6064
--output f.jsonl keep the raw request trees
6165
--json print stats and findings as JSON
66+
--save-baseline f.json save calls per request for each route (commit it)
67+
--baseline f.json compare with it; exit 1 if a route makes more calls
68+
(e.g. an N+1) or a new problem appears. Counts, not
69+
timings: deterministic, safe for CI
6270
--no-profile don't sample the CPU (blocking code is then named
6371
only by its tracked call)`;
6472

@@ -134,6 +142,8 @@ export function parseRunArgs(argv: string[]): RunArgs | string {
134142
break;
135143
}
136144
case '--json': args.json = true; break;
145+
case '--save-baseline': if (!(args.saveBaseline = argv[++i])) return '--save-baseline expects a file'; break;
146+
case '--baseline': if (!(args.baseline = argv[++i])) return '--baseline expects a file'; break;
137147
case '--no-profile': args.profile = false; break;
138148
case '--probe': {
139149
const n = Number(argv[++i]);
@@ -344,6 +354,76 @@ export function statusFinding(load: LoadResult): Finding | undefined {
344354
};
345355
}
346356

357+
/** Calls per request for each route, and the kinds of problem found: deterministic, unlike timings */
358+
export interface Baseline {
359+
version: 1;
360+
routes: Record<string, { requests: number; calls: Record<string, number> }>;
361+
/** "kind route" of repeated-call (N+1) and error findings: counts, so deterministic (blocking is timing, so not here) */
362+
problems: string[];
363+
}
364+
365+
interface CountNode { label: string; children: CountNode[] }
366+
367+
/** Median number of each call label per request, per route */
368+
export function baselineOf(trees: CountNode[], findings: Finding[]): Baseline {
369+
const byRoute = new Map<string, Array<Map<string, number>>>();
370+
for (const tree of trees) {
371+
const counts = new Map<string, number>();
372+
const walk = (n: CountNode) => n.children.forEach(c => {
373+
counts.set(c.label, (counts.get(c.label) || 0) + 1);
374+
walk(c);
375+
});
376+
walk(tree);
377+
const list = byRoute.get(tree.label) ?? [];
378+
list.push(counts);
379+
byRoute.set(tree.label, list);
380+
}
381+
const routes: Baseline['routes'] = {};
382+
[...byRoute.keys()].sort().forEach(route => {
383+
const list = byRoute.get(route)!;
384+
const labels = [...new Set(list.flatMap(m => [...m.keys()]))].sort();
385+
const calls: Record<string, number> = {};
386+
for (const label of labels) {
387+
const values = list.map(m => m.get(label) || 0).sort((a, b) => a - b);
388+
const median = values[Math.floor((values.length - 1) / 2)];
389+
if (median > 0) calls[label] = median;
390+
}
391+
routes[route] = { requests: list.length, calls };
392+
});
393+
const problems = [...new Set(findings
394+
.filter(f => (f.kind === 'repeated' || f.kind === 'error') && f.root !== 'load')
395+
.map(f => `${f.kind === 'repeated' ? 'N+1' : 'errors'} in ${f.root}`))].sort();
396+
return { version: 1, routes, problems };
397+
}
398+
399+
/** Differences that make a PR worse: more calls per request on a route, or a new problem */
400+
export function compareBaseline(before: Baseline, now: Baseline): { regressions: string[]; improvements: string[]; notes: string[] } {
401+
const regressions: string[] = [];
402+
const improvements: string[] = [];
403+
const notes: string[] = [];
404+
for (const route of Object.keys(now.routes).sort()) {
405+
const b = before.routes[route];
406+
const a = now.routes[route];
407+
if (!b) {
408+
notes.push(`${route}: new route (not in the baseline)`);
409+
continue;
410+
}
411+
const labels = [...new Set([...Object.keys(b.calls), ...Object.keys(a.calls)])].sort();
412+
for (const label of labels) {
413+
const x = b.calls[label] || 0;
414+
const y = a.calls[label] || 0;
415+
if (y > x) regressions.push(`${route}: ${label} ${x} → ${y} per request${x <= 1 && y >= 5 ? ' (an N+1?)' : ''}`);
416+
else if (y < x) improvements.push(`${route}: ${label} ${x} → ${y} per request`);
417+
}
418+
}
419+
for (const route of Object.keys(before.routes)) {
420+
if (!now.routes[route]) notes.push(`${route}: not requested in this run`);
421+
}
422+
for (const p of now.problems) if (!before.problems.includes(p)) regressions.push(`new problem: ${p}`);
423+
for (const p of before.problems) if (!now.problems.includes(p)) improvements.push(`fixed: ${p}`);
424+
return { regressions, improvements, notes };
425+
}
426+
347427
export interface RunSummary {
348428
stats: string;
349429
findings: Finding[];
@@ -584,11 +664,25 @@ export async function runApp(args: RunArgs, deps: RunDeps): Promise<number> {
584664
const after = summary.findings.filter(f => f.kind === 'error' || f.kind === 'blocking').length;
585665
summary.findings.splice(after, 0, ...queued);
586666
}
667+
// Baselines use the one-at-a-time probe when there is one (the same requests every run)
668+
let baselineReport: { regressions: string[]; improvements: string[]; notes: string[] } | undefined;
669+
let baselineError: string | undefined;
670+
if ((args.saveBaseline || args.baseline) && summary.trees) {
671+
const current = baselineOf(deps.parseTrees(probeText.trim() ? probeText : text), summary.findings);
672+
if (args.saveBaseline) fs.writeFileSync(args.saveBaseline, JSON.stringify(current, null, 2) + '\n');
673+
if (args.baseline) {
674+
try {
675+
baselineReport = compareBaseline(JSON.parse(fs.readFileSync(args.baseline, 'utf8')), current);
676+
} catch (e) {
677+
baselineError = `traceperf: could not read the baseline ${args.baseline}: ${e instanceof Error ? e.message : String(e)}`;
678+
}
679+
}
680+
}
587681
if (args.html && summary.trees) {
588682
fs.writeFileSync(args.html, deps.report(text, { html: true, findings: summary.findings, title: `traceperf run: ${args.command.join(' ')}` }));
589683
}
590684
if (args.json) {
591-
deps.out(JSON.stringify({ load, trees: summary.trees, findings: summary.findings }, null, 2));
685+
deps.out(JSON.stringify({ load, trees: summary.trees, findings: summary.findings, ...(baselineReport ? { baseline: baselineReport } : {}) }, null, 2));
592686
} else if (!summary.trees) {
593687
deps.out('\ntraceperf: no requests were tracked.');
594688
} else {
@@ -600,10 +694,21 @@ export async function runApp(args: RunArgs, deps: RunDeps): Promise<number> {
600694
lines.push('', summary.stats, '', 'Why it was slow:', formatFindings(summary.findings));
601695
if (args.html) lines.push('', `HTML report: ${args.html}`);
602696
if (args.output) lines.push(`Request trees: ${args.output}`);
697+
if (args.saveBaseline) lines.push(`Baseline saved: ${args.saveBaseline}`);
698+
if (baselineReport) {
699+
const { regressions, improvements, notes } = baselineReport;
700+
lines.push('', `Compared with ${args.baseline}:`);
701+
regressions.forEach(r => lines.push(` ✖ ${r}`));
702+
improvements.forEach(r => lines.push(` ✔ ${r}`));
703+
notes.forEach(r => lines.push(` · ${r}`));
704+
if (!regressions.length && !improvements.length) lines.push(' ✔ same calls per request on every route');
705+
}
603706
deps.out(lines.join('\n'));
604707
}
708+
if (baselineError) deps.err(baselineError);
605709
if (!args.output) fs.rmSync(dir, { recursive: true, force: true });
606710
// Interactive runs end with Ctrl-C; that is success. A crash before any traffic is not.
711+
if (baselineError || (baselineReport && baselineReport.regressions.length)) return 1;
607712
return !summary.trees && exitCode ? 1 : 0;
608713
}
609714

0 commit comments

Comments
 (0)