rolldown-perf-kit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +345 -0
- package/LICENSE +25 -0
- package/README.md +621 -2
- package/harness.mjs +4860 -0
- package/lib/build.mjs +57 -0
- package/lib/cdp.mjs +217 -0
- package/lib/chunk-discovery.mjs +226 -0
- package/lib/coverage.mjs +498 -0
- package/lib/field/analysis.mjs +225 -0
- package/lib/field/browser.mjs +232 -0
- package/lib/field/build-match.mjs +86 -0
- package/lib/field/capture.mjs +501 -0
- package/lib/field/config.mjs +84 -0
- package/lib/field/run.mjs +612 -0
- package/lib/gen-app.mjs +399 -0
- package/lib/graph-diff.mjs +136 -0
- package/lib/interrupt.mjs +25 -0
- package/lib/measure.mjs +540 -0
- package/lib/min-cut.mjs +218 -0
- package/lib/module-graph.mjs +318 -0
- package/lib/network-evidence.mjs +84 -0
- package/lib/node/launch.mjs +351 -0
- package/lib/node/measure.mjs +289 -0
- package/lib/paint-diagnostics.mjs +76 -0
- package/lib/parity/anchor.mjs +186 -0
- package/lib/parity/capture.mjs +527 -0
- package/lib/parity/compare.mjs +813 -0
- package/lib/parity/png.mjs +166 -0
- package/lib/parity/run.mjs +145 -0
- package/lib/profile.mjs +281 -0
- package/lib/replay/cert.mjs +45 -0
- package/lib/replay/model.mjs +656 -0
- package/lib/replay/run.mjs +1140 -0
- package/lib/replay/server.mjs +499 -0
- package/lib/replay/shaper.mjs +158 -0
- package/lib/rolldown-support.mjs +161 -0
- package/lib/serve.mjs +142 -0
- package/lib/worker/attrib.mjs +125 -0
- package/lib/worker/config.mjs +116 -0
- package/lib/worker/launch.mjs +337 -0
- package/lib/worker/measure.mjs +296 -0
- package/package.json +38 -4
- package/replay.mjs +79 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
// `field` - measure a DEPLOYED site in the user's own logged-in Chrome and write
|
|
2
|
+
// the lab's report files (runtime-metrics.json, coverage.json, profile.json) per
|
|
3
|
+
// variant, plus field-report.json with the paired comparison.
|
|
4
|
+
//
|
|
5
|
+
// What field mode adds to the lab: real data, real network, real server time.
|
|
6
|
+
// What it gives up: control. Results are for one account, one network and one
|
|
7
|
+
// machine; noise is handled with alternating pairs, retries for invalid runs,
|
|
8
|
+
// outlier flags and a bootstrap interval, not hidden. The parity check does not
|
|
9
|
+
// apply (real data changes between loads).
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { parseArgs } from 'node:util';
|
|
15
|
+
|
|
16
|
+
import { sleep } from '../cdp.mjs';
|
|
17
|
+
import { attributeChunks, coldAtPaintModules } from '../coverage.mjs';
|
|
18
|
+
import { DEFAULT_THROTTLE, median, scaleThrottle, summarize } from '../measure.mjs';
|
|
19
|
+
import { aggregateProfile } from '../profile.mjs';
|
|
20
|
+
import {
|
|
21
|
+
chunkCoverage,
|
|
22
|
+
criticalInitiatorUrl,
|
|
23
|
+
criticalPath,
|
|
24
|
+
initiatorChain,
|
|
25
|
+
makeRedactor,
|
|
26
|
+
outlierIndexes,
|
|
27
|
+
pairedDelta,
|
|
28
|
+
round,
|
|
29
|
+
} from './analysis.mjs';
|
|
30
|
+
import { attachBrowser, contextForOrigin, restoreCookies, snapshotCookies } from './browser.mjs';
|
|
31
|
+
import { indexBuild, matchScript, matchSummary, readMatched } from './build-match.mjs';
|
|
32
|
+
import { describeConfig, loadFieldConfig } from './config.mjs';
|
|
33
|
+
import { diagnosticRun, timingRun } from './capture.mjs';
|
|
34
|
+
import { onInterrupt } from '../interrupt.mjs';
|
|
35
|
+
|
|
36
|
+
// Mirrors the lab's defer-candidate thresholds (harness.mjs) so field coverage
|
|
37
|
+
// reports read the same way.
|
|
38
|
+
const CANDIDATE_MIN_BYTES = 3 * 1024;
|
|
39
|
+
const CANDIDATE_MAX_PAINT_RATIO = 0.02;
|
|
40
|
+
export const MAX_RETRIES = 3;
|
|
41
|
+
|
|
42
|
+
const OPTIONS = {
|
|
43
|
+
config: { type: 'string' },
|
|
44
|
+
runs: { type: 'string', default: '6' },
|
|
45
|
+
diagnose: { type: 'string', default: '1' },
|
|
46
|
+
cut: { type: 'string' },
|
|
47
|
+
out: { type: 'string' },
|
|
48
|
+
only: { type: 'string' },
|
|
49
|
+
pause: { type: 'string', default: '3000' },
|
|
50
|
+
settle: { type: 'string', default: '3000' },
|
|
51
|
+
timeout: { type: 'string', default: '30000' },
|
|
52
|
+
throttle: { type: 'string', default: 'none' },
|
|
53
|
+
'net-scale': { type: 'string', default: '1' },
|
|
54
|
+
'cdp-url': { type: 'string' },
|
|
55
|
+
'cdp-port': { type: 'string' },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const slug = (url) =>
|
|
59
|
+
url
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.replace(/^https?:\/\//, '')
|
|
62
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
63
|
+
.replace(/^-+|-+$/g, '')
|
|
64
|
+
.slice(0, 60);
|
|
65
|
+
export const writeJson = (file, value) => {
|
|
66
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
67
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
68
|
+
};
|
|
69
|
+
export const msStr = (v) => (typeof v === 'number' ? `${Math.round(v)}ms` : 'n/a');
|
|
70
|
+
const mb = (n) => `${(n / 1048576).toFixed(2)}MB`;
|
|
71
|
+
const shortUrl = (url, origin) => (url?.startsWith(origin) ? url.slice(origin.length) || '/' : url);
|
|
72
|
+
|
|
73
|
+
export async function cmdField(argv) {
|
|
74
|
+
const opts = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true }).values;
|
|
75
|
+
if (!opts.config) throw new Error('field needs --config <file> (see README.md, "Field mode")');
|
|
76
|
+
const config = loadFieldConfig(opts.config);
|
|
77
|
+
const redact = makeRedactor(config.redact);
|
|
78
|
+
let variants = Object.values(config.variants);
|
|
79
|
+
if (opts.only) {
|
|
80
|
+
const keep = new Set(opts.only.split(','));
|
|
81
|
+
variants = variants.filter((v) => keep.has(v.name));
|
|
82
|
+
}
|
|
83
|
+
const runs = Number(opts.runs);
|
|
84
|
+
const diagnose = Number(opts.diagnose);
|
|
85
|
+
const cut = opts.cut ?? (config.gate ? 'gate' : 'lcp');
|
|
86
|
+
if (!['fcp', 'lcp', 'gate'].includes(cut)) throw new Error('--cut must be fcp, lcp or gate');
|
|
87
|
+
if (cut === 'gate' && !config.gate) throw new Error('--cut gate needs "gate" in the config');
|
|
88
|
+
const throttle =
|
|
89
|
+
opts.throttle === 'lab' ? scaleThrottle(DEFAULT_THROTTLE, Number(opts['net-scale'])) : null;
|
|
90
|
+
const outDir = path.resolve(
|
|
91
|
+
opts.out ?? path.join(os.tmpdir(), 'perf-kit-field', slug(redact(config.url))),
|
|
92
|
+
);
|
|
93
|
+
const timing = { settleMs: Number(opts.settle), timeoutMs: Number(opts.timeout), throttle };
|
|
94
|
+
const pause = Number(opts.pause);
|
|
95
|
+
|
|
96
|
+
console.log(`field: ${redact(config.url)} variants: ${variants.map((v) => v.name).join(', ')}`);
|
|
97
|
+
console.log(
|
|
98
|
+
`output: ${outDir} (outside the repo; contains no cookies, no page text, no screenshots)`,
|
|
99
|
+
);
|
|
100
|
+
if (throttle) console.log(`throttle: lab x${throttle.netScale} ADDED ON TOP of the real network`);
|
|
101
|
+
|
|
102
|
+
const browser = await attachBrowser({ cdpUrl: opts['cdp-url'], cdpPort: opts['cdp-port'] });
|
|
103
|
+
const { cdp } = browser;
|
|
104
|
+
const ctx = await contextForOrigin(cdp, config.origin);
|
|
105
|
+
if (!ctx.found) {
|
|
106
|
+
console.log(
|
|
107
|
+
`note: no open tab on ${config.origin}; using the default profile. Open a logged-in tab first to measure that profile.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const names = [...new Set(variants.flatMap((v) => v.cookies.map((c) => c.name)))];
|
|
111
|
+
const saved = await snapshotCookies(cdp, {
|
|
112
|
+
browserContextId: ctx.browserContextId,
|
|
113
|
+
origin: config.origin,
|
|
114
|
+
names,
|
|
115
|
+
});
|
|
116
|
+
const configured = variants.flatMap((v) => v.cookies);
|
|
117
|
+
// One restore, shared: a Ctrl-C during the final restore waits for it.
|
|
118
|
+
let restoring = null;
|
|
119
|
+
const restore = () =>
|
|
120
|
+
(restoring ??= restoreCookies(cdp, {
|
|
121
|
+
browserContextId: ctx.browserContextId,
|
|
122
|
+
origin: config.origin,
|
|
123
|
+
saved,
|
|
124
|
+
configured,
|
|
125
|
+
}));
|
|
126
|
+
const offInterrupt = onInterrupt(restore);
|
|
127
|
+
const common = { config, context: ctx.browserContextId, saved, configured, redact };
|
|
128
|
+
// Requests in the timing runs carry redacted URLs; a pinned gate matches in that form.
|
|
129
|
+
const gate = redact(config.gate);
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
// --- timing runs: alternating order, pair i = one run of each variant ---------
|
|
133
|
+
const timed = [];
|
|
134
|
+
const invalid = [];
|
|
135
|
+
for (let i = 0; i < runs; i++) {
|
|
136
|
+
const order = i % 2 ? [...variants].reverse() : variants;
|
|
137
|
+
for (const variant of order) {
|
|
138
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
139
|
+
const run = await timingRun(cdp, { ...common, variant, ...timing });
|
|
140
|
+
if (run.invalid) {
|
|
141
|
+
invalid.push({ pair: i + 1, variant: variant.name, reason: run.invalid });
|
|
142
|
+
console.log(
|
|
143
|
+
` pair ${i + 1} ${variant.name.padEnd(10)} INVALID: ${run.invalid}${attempt < MAX_RETRIES ? ' - retrying' : ''}`,
|
|
144
|
+
);
|
|
145
|
+
await sleep(pause);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const cp = criticalPath(run.requests, run.sample.lcp, { pattern: gate });
|
|
149
|
+
timed.push({ pair: i + 1, variant: variant.name, ...run, criticalPath: cp });
|
|
150
|
+
const b = cp.breakdown;
|
|
151
|
+
console.log(
|
|
152
|
+
` pair ${i + 1} ${variant.name.padEnd(10)} LCP ${msStr(run.sample.lcp).padStart(7)}` +
|
|
153
|
+
(b
|
|
154
|
+
? ` gate ${shortUrl(cp.gate.url, config.origin).slice(0, 48)} start ${msStr(b.beforeGateMs)} ttfb ${msStr(b.ttfbMs)} after ${msStr(b.afterGateMs)}`
|
|
155
|
+
: ` gate: ${cp.reason}`),
|
|
156
|
+
);
|
|
157
|
+
await sleep(pause);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- diagnostic runs: coverage at the cut + settle, CPU profile, bodies ------
|
|
164
|
+
const diagnostics = new Map();
|
|
165
|
+
if (diagnose > 0) {
|
|
166
|
+
console.log(
|
|
167
|
+
`\ndiagnostic runs (coverage cut: ${cut}) - slower by design, not used for timing:`,
|
|
168
|
+
);
|
|
169
|
+
for (const variant of variants) {
|
|
170
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
171
|
+
const run = await diagnosticRun(cdp, { ...common, variant, cut, ...timing });
|
|
172
|
+
if (run.invalid) {
|
|
173
|
+
console.log(
|
|
174
|
+
` ${variant.name.padEnd(10)} INVALID: ${run.invalid}${attempt < MAX_RETRIES ? ' - retrying' : ''}`,
|
|
175
|
+
);
|
|
176
|
+
await sleep(pause);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
diagnostics.set(variant.name, run);
|
|
180
|
+
console.log(
|
|
181
|
+
` ${variant.name.padEnd(10)} cut at ${msStr(run.cutMs)}, ${run.scripts.length} same-origin scripts`,
|
|
182
|
+
);
|
|
183
|
+
await sleep(pause);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- reports -------------------------------------------------------------------
|
|
190
|
+
const summaries = {};
|
|
191
|
+
for (const variant of variants) {
|
|
192
|
+
const mine = timed.filter((t) => t.variant === variant.name);
|
|
193
|
+
const runtime = buildRuntimeReport({ variant, mine, invalid, redact, throttle });
|
|
194
|
+
const diag = diagnostics.get(variant.name);
|
|
195
|
+
if (diag && runtime.criticalPath) {
|
|
196
|
+
runtime.criticalPath = {
|
|
197
|
+
...runtime.criticalPath,
|
|
198
|
+
...diagnosticChain(runtime.criticalPath, diag, { ...config, gate }),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (runtime.criticalPath) delete runtime.criticalPath.medianRunRequests;
|
|
202
|
+
writeJson(path.join(outDir, variant.name, 'runtime-metrics.json'), runtime);
|
|
203
|
+
let coverage = null;
|
|
204
|
+
let profile = null;
|
|
205
|
+
if (diag) {
|
|
206
|
+
({ coverage, profile } = buildDiagnosticReports({ variant, diag, config, redact }));
|
|
207
|
+
writeJson(path.join(outDir, variant.name, 'coverage.json'), coverage);
|
|
208
|
+
writeJson(path.join(outDir, variant.name, 'profile.json'), profile);
|
|
209
|
+
}
|
|
210
|
+
summaries[variant.name] = { runtime, coverage, profile };
|
|
211
|
+
}
|
|
212
|
+
const report = buildFieldReport({
|
|
213
|
+
variants,
|
|
214
|
+
timed,
|
|
215
|
+
invalid,
|
|
216
|
+
summaries,
|
|
217
|
+
config,
|
|
218
|
+
redact,
|
|
219
|
+
cut,
|
|
220
|
+
throttle,
|
|
221
|
+
});
|
|
222
|
+
writeJson(path.join(outDir, 'field-report.json'), report);
|
|
223
|
+
printFieldReport(report, summaries, config);
|
|
224
|
+
console.log(`\nfiles: ${outDir}`);
|
|
225
|
+
} finally {
|
|
226
|
+
try {
|
|
227
|
+
await restore();
|
|
228
|
+
if (names.length) console.log(`cookies restored: ${names.join(', ')} (values not printed)`);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.error(`WARNING: cookie restore failed: ${err.message}`);
|
|
231
|
+
}
|
|
232
|
+
offInterrupt();
|
|
233
|
+
await browser.close().catch(() => {});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function criticalSummary(mine) {
|
|
238
|
+
const withPath = mine.filter((t) => t.criticalPath.breakdown);
|
|
239
|
+
if (!withPath.length) return null;
|
|
240
|
+
const pick = (key) =>
|
|
241
|
+
round(
|
|
242
|
+
median(
|
|
243
|
+
withPath.map((t) => t.criticalPath.breakdown[key]).filter((v) => typeof v === 'number'),
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
const gateCounts = new Map();
|
|
247
|
+
for (const t of withPath)
|
|
248
|
+
gateCounts.set(t.criticalPath.gate.url, (gateCounts.get(t.criticalPath.gate.url) ?? 0) + 1);
|
|
249
|
+
const [gateUrl, gateRuns] = [...gateCounts].sort((a, b) => b[1] - a[1])[0];
|
|
250
|
+
const byLcp = [...withPath].sort((a, b) => a.sample.lcp - b.sample.lcp);
|
|
251
|
+
const mid = byLcp[Math.floor(byLcp.length / 2)];
|
|
252
|
+
return {
|
|
253
|
+
gate: { url: gateUrl, runs: gateRuns, of: mine.length },
|
|
254
|
+
medians: {
|
|
255
|
+
documentEndMs: pick('documentEndMs'),
|
|
256
|
+
beforeGateMs: pick('beforeGateMs'),
|
|
257
|
+
gateAfterDocumentMs: pick('gateAfterDocumentMs'),
|
|
258
|
+
ttfbMs: pick('ttfbMs'),
|
|
259
|
+
downloadMs: pick('downloadMs'),
|
|
260
|
+
afterGateMs: pick('afterGateMs'),
|
|
261
|
+
},
|
|
262
|
+
medianRunChain: mid.criticalPath.chain,
|
|
263
|
+
medianRunPair: mid.pair,
|
|
264
|
+
medianRunRequests: mid.requests,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The chain's SHAPE from the diagnostic run (async stacks name the real caller
|
|
270
|
+
* behind a fetch wrapper), its TIMES from the median timing run (the diagnostic
|
|
271
|
+
* run is slowed by coverage, profiling and the debugger).
|
|
272
|
+
*/
|
|
273
|
+
export function diagnosticChain(summary, diag, config) {
|
|
274
|
+
const gateReq =
|
|
275
|
+
diag.requests.find(
|
|
276
|
+
(r) => (config.gate ? r.url.includes(config.gate) : r.url === summary.gate.url) && !r.failed,
|
|
277
|
+
) ?? null;
|
|
278
|
+
if (!gateReq)
|
|
279
|
+
return { chainSource: 'median timing run (gate request not seen in the diagnostic run)' };
|
|
280
|
+
const shape = initiatorChain(diag.requests, criticalInitiatorUrl(gateReq, diag.requests));
|
|
281
|
+
const timingRequests = summary.medianRunRequests ?? [];
|
|
282
|
+
return {
|
|
283
|
+
medianRunChain: shape.map((step) => {
|
|
284
|
+
const t = timingRequests.find((r) => r.url === step.url && !r.failed);
|
|
285
|
+
return t
|
|
286
|
+
? { ...step, startMs: t.startMs, endMs: t.endMs }
|
|
287
|
+
: { ...step, timesFrom: 'diagnostic run' };
|
|
288
|
+
}),
|
|
289
|
+
gateStack: [...new Set((gateReq.initiator.frames ?? []).map((f) => f.url))],
|
|
290
|
+
chainSource: 'shape from the diagnostic run (async stacks), times from the median timing run',
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function buildRuntimeReport({ variant, mine, invalid, redact, throttle }) {
|
|
295
|
+
const summary = summarize(mine.map((t) => t.sample));
|
|
296
|
+
const ttfbs = mine.map((t) => t.criticalPath.breakdown?.ttfbMs ?? null);
|
|
297
|
+
const outliers = outlierIndexes(ttfbs).map((i) => mine[i].pair);
|
|
298
|
+
const nonGet = new Map();
|
|
299
|
+
for (const t of mine) {
|
|
300
|
+
for (const r of t.requests) {
|
|
301
|
+
if (['GET', 'HEAD', 'OPTIONS'].includes(r.method) || r.blocked) continue;
|
|
302
|
+
const key = `${r.method} ${r.url.split('?')[0]}`;
|
|
303
|
+
nonGet.set(key, (nonGet.get(key) ?? 0) + 1);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
schemaVersion: 1,
|
|
308
|
+
mode: 'field',
|
|
309
|
+
generatedAtMs: Date.now(),
|
|
310
|
+
variant: variant.name,
|
|
311
|
+
url: redact(variant.url),
|
|
312
|
+
throttle,
|
|
313
|
+
runs: summary.runs,
|
|
314
|
+
invalidRuns: invalid.filter((x) => x.variant === variant.name),
|
|
315
|
+
metrics: summary.metrics,
|
|
316
|
+
guard: summary.guard,
|
|
317
|
+
gatingFetches: summary.gatingFetches,
|
|
318
|
+
resourceWeight: summary.resourceWeight,
|
|
319
|
+
renderBlockingGate: summary.renderBlockingGate,
|
|
320
|
+
postLoadScripts: summary.postLoadScripts,
|
|
321
|
+
lcpElement: summary.lcpElement,
|
|
322
|
+
criticalPath: criticalSummary(mine),
|
|
323
|
+
serverOutlierPairs: outliers,
|
|
324
|
+
nonGetRequests: [...nonGet].map(([request, count]) => ({ request, count })),
|
|
325
|
+
runsDetail: mine.map((t) => ({
|
|
326
|
+
pair: t.pair,
|
|
327
|
+
path: t.path,
|
|
328
|
+
lcp: t.sample.lcp,
|
|
329
|
+
fcp: t.sample.fcp,
|
|
330
|
+
criticalPath: t.criticalPath,
|
|
331
|
+
requests: t.requests,
|
|
332
|
+
})),
|
|
333
|
+
samples: summary.samples,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function buildDiagnosticReports({ variant, diag, config, redact }) {
|
|
338
|
+
const index = variant.build && fs.existsSync(variant.build) ? indexBuild(variant.build) : null;
|
|
339
|
+
const toFile = (pathname) => {
|
|
340
|
+
try {
|
|
341
|
+
return decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
342
|
+
} catch {
|
|
343
|
+
return pathname.replace(/^\/+/, '');
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
const matches = new Map();
|
|
347
|
+
const chunkRows = [];
|
|
348
|
+
const fetchByPath = new Map(diag.scriptFetches.map((f) => [f.pathname, f]));
|
|
349
|
+
for (const script of diag.scripts) {
|
|
350
|
+
const file = toFile(script.pathname);
|
|
351
|
+
const body = diag.bodies.get(script.url);
|
|
352
|
+
if (body == null) continue;
|
|
353
|
+
const match = matchScript(index, { file, body });
|
|
354
|
+
matches.set(file, match);
|
|
355
|
+
const fetch = fetchByPath.get(script.pathname);
|
|
356
|
+
chunkRows.push({
|
|
357
|
+
file: redact(file),
|
|
358
|
+
match: match.status,
|
|
359
|
+
fetchStartMs: fetch?.startMs ?? null,
|
|
360
|
+
fetchEndMs: fetch?.endMs ?? null,
|
|
361
|
+
fetchedBeforeCut: Boolean(fetch?.prePaint),
|
|
362
|
+
...chunkCoverage({ bytes: body.length, atCut: script.atPaint, atSettle: script.atSettle }),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
chunkRows.sort((a, b) => b.bytes - b.cutBytes - (a.bytes - a.cutBytes));
|
|
366
|
+
const readChunk = (file) => readMatched(index, matches.get(file));
|
|
367
|
+
const prePaintFetches = new Map(
|
|
368
|
+
diag.scriptFetches.filter((f) => f.prePaint).map((f) => [toFile(f.pathname), f.bytes]),
|
|
369
|
+
);
|
|
370
|
+
const attributed = attributeChunks({
|
|
371
|
+
scripts: diag.scripts,
|
|
372
|
+
entryName: diag.entry ?? '/(no module entry)',
|
|
373
|
+
readChunk,
|
|
374
|
+
prePaintFetches,
|
|
375
|
+
});
|
|
376
|
+
// attributeChunks says 'no-sourcemap' for every unmatched chunk; field mode
|
|
377
|
+
// knows the real reason (usually a build of other code or settings).
|
|
378
|
+
const skipped = attributed.skipped.map((s) =>
|
|
379
|
+
s.reason === 'no-sourcemap' && matches.get(s.file) && !matches.get(s.file).local
|
|
380
|
+
? { ...s, reason: `no-build-match:${matches.get(s.file).status}` }
|
|
381
|
+
: s,
|
|
382
|
+
);
|
|
383
|
+
const candidates = attributed.modules.filter(
|
|
384
|
+
(m) => m.totalBytes >= CANDIDATE_MIN_BYTES && m.paintRatio < CANDIDATE_MAX_PAINT_RATIO,
|
|
385
|
+
);
|
|
386
|
+
const buildMatch = {
|
|
387
|
+
build: variant.build,
|
|
388
|
+
...matchSummary(chunkRows.map((r) => ({ status: r.match, bytes: r.bytes }))),
|
|
389
|
+
};
|
|
390
|
+
const preCut = chunkRows.filter((r) => r.fetchedBeforeCut || r.cutBytes > 0);
|
|
391
|
+
const coverage = {
|
|
392
|
+
schemaVersion: 1,
|
|
393
|
+
mode: 'field',
|
|
394
|
+
generatedAtMs: Date.now(),
|
|
395
|
+
entry: diag.entry,
|
|
396
|
+
chunks: attributed.chunks,
|
|
397
|
+
skippedChunks: skipped,
|
|
398
|
+
deferred: null,
|
|
399
|
+
thresholds: {
|
|
400
|
+
candidateMinBytes: CANDIDATE_MIN_BYTES,
|
|
401
|
+
candidateMaxPaintRatio: CANDIDATE_MAX_PAINT_RATIO,
|
|
402
|
+
},
|
|
403
|
+
modules: attributed.modules,
|
|
404
|
+
candidates,
|
|
405
|
+
coldAtPaint: coldAtPaintModules(attributed.modules).slice(0, 20),
|
|
406
|
+
field: {
|
|
407
|
+
variant: variant.name,
|
|
408
|
+
path: diag.path,
|
|
409
|
+
cut: diag.cut,
|
|
410
|
+
cutMs: round(diag.cutMs),
|
|
411
|
+
note: '"paint" fields in this file mean "at the cut" (field.cut), not first paint',
|
|
412
|
+
buildMatch,
|
|
413
|
+
preCut: {
|
|
414
|
+
scripts: preCut.length,
|
|
415
|
+
bytes: preCut.reduce((s, r) => s + r.bytes, 0),
|
|
416
|
+
executedBytes: preCut.reduce((s, r) => s + r.cutBytes, 0),
|
|
417
|
+
},
|
|
418
|
+
chunkCoverage: chunkRows,
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
const profileSummary = aggregateProfile(diag.profile, { origin: config.origin, readChunk });
|
|
422
|
+
const profile = {
|
|
423
|
+
schemaVersion: 2,
|
|
424
|
+
mode: 'field',
|
|
425
|
+
generatedAtMs: Date.now(),
|
|
426
|
+
entry: diag.entry,
|
|
427
|
+
...profileSummary,
|
|
428
|
+
rows: profileSummary.rows.map((r) => ({
|
|
429
|
+
...r,
|
|
430
|
+
bucket: redact(r.bucket),
|
|
431
|
+
chunks: r.chunks.map(redact),
|
|
432
|
+
})),
|
|
433
|
+
field: { variant: variant.name, cut: diag.cut, cutMs: round(diag.cutMs) },
|
|
434
|
+
};
|
|
435
|
+
return { coverage, profile };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function buildFieldReport({
|
|
439
|
+
variants,
|
|
440
|
+
timed,
|
|
441
|
+
invalid,
|
|
442
|
+
summaries,
|
|
443
|
+
config,
|
|
444
|
+
redact,
|
|
445
|
+
cut,
|
|
446
|
+
throttle,
|
|
447
|
+
}) {
|
|
448
|
+
const perVariant = {};
|
|
449
|
+
for (const v of variants) {
|
|
450
|
+
const s = summaries[v.name];
|
|
451
|
+
perVariant[v.name] = {
|
|
452
|
+
validRuns: s.runtime.runs,
|
|
453
|
+
invalidRuns: s.runtime.invalidRuns.length,
|
|
454
|
+
lcpMs: s.runtime.metrics['runtime.lcp_ms'],
|
|
455
|
+
fcpMs: s.runtime.metrics['runtime.fcp_ms'],
|
|
456
|
+
ttfbMs: s.runtime.metrics['runtime.ttfb_ms'],
|
|
457
|
+
criticalPath: s.runtime.criticalPath,
|
|
458
|
+
serverOutlierPairs: s.runtime.serverOutlierPairs,
|
|
459
|
+
buildMatch: s.coverage?.field.buildMatch ?? null,
|
|
460
|
+
preCut: s.coverage?.field.preCut ?? null,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
const comparisons = [];
|
|
464
|
+
const [base, ...others] = variants;
|
|
465
|
+
for (const cand of others) {
|
|
466
|
+
const pairsOf = (metric, skip = new Set()) => {
|
|
467
|
+
const rows = [];
|
|
468
|
+
for (let i = 1; i <= Math.max(0, ...timed.map((t) => t.pair)); i++) {
|
|
469
|
+
if (skip.has(i)) continue;
|
|
470
|
+
const a = timed.find((t) => t.pair === i && t.variant === base.name);
|
|
471
|
+
const b = timed.find((t) => t.pair === i && t.variant === cand.name);
|
|
472
|
+
if (a && b) rows.push([metric(a), metric(b)]);
|
|
473
|
+
}
|
|
474
|
+
return rows;
|
|
475
|
+
};
|
|
476
|
+
const lcp = (t) => t.sample.lcp;
|
|
477
|
+
const gateStart = (t) => t.criticalPath.breakdown?.beforeGateMs ?? null;
|
|
478
|
+
const gateAfterDoc = (t) => t.criticalPath.breakdown?.gateAfterDocumentMs ?? null;
|
|
479
|
+
const docEnd = (t) => t.criticalPath.breakdown?.documentEndMs ?? null;
|
|
480
|
+
const afterGate = (t) => t.criticalPath.breakdown?.afterGateMs ?? null;
|
|
481
|
+
const outlierPairs = new Set([
|
|
482
|
+
...perVariant[base.name].serverOutlierPairs,
|
|
483
|
+
...perVariant[cand.name].serverOutlierPairs,
|
|
484
|
+
]);
|
|
485
|
+
comparisons.push({
|
|
486
|
+
base: base.name,
|
|
487
|
+
candidate: cand.name,
|
|
488
|
+
lcp: pairedDelta(pairsOf(lcp)),
|
|
489
|
+
lcpWithoutServerOutliers: outlierPairs.size ? pairedDelta(pairsOf(lcp, outlierPairs)) : null,
|
|
490
|
+
gateStart: pairedDelta(pairsOf(gateStart)),
|
|
491
|
+
gateAfterDocument: pairedDelta(pairsOf(gateAfterDoc)),
|
|
492
|
+
documentEnd: pairedDelta(pairsOf(docEnd)),
|
|
493
|
+
afterGate: pairedDelta(pairsOf(afterGate)),
|
|
494
|
+
excludedPairs: [...outlierPairs],
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
schemaVersion: 1,
|
|
499
|
+
mode: 'field',
|
|
500
|
+
generatedAtMs: Date.now(),
|
|
501
|
+
config: describeConfig(config, redact),
|
|
502
|
+
cut,
|
|
503
|
+
throttle,
|
|
504
|
+
invalidRuns: invalid,
|
|
505
|
+
variants: perVariant,
|
|
506
|
+
comparisons,
|
|
507
|
+
limits: [
|
|
508
|
+
'one account, one network, one machine - not a population (use field RUM for that)',
|
|
509
|
+
'the gate is a heuristic unless "gate" is set in the config',
|
|
510
|
+
'coverage "paint" fields mean "at the cut"; the snapshot is async, so a few ms of later work can land in it',
|
|
511
|
+
'module attribution needs a local build whose chunks match the served bytes (see buildMatch)',
|
|
512
|
+
],
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function printFieldReport(report, summaries, config) {
|
|
517
|
+
const names = Object.keys(report.variants);
|
|
518
|
+
console.log('\nmedians (valid runs):');
|
|
519
|
+
console.log(
|
|
520
|
+
` ${'variant'.padEnd(12)} ${'LCP'.padStart(7)} ${'doc end'.padStart(8)} ${'gate start'.padStart(11)} ${'after doc'.padStart(10)} ${'gate ttfb'.padStart(10)} ${'after gate'.padStart(11)} valid`,
|
|
521
|
+
);
|
|
522
|
+
for (const name of names) {
|
|
523
|
+
const v = report.variants[name];
|
|
524
|
+
const m = v.criticalPath?.medians ?? {};
|
|
525
|
+
console.log(
|
|
526
|
+
` ${name.padEnd(12)} ${msStr(v.lcpMs).padStart(7)} ${msStr(m.documentEndMs).padStart(8)} ${msStr(m.beforeGateMs).padStart(11)} ${msStr(m.gateAfterDocumentMs).padStart(10)} ${msStr(m.ttfbMs).padStart(10)} ${msStr(m.afterGateMs).padStart(11)} ${v.validRuns}/${v.validRuns + v.invalidRuns}`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
for (const name of names) {
|
|
530
|
+
const cp = report.variants[name].criticalPath;
|
|
531
|
+
if (cp)
|
|
532
|
+
console.log(
|
|
533
|
+
` ${name} gate: ${shortUrl(cp.gate.url, config.origin)} (${cp.gate.runs}/${cp.gate.of} runs)`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
const fmt = (d) =>
|
|
537
|
+
d
|
|
538
|
+
? `${d.medianDeltaMs > 0 ? '+' : ''}${d.medianDeltaMs}ms [90% ${d.ci90Ms[0]}..${d.ci90Ms[1]}], faster in ${d.candidateFaster}/${d.pairs}`
|
|
539
|
+
: 'n/a';
|
|
540
|
+
for (const c of report.comparisons) {
|
|
541
|
+
console.log(`\n${c.candidate} vs ${c.base} (paired, negative = ${c.candidate} faster):`);
|
|
542
|
+
console.log(` LCP ${fmt(c.lcp)}`);
|
|
543
|
+
if (c.lcpWithoutServerOutliers) {
|
|
544
|
+
console.log(
|
|
545
|
+
` LCP w/o slow-server pairs ${c.excludedPairs.join(',')}: ${fmt(c.lcpWithoutServerOutliers)}`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
console.log(
|
|
549
|
+
` document end ${fmt(c.documentEnd)} (HTML response: server/worker, not the bundle)`,
|
|
550
|
+
);
|
|
551
|
+
console.log(
|
|
552
|
+
` gate start ${fmt(c.gateStart)} (client time before the request LCP waits for)`,
|
|
553
|
+
);
|
|
554
|
+
console.log(
|
|
555
|
+
` gate - doc ${fmt(c.gateAfterDocument)} (the same, measured from the end of the HTML)`,
|
|
556
|
+
);
|
|
557
|
+
console.log(` after gate ${fmt(c.afterGate)} (client time from that response to LCP)`);
|
|
558
|
+
}
|
|
559
|
+
for (const name of names) {
|
|
560
|
+
const cp = report.variants[name].criticalPath;
|
|
561
|
+
if (!cp?.medianRunChain?.length) continue;
|
|
562
|
+
console.log(
|
|
563
|
+
`\n${name}: what the gate request waited for (median run, pair ${cp.medianRunPair}), newest first:`,
|
|
564
|
+
);
|
|
565
|
+
if (cp.chainSource) console.log(` (${cp.chainSource})`);
|
|
566
|
+
for (const step of cp.medianRunChain) {
|
|
567
|
+
console.log(
|
|
568
|
+
` ${String(step.type ?? '?').padEnd(9)} ${msStr(step.startMs).padStart(7)} -> ${msStr(step.endMs).padStart(7)} ${shortUrl(step.url, config.origin)}`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
for (const name of names) {
|
|
573
|
+
const cov = summaries[name].coverage;
|
|
574
|
+
if (!cov) continue;
|
|
575
|
+
const f = cov.field;
|
|
576
|
+
const bm = f.buildMatch;
|
|
577
|
+
console.log(`\n${name}: coverage at ${f.cut} (${msStr(f.cutMs)}):`);
|
|
578
|
+
console.log(
|
|
579
|
+
` scripts fetched or run before the cut: ${f.preCut.scripts}, ${mb(f.preCut.bytes)}, executed ${mb(f.preCut.executedBytes)} (${f.preCut.bytes ? Math.round((f.preCut.executedBytes / f.preCut.bytes) * 100) : 0}%)`,
|
|
580
|
+
);
|
|
581
|
+
console.log(' coldest chunks at the cut (bytes not executed by then):');
|
|
582
|
+
for (const row of f.chunkCoverage
|
|
583
|
+
.filter((r) => r.fetchedBeforeCut || r.cutBytes > 0)
|
|
584
|
+
.slice(0, 8)) {
|
|
585
|
+
console.log(
|
|
586
|
+
` ${mb(row.bytes - row.cutBytes).padStart(7)} cold of ${mb(row.bytes).padStart(7)} fetch ${msStr(row.fetchStartMs).padStart(7)} ${row.file}`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
console.log(
|
|
590
|
+
` build match: ${bm.matchedScripts}/${bm.scripts} scripts, ${mb(bm.matchedBytes)} of ${mb(bm.bytes)}` +
|
|
591
|
+
` ${JSON.stringify(bm.byStatus)}${bm.build ? '' : ' (no "build" in the config for this variant)'}`,
|
|
592
|
+
);
|
|
593
|
+
if (cov.coldAtPaint.length) {
|
|
594
|
+
console.log(' coldest modules at the cut:');
|
|
595
|
+
for (const m of cov.coldAtPaint.slice(0, 8)) {
|
|
596
|
+
console.log(
|
|
597
|
+
` ${mb(m.coldBytes).padStart(7)} cold ${m.source}${m.framework ? ' (framework)' : ''}`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
} else if (bm.build) {
|
|
601
|
+
console.log(' module attribution: none - no served chunk matched the local build bytes');
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (report.invalidRuns.length) {
|
|
605
|
+
console.log(
|
|
606
|
+
`\ninvalid runs (retried): ${report.invalidRuns.length} - ${[...new Set(report.invalidRuns.map((r) => r.reason))].join('; ')}`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
console.log(
|
|
610
|
+
'\nlimits: one account, one network, one machine. Judge a change by the paired deltas and their intervals.',
|
|
611
|
+
);
|
|
612
|
+
}
|