Skip to content

Commit 9ea73f5

Browse files
kckernclaude
andcommitted
test(web): perf.mjs lists error responses apart from the timings
A non-2xx/3xx Node response leaves the URL out of the stats; a live one leaves it without a live time. Live statuses are cached with the times. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6ba1c7c commit 9ea73f5

4 files changed

Lines changed: 58 additions & 15 deletions

File tree

‎apps/web/test/parity/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,6 @@ The output holds paths real visitors requested, search terms included. Keep it,
5050
node test/parity/perf.mjs [--corpus traffic] [--format html,api] [--only john] [--runs 5] [--max-live 200] [--refresh-live] [--json out.json]
5151
```
5252

53-
It prints, per format and overall, the p50 and p95 across URLs of each URL's median on Node and live, and the ratio of the Node p50 to the live p50 over URLs timed on both. A URL is flagged when its Node median is more than 1.5 times live's and more than 300 ms slower. Live requests are spaced at least 250 ms apart and capped by `--max-live` (warm-ups count); URLs past the cap get Node times only. Live times are cached with a timestamp in `.cache/perf-live.json`, so later runs reuse them until `--refresh-live`.
53+
It prints, per format and overall, the p50 and p95 across URLs of each URL's median on Node and live, and the ratio of the Node p50 to the live p50 over URLs timed on both. A URL is flagged when its Node median is more than 1.5 times live's and more than 300 ms slower. Responses other than 2xx/3xx are listed apart: a Node error leaves the URL out of the stats, a live one leaves it without a live time. Live requests are spaced at least 250 ms apart and capped by `--max-live` (warm-ups count); URLs past the cap get Node times only. Live times are cached with a timestamp in `.cache/perf-live.json`, so later runs reuse them until `--refresh-live`.
5454

5555
Against a local app and database the comparison is indicative only: live times include the network round trip from your machine, Node times do not, and a laptop with a local copy of the database is not the production host. Time on staging for numbers that decide anything.

‎apps/web/test/parity/perf.mjs‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import { performance } from 'node:perf_hooks';
1313
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
1414
import { CACHE_ROOT, NODE_BASE, LIVE_SCHEME, USER_AGENT, cliOptions, loadCorpus, selectRequests, get, hostFor } from './harness.mjs';
15-
import { report, cachedLive, FLAG_RATIO, FLAG_MS } from './perfStats.mjs';
15+
import { report, cachedLive, isOk, FLAG_RATIO, FLAG_MS } from './perfStats.mjs';
1616

1717
const LIVE_GAP_MS = 250;
1818
const LIVE_CACHE = CACHE_ROOT + '/perf-live.json';
@@ -41,7 +41,7 @@ async function series(url, headers, { gap = 0 } = {}) {
4141
for (let i = 0; i <= runs; i++) { // i = 0 is the warm-up
4242
if (gap && i) await new Promise(r => setTimeout(r, gap));
4343
const t = await timed(url, headers);
44-
status = t.status;
44+
if (status === undefined || isOk(status)) status = t.status; // keep the first error status
4545
if (i) times.push(Math.round(t.ms * 10) / 10);
4646
}
4747
return { times, status };
@@ -50,19 +50,20 @@ async function series(url, headers, { gap = 0 } = {}) {
5050
let liveSpent = 0;
5151
let liveSkipped = 0;
5252
let lastLive = 0;
53+
// { times, status } for the live URL, or null without live times
5354
async function liveTimes(format, path) {
5455
const url = `${LIVE_SCHEME}://${hostFor(format)}${path}`;
5556
const cached = cachedLive(liveCache, url, runs, { refresh: refreshLive });
56-
if (cached) return cached;
57+
if (cached) return { times: cached, status: liveCache[url].status };
5758
if (liveSpent + runs + 1 > maxLive) { liveSkipped++; return null; }
5859
const wait = lastLive + LIVE_GAP_MS - Date.now();
5960
if (wait > 0) await new Promise(r => setTimeout(r, wait));
6061
liveSpent += runs + 1;
6162
try {
62-
const { times } = await series(url, {}, { gap: LIVE_GAP_MS });
63-
liveCache[url] = { at: new Date().toISOString(), times };
63+
const { times, status } = await series(url, {}, { gap: LIVE_GAP_MS });
64+
liveCache[url] = { at: new Date().toISOString(), times, status };
6465
writeFileSync(LIVE_CACHE, JSON.stringify(liveCache, null, 1));
65-
return times;
66+
return { times, status };
6667
} catch (e) {
6768
console.error(`live ${url}: ${e.message}`);
6869
return null;
@@ -75,13 +76,13 @@ const results = [];
7576
for (const [format, path] of requests) {
7677
let node;
7778
try {
78-
node = (await series(NODE_BASE + path, { Host: hostFor(format) })).times;
79+
node = await series(NODE_BASE + path, { Host: hostFor(format) });
7980
} catch (e) {
8081
console.error(`node ${format} ${path}: ${e.message}`);
8182
continue;
8283
}
8384
const live = await liveTimes(format, path);
84-
results.push({ format, path, node, live });
85+
results.push({ format, path, node: node.times, nodeStatus: node.status, live: live?.times ?? null, liveStatus: live?.status });
8586
process.stderr.write('.');
8687
}
8788
process.stderr.write('\n');
@@ -92,7 +93,7 @@ const x = v => (v == null ? '-' : v.toFixed(2));
9293
const row = (name, g, n) => `${name.padEnd(14)} ${String(n).padStart(4)} ${ms(g.node.p50).padStart(8)} ${ms(g.node.p95).padStart(8)} ` +
9394
`${ms(g.live.p50).padStart(8)} ${ms(g.live.p95).padStart(8)} ${x(g.ratio).padStart(6)} ${String(g.flagged).padStart(7)}`;
9495

95-
console.log(`${runs} runs per URL, median per URL; p50/p95 across URLs, in ms. Ratio = Node p50 / live p50 over URLs timed on both.`);
96+
console.log(`${runs} runs per URL, median per URL; p50/p95 across URLs, in ms. Ratio = Node p50 / live p50 over URLs timed on both. Error responses are listed apart.`);
9697
console.log('format urls node p50 node p95 live p50 live p95 ratio flagged');
9798
for (const [f, g] of Object.entries(r.formats)) console.log(row(f, g, r.urls.filter(u => u.format === f).length));
9899
console.log(row('overall', r.overall, r.urls.length));
@@ -104,6 +105,10 @@ console.log('\nSlowest on Node:');
104105
for (const u of [...r.urls].sort((a, b) => b.node - a.node).slice(0, 10)) {
105106
console.log(` ${ms(u.node).padStart(6)} live ${ms(u.live).padStart(6)} ${u.format.padEnd(8)} ${u.path}`);
106107
}
108+
if (r.errors.length) {
109+
console.log(`\nError responses (not 2xx/3xx; a Node error leaves the URL out of the stats, a live one its live time): ${r.errors.length}`);
110+
for (const e of r.errors) console.log(` ${e.format.padEnd(8)} ${e.path} node ${e.nodeStatus ?? '-'} live ${e.liveStatus ?? '-'}`);
111+
}
107112
console.log(`\nLive: ${liveSpent} requests made, ${liveSkipped} URLs without live times (--max-live ${maxLive}).`);
108113

109114
if (jsonOut) {

‎apps/web/test/parity/perf.test.js‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from 'vitest';
2-
import { percentile, summarize, ratio, isFlagged, cachedLive, report } from './perfStats.mjs';
2+
import { percentile, summarize, ratio, isFlagged, cachedLive, report, isOk } from './perfStats.mjs';
33

44
describe('percentile', () => {
55
test('interpolates between the closest ranks', () => {
@@ -100,3 +100,30 @@ describe('report', () => {
100100
expect(r.overall.ratio).toBeCloseTo(530 / 200);
101101
});
102102
});
103+
104+
describe('error responses', () => {
105+
test('isOk: 2xx and 3xx', () => {
106+
expect([200, 204, 301, 304, 399].every(isOk)).toBe(true);
107+
expect([400, 404, 410, 500, 503, undefined, null].some(isOk)).toBe(false);
108+
});
109+
const results = [
110+
{ format: 'html', path: '/a', node: [100, 100], nodeStatus: 200, live: [50, 50], liveStatus: 200 },
111+
{ format: 'html', path: '/missing', node: [5, 5], nodeStatus: 404, live: [900, 900], liveStatus: 404 },
112+
{ format: 'html', path: '/broken-live', node: [20, 20], nodeStatus: 200, live: [3000, 3000], liveStatus: 500 },
113+
{ format: 'html', path: '/old-cache', node: [30, 30], nodeStatus: 200, live: [40, 40] },
114+
];
115+
const r = report(results);
116+
test('a Node error response is left out of the stats and listed', () => {
117+
expect(r.urls.map(u => u.path)).toEqual(['/a', '/broken-live', '/old-cache']);
118+
expect(r.errors).toEqual([
119+
{ format: 'html', path: '/missing', nodeStatus: 404, liveStatus: 404 },
120+
{ format: 'html', path: '/broken-live', nodeStatus: 200, liveStatus: 500 },
121+
]);
122+
});
123+
test('a live error response leaves the URL without a live time', () => {
124+
const u = r.urls.find(x => x.path === '/broken-live');
125+
expect(u.live).toBeNull();
126+
expect(u.flagged).toBe(false);
127+
expect(r.formats.html.live.n).toBe(2); // /a and /old-cache (cached before statuses were kept)
128+
});
129+
});

‎apps/web/test/parity/perfStats.mjs‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export function summarize(values) {
2020
return { n: values.length, p50: percentile(values, 50), p95: percentile(values, 95) };
2121
}
2222

23+
// A response worth timing: 2xx or 3xx. Error pages (404 probes, 5xx) are reported apart.
24+
export function isOk(status) {
25+
return Number.isInteger(status) && status >= 200 && status < 400;
26+
}
27+
2328
export function ratio(node, live) {
2429
return node == null || !live ? null : node / live;
2530
}
@@ -47,16 +52,22 @@ function group(urls) {
4752
}
4853

4954
/**
50-
* @param {{format:string, path:string, node:number[], live:number[]|null}[]} results
55+
* @param {{format:string, path:string, node:number[], live:number[]|null, nodeStatus?:number, liveStatus?:number}[]} results
5156
* Per URL: the median of its runs. Per format and overall: p50/p95 across those URL medians.
57+
* A URL whose Node response is an error is left out (listed in `errors`); a live error response
58+
* leaves the URL without a live time. A missing liveStatus (cached before statuses were kept)
59+
* counts as ok.
5260
*/
5361
export function report(results) {
54-
const urls = results.map(({ format, path, node, live }) => {
62+
const errors = results
63+
.filter(r => !isOk(r.nodeStatus ?? 200) || !isOk(r.liveStatus ?? 200))
64+
.map(({ format, path, nodeStatus, liveStatus }) => ({ format, path, nodeStatus, liveStatus }));
65+
const urls = results.filter(r => isOk(r.nodeStatus ?? 200)).map(({ format, path, node, live, liveStatus }) => {
5566
const n = percentile(node, 50);
56-
const l = live?.length ? percentile(live, 50) : null;
67+
const l = live?.length && isOk(liveStatus ?? 200) ? percentile(live, 50) : null;
5768
return { format, path, node: n, live: l, ratio: ratio(n, l), flagged: isFlagged(n, l) };
5869
});
5970
const formats = {};
6071
for (const f of new Set(urls.map(u => u.format))) formats[f] = group(urls.filter(u => u.format === f));
61-
return { urls, formats, overall: group(urls) };
72+
return { urls, formats, overall: group(urls), errors };
6273
}

0 commit comments

Comments
 (0)