@decocms/start 0.37.3 → 0.39.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/package.json +2 -1
- package/scripts/analyze-traces.mjs +1117 -0
- package/src/admin/index.ts +2 -0
- package/src/admin/invoke.ts +53 -5
- package/src/admin/setup.ts +7 -1
- package/src/apps/autoconfig.ts +50 -72
- package/src/sdk/invoke.ts +123 -12
- package/src/sdk/requestContext.ts +42 -0
- package/src/sdk/setupApps.ts +211 -0
- package/src/sdk/workerEntry.ts +6 -0
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Chrome Performance Trace Comparator
|
|
4
|
+
* Compares two Chrome DevTools traces side-by-side for performance analysis.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node analyze-traces.mjs <traceA.gz> <traceB.gz> [labelA] [labelB] [flags]
|
|
8
|
+
*
|
|
9
|
+
* Flags:
|
|
10
|
+
* --all Include everything (extensions + third-party)
|
|
11
|
+
* --first-party Only site code: filters out GTM, analytics, ads, chat, etc.
|
|
12
|
+
* --output <name> Write results to <name>.md as markdown
|
|
13
|
+
* --ai Generate AI context file with raw data + analysis prompt
|
|
14
|
+
* (default) Filters extensions, includes third-party scripts
|
|
15
|
+
*
|
|
16
|
+
* Examples:
|
|
17
|
+
* node analyze-traces.mjs worker.gz fallback.gz Worker Fallback
|
|
18
|
+
* node analyze-traces.mjs worker.gz fallback.gz Worker Fallback --first-party
|
|
19
|
+
* node analyze-traces.mjs worker.gz fallback.gz Worker Fallback --output report
|
|
20
|
+
* node analyze-traces.mjs worker.gz fallback.gz --all --output results
|
|
21
|
+
* node analyze-traces.mjs worker.gz fallback.gz Worker Fallback --ai
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
24
|
+
import { gunzipSync } from "zlib";
|
|
25
|
+
import { basename } from "path";
|
|
26
|
+
|
|
27
|
+
// ── CLI parsing ──────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
|
|
31
|
+
// Parse --output <name>
|
|
32
|
+
let outputFile = null;
|
|
33
|
+
const outputIdx = args.indexOf("--output");
|
|
34
|
+
if (outputIdx !== -1 && args[outputIdx + 1]) {
|
|
35
|
+
outputFile = args[outputIdx + 1];
|
|
36
|
+
if (!outputFile.endsWith(".md")) outputFile += ".md";
|
|
37
|
+
args.splice(outputIdx, 2);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
41
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
42
|
+
|
|
43
|
+
const [pathA, pathB, labelA, labelB] = positional;
|
|
44
|
+
|
|
45
|
+
if (!pathA || !pathB) {
|
|
46
|
+
console.error(
|
|
47
|
+
"Usage: node analyze-traces.mjs <traceA.gz> <traceB.gz> [labelA] [labelB] [--first-party|--all] [--output name]"
|
|
48
|
+
);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const MODE_ALL = flags.has("--all");
|
|
53
|
+
const MODE_FIRST_PARTY = flags.has("--first-party");
|
|
54
|
+
const MODE_AI = flags.has("--ai");
|
|
55
|
+
const modeLabel = MODE_ALL
|
|
56
|
+
? "ALL (incl. extensions)"
|
|
57
|
+
: MODE_FIRST_PARTY
|
|
58
|
+
? "FIRST-PARTY only"
|
|
59
|
+
: "default (excl. extensions)";
|
|
60
|
+
|
|
61
|
+
const nameA = labelA || basename(pathA).replace(/\.(json\.)?gz$/, "");
|
|
62
|
+
const nameB = labelB || basename(pathB).replace(/\.(json\.)?gz$/, "");
|
|
63
|
+
|
|
64
|
+
// ── filter helpers ───────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const isExtension = (url) =>
|
|
67
|
+
url &&
|
|
68
|
+
(url.includes("chrome-extension://") || url.includes("moz-extension://"));
|
|
69
|
+
|
|
70
|
+
// Known third-party domains/patterns
|
|
71
|
+
const THIRD_PARTY_PATTERNS = [
|
|
72
|
+
"googletagmanager.com",
|
|
73
|
+
"google-analytics.com",
|
|
74
|
+
"analytics.google.com",
|
|
75
|
+
"gtag/js",
|
|
76
|
+
"googlesyndication.com",
|
|
77
|
+
"googleads.g.doubleclick.net",
|
|
78
|
+
"google.com/ccm",
|
|
79
|
+
"connect.facebook.net",
|
|
80
|
+
"facebook.com/tr",
|
|
81
|
+
"facebook.com/privacy_sandbox",
|
|
82
|
+
"analytics.tiktok.com",
|
|
83
|
+
"tiktok.com/i18n/pixel",
|
|
84
|
+
"bat.bing.com",
|
|
85
|
+
"clarity.ms",
|
|
86
|
+
"scripts.clarity.ms",
|
|
87
|
+
"hotjar.com",
|
|
88
|
+
"script.hotjar.com",
|
|
89
|
+
"scarabresearch.com",
|
|
90
|
+
"creativecdn.com",
|
|
91
|
+
"cdn.pn.vg",
|
|
92
|
+
"osp-assets.pn.vg",
|
|
93
|
+
"ilabspush",
|
|
94
|
+
"pmweb.com",
|
|
95
|
+
"lilstts.com",
|
|
96
|
+
"onedollarstats.com",
|
|
97
|
+
"push-webchat",
|
|
98
|
+
"storage.googleapis.com/push-webchat",
|
|
99
|
+
"weni-sp-integrations",
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
function isThirdParty(url) {
|
|
103
|
+
if (!url) return false;
|
|
104
|
+
return THIRD_PARTY_PATTERNS.some((p) => url.includes(p));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Returns true if this URL should be excluded based on current mode */
|
|
108
|
+
function shouldExclude(url) {
|
|
109
|
+
if (!url) return false;
|
|
110
|
+
if (!MODE_ALL && isExtension(url)) return true;
|
|
111
|
+
if (MODE_FIRST_PARTY && isThirdParty(url)) return true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function shouldExcludeEvent(e) {
|
|
116
|
+
const url =
|
|
117
|
+
e.args?.data?.url ||
|
|
118
|
+
e.args?.data?.scriptName ||
|
|
119
|
+
e.args?.data?.sourceURL ||
|
|
120
|
+
"";
|
|
121
|
+
return shouldExclude(url);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function loadTrace(path) {
|
|
127
|
+
const gz = readFileSync(path);
|
|
128
|
+
const json = gunzipSync(gz).toString("utf-8");
|
|
129
|
+
const data = JSON.parse(json);
|
|
130
|
+
return Array.isArray(data) ? data : data.traceEvents || [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function navStart(events) {
|
|
134
|
+
return (
|
|
135
|
+
events.find(
|
|
136
|
+
(e) =>
|
|
137
|
+
e.name === "navigationStart" ||
|
|
138
|
+
(e.cat === "blink.user_timing" && e.name === "navigationStart")
|
|
139
|
+
)?.ts ?? null
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── metric extractors ────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function timingMarks(events, ns) {
|
|
146
|
+
const names = [
|
|
147
|
+
"firstPaint",
|
|
148
|
+
"firstContentfulPaint",
|
|
149
|
+
"largestContentfulPaint::Candidate",
|
|
150
|
+
"domInteractive",
|
|
151
|
+
"domContentLoadedEventEnd",
|
|
152
|
+
"domComplete",
|
|
153
|
+
"loadEventEnd",
|
|
154
|
+
];
|
|
155
|
+
const marks = {};
|
|
156
|
+
for (const e of events) {
|
|
157
|
+
if (!e.cat?.includes("blink.user_timing") && !e.cat?.includes("loading"))
|
|
158
|
+
continue;
|
|
159
|
+
for (const n of names) {
|
|
160
|
+
if (e.name === n || e.name.startsWith(n)) {
|
|
161
|
+
const ms = (e.ts - ns) / 1000;
|
|
162
|
+
if (!marks[n] || ms > marks[n]) marks[n] = ms;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const e of events) {
|
|
167
|
+
if (e.name === "firstPaint" || e.name === "firstContentfulPaint") {
|
|
168
|
+
const ms = (e.ts - ns) / 1000;
|
|
169
|
+
if (!marks[e.name] || ms > marks[e.name]) marks[e.name] = ms;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return marks;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function cls(events) {
|
|
176
|
+
let score = 0;
|
|
177
|
+
const shifts = [];
|
|
178
|
+
for (const e of events) {
|
|
179
|
+
if (
|
|
180
|
+
e.name === "LayoutShift" &&
|
|
181
|
+
e.args?.data?.is_main_frame !== false &&
|
|
182
|
+
!e.args?.data?.had_recent_input
|
|
183
|
+
) {
|
|
184
|
+
const s = e.args.data.score || 0;
|
|
185
|
+
score += s;
|
|
186
|
+
if (s > 0.001) shifts.push({ score: s, sources: e.args.data.sources });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { score, shifts };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function tbt(events) {
|
|
193
|
+
let total = 0;
|
|
194
|
+
for (const e of events) {
|
|
195
|
+
if (
|
|
196
|
+
(e.name === "RunTask" || e.name === "ThreadControllerImpl::RunTask") &&
|
|
197
|
+
e.dur > 50000 &&
|
|
198
|
+
!shouldExcludeEvent(e)
|
|
199
|
+
) {
|
|
200
|
+
total += e.dur - 50000;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return total / 1000;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function longTasks(events, ns) {
|
|
207
|
+
const tasks = [];
|
|
208
|
+
for (const e of events) {
|
|
209
|
+
if (
|
|
210
|
+
(e.name === "RunTask" || e.name === "ThreadControllerImpl::RunTask") &&
|
|
211
|
+
e.dur > 50000
|
|
212
|
+
) {
|
|
213
|
+
const taskStart = e.ts;
|
|
214
|
+
const taskEnd = e.ts + e.dur;
|
|
215
|
+
const childScripts = events.filter(
|
|
216
|
+
(c) =>
|
|
217
|
+
c.name === "EvaluateScript" &&
|
|
218
|
+
c.ts >= taskStart &&
|
|
219
|
+
c.ts <= taskEnd &&
|
|
220
|
+
c.args?.data?.url
|
|
221
|
+
);
|
|
222
|
+
const allExcluded =
|
|
223
|
+
childScripts.length > 0 &&
|
|
224
|
+
childScripts.every((c) => shouldExclude(c.args.data.url));
|
|
225
|
+
if (!allExcluded) {
|
|
226
|
+
const mainScript = childScripts.find((c) => !shouldExclude(c.args.data.url));
|
|
227
|
+
tasks.push({
|
|
228
|
+
ts_ms: (e.ts - ns) / 1000,
|
|
229
|
+
dur_ms: e.dur / 1000,
|
|
230
|
+
script: mainScript
|
|
231
|
+
? (mainScript.args.data.url || "").split("/").slice(-2).join("/")
|
|
232
|
+
: "",
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return tasks.sort((a, b) => b.dur_ms - a.dur_ms);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function layoutTree(events) {
|
|
241
|
+
let count = 0,
|
|
242
|
+
total = 0;
|
|
243
|
+
for (const e of events) {
|
|
244
|
+
if (e.name === "UpdateLayoutTree" && e.dur > 1000) {
|
|
245
|
+
count++;
|
|
246
|
+
total += e.dur;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { count, total_ms: total / 1000 };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function v8Compile(events) {
|
|
253
|
+
let count = 0,
|
|
254
|
+
total = 0;
|
|
255
|
+
const top = [];
|
|
256
|
+
for (const e of events) {
|
|
257
|
+
if (
|
|
258
|
+
(e.name === "v8.compile" || e.name === "V8.CompileCode") &&
|
|
259
|
+
e.dur > 1000 &&
|
|
260
|
+
!shouldExcludeEvent(e)
|
|
261
|
+
) {
|
|
262
|
+
count++;
|
|
263
|
+
total += e.dur;
|
|
264
|
+
top.push({
|
|
265
|
+
dur_ms: e.dur / 1000,
|
|
266
|
+
url: (e.args?.data?.url || "inline").split("/").slice(-2).join("/"),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
top.sort((a, b) => b.dur_ms - a.dur_ms);
|
|
271
|
+
return { count, total_ms: total / 1000, top: top.slice(0, 10) };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function scriptEval(events, ns) {
|
|
275
|
+
const scripts = [];
|
|
276
|
+
for (const e of events) {
|
|
277
|
+
if (
|
|
278
|
+
e.name === "EvaluateScript" &&
|
|
279
|
+
e.dur > 5000 &&
|
|
280
|
+
!shouldExcludeEvent(e)
|
|
281
|
+
) {
|
|
282
|
+
scripts.push({
|
|
283
|
+
url: (e.args?.data?.url || "inline").split("/").slice(-2).join("/"),
|
|
284
|
+
fullUrl: e.args?.data?.url || "inline",
|
|
285
|
+
ts_ms: (e.ts - ns) / 1000,
|
|
286
|
+
dur_ms: e.dur / 1000,
|
|
287
|
+
isThirdParty: isThirdParty(e.args?.data?.url || ""),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return scripts.sort((a, b) => b.dur_ms - a.dur_ms).slice(0, 15);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function paintEvents(events) {
|
|
295
|
+
let count = 0,
|
|
296
|
+
total = 0;
|
|
297
|
+
for (const e of events) {
|
|
298
|
+
if (e.name === "Paint" && e.dur > 500) {
|
|
299
|
+
count++;
|
|
300
|
+
total += e.dur;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { count, total_ms: total / 1000 };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function htmlDoc(events) {
|
|
307
|
+
const htmlResps = events.filter(
|
|
308
|
+
(e) =>
|
|
309
|
+
e.name === "ResourceReceiveResponse" &&
|
|
310
|
+
e.args?.data?.mimeType?.includes("text/html")
|
|
311
|
+
);
|
|
312
|
+
for (const resp of htmlResps) {
|
|
313
|
+
const id = resp.args.data.requestId;
|
|
314
|
+
const send = events.find(
|
|
315
|
+
(e) =>
|
|
316
|
+
e.name === "ResourceSendRequest" && e.args?.data?.requestId === id
|
|
317
|
+
);
|
|
318
|
+
if (!send) continue;
|
|
319
|
+
const url = send.args.data.url || "";
|
|
320
|
+
if (isExtension(url)) continue;
|
|
321
|
+
if (url.includes("sw_iframe") || url.includes("service_worker")) continue;
|
|
322
|
+
const finish = events.find(
|
|
323
|
+
(e) => e.name === "ResourceFinish" && e.args?.data?.requestId === id
|
|
324
|
+
);
|
|
325
|
+
return {
|
|
326
|
+
url,
|
|
327
|
+
encoded_kb: (finish?.args?.data?.encodedDataLength || 0) / 1024,
|
|
328
|
+
decoded_kb: (finish?.args?.data?.decodedBodyLength || 0) / 1024,
|
|
329
|
+
ttfb_ms: (resp.ts - send.ts) / 1000,
|
|
330
|
+
total_ms: finish ? (finish.ts - send.ts) / 1000 : 0,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function serverFnPayload(events) {
|
|
337
|
+
const sends = events.filter(
|
|
338
|
+
(e) =>
|
|
339
|
+
e.name === "ResourceSendRequest" &&
|
|
340
|
+
(e.args?.data?.url?.includes("_serverFn") ||
|
|
341
|
+
e.args?.data?.url?.includes("deco/render"))
|
|
342
|
+
);
|
|
343
|
+
let encoded = 0,
|
|
344
|
+
decoded = 0,
|
|
345
|
+
count = 0;
|
|
346
|
+
for (const s of sends) {
|
|
347
|
+
const id = s.args.data.requestId;
|
|
348
|
+
const finish = events.find(
|
|
349
|
+
(e) => e.name === "ResourceFinish" && e.args?.data?.requestId === id
|
|
350
|
+
);
|
|
351
|
+
if (finish) {
|
|
352
|
+
encoded += finish.args.data.encodedDataLength || 0;
|
|
353
|
+
decoded += finish.args.data.decodedBodyLength || 0;
|
|
354
|
+
}
|
|
355
|
+
count++;
|
|
356
|
+
}
|
|
357
|
+
return { count, encoded_kb: encoded / 1024, decoded_kb: decoded / 1024 };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function imageStats(events, ns) {
|
|
361
|
+
const imgDomains = [
|
|
362
|
+
"vtexassets",
|
|
363
|
+
"decoims",
|
|
364
|
+
"decoazn",
|
|
365
|
+
"decocache",
|
|
366
|
+
"deco-sites-assets",
|
|
367
|
+
];
|
|
368
|
+
const sends = events.filter((e) => {
|
|
369
|
+
if (e.name !== "ResourceSendRequest") return false;
|
|
370
|
+
const url = e.args?.data?.url || "";
|
|
371
|
+
return imgDomains.some((d) => url.includes(d));
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const pivot = events.find(
|
|
375
|
+
(e) =>
|
|
376
|
+
e.name === "ResourceSendRequest" &&
|
|
377
|
+
(e.args?.data?.url?.includes("_serverFn") ||
|
|
378
|
+
e.args?.data?.url?.includes("deco/render"))
|
|
379
|
+
);
|
|
380
|
+
const pivotTs = pivot ? (pivot.ts - ns) / 1000 : Infinity;
|
|
381
|
+
|
|
382
|
+
let before = 0,
|
|
383
|
+
after = 0,
|
|
384
|
+
totalEncoded = 0;
|
|
385
|
+
for (const s of sends) {
|
|
386
|
+
const ts = (s.ts - ns) / 1000;
|
|
387
|
+
const id = s.args.data.requestId;
|
|
388
|
+
const finish = events.find(
|
|
389
|
+
(e) => e.name === "ResourceFinish" && e.args?.data?.requestId === id
|
|
390
|
+
);
|
|
391
|
+
totalEncoded += finish?.args?.data?.encodedDataLength || 0;
|
|
392
|
+
if (ts < pivotTs) before++;
|
|
393
|
+
else after++;
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
total: sends.length,
|
|
397
|
+
before_deferred: before,
|
|
398
|
+
after_deferred: after,
|
|
399
|
+
total_kb: totalEncoded / 1024,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function jsBundleSize(events) {
|
|
404
|
+
const sends = events.filter(
|
|
405
|
+
(e) =>
|
|
406
|
+
e.name === "ResourceSendRequest" &&
|
|
407
|
+
e.args?.data?.url &&
|
|
408
|
+
/\.(js|mjs)(\?|$)/.test(e.args.data.url) &&
|
|
409
|
+
!shouldExclude(e.args.data.url)
|
|
410
|
+
);
|
|
411
|
+
const finishes = {};
|
|
412
|
+
for (const e of events) {
|
|
413
|
+
if (e.name === "ResourceFinish" && e.args?.data) {
|
|
414
|
+
finishes[e.args.data.requestId] = e.args.data;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let total = 0,
|
|
419
|
+
firstPartyTotal = 0,
|
|
420
|
+
thirdPartyTotal = 0;
|
|
421
|
+
const bundles = [];
|
|
422
|
+
for (const s of sends) {
|
|
423
|
+
const f = finishes[s.args.data.requestId];
|
|
424
|
+
const size = f?.encodedDataLength || 0;
|
|
425
|
+
const url = s.args.data.url;
|
|
426
|
+
const is3p = isThirdParty(url);
|
|
427
|
+
total += size;
|
|
428
|
+
if (is3p) thirdPartyTotal += size;
|
|
429
|
+
else firstPartyTotal += size;
|
|
430
|
+
bundles.push({
|
|
431
|
+
url,
|
|
432
|
+
kb: size / 1024,
|
|
433
|
+
isThirdParty: is3p,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
bundles.sort((a, b) => b.kb - a.kb);
|
|
437
|
+
return {
|
|
438
|
+
count: sends.length,
|
|
439
|
+
total_kb: total / 1024,
|
|
440
|
+
firstParty_kb: firstPartyTotal / 1024,
|
|
441
|
+
thirdParty_kb: thirdPartyTotal / 1024,
|
|
442
|
+
top: bundles.slice(0, 15),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function requestCount(events) {
|
|
447
|
+
return events.filter(
|
|
448
|
+
(e) =>
|
|
449
|
+
e.name === "ResourceSendRequest" &&
|
|
450
|
+
!shouldExclude(e.args?.data?.url || "")
|
|
451
|
+
).length;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function domainBreakdown(events) {
|
|
455
|
+
const domains = {};
|
|
456
|
+
for (const e of events) {
|
|
457
|
+
if (e.name !== "ResourceSendRequest" || !e.args?.data?.url) continue;
|
|
458
|
+
if (shouldExclude(e.args.data.url)) continue;
|
|
459
|
+
try {
|
|
460
|
+
const d = new URL(e.args.data.url).hostname;
|
|
461
|
+
if (d) domains[d] = (domains[d] || 0) + 1;
|
|
462
|
+
} catch {}
|
|
463
|
+
}
|
|
464
|
+
return Object.entries(domains)
|
|
465
|
+
.sort((a, b) => b[1] - a[1])
|
|
466
|
+
.map(([d, c]) => ({ domain: d, count: c }));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function lcpCandidate(events, ns) {
|
|
470
|
+
const candidates = events.filter(
|
|
471
|
+
(e) =>
|
|
472
|
+
e.name === "largestContentfulPaint::Candidate" ||
|
|
473
|
+
e.name === "LargestContentfulPaint::Candidate"
|
|
474
|
+
);
|
|
475
|
+
if (!candidates.length) return null;
|
|
476
|
+
const last = candidates[candidates.length - 1];
|
|
477
|
+
return {
|
|
478
|
+
ts_ms: (last.ts - ns) / 1000,
|
|
479
|
+
size: last.args?.data?.size || "?",
|
|
480
|
+
type: last.args?.data?.type || "?",
|
|
481
|
+
url: last.args?.data?.url || "",
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function parseHTMLStats(events) {
|
|
486
|
+
let count = 0,
|
|
487
|
+
total = 0;
|
|
488
|
+
for (const e of events) {
|
|
489
|
+
if (e.name === "ParseHTML" && e.dur) {
|
|
490
|
+
count++;
|
|
491
|
+
total += e.dur;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return { count, total_ms: total / 1000 };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ── extract all metrics from a trace ─────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
function extract(path) {
|
|
500
|
+
const events = loadTrace(path);
|
|
501
|
+
const ns = navStart(events);
|
|
502
|
+
if (!ns) {
|
|
503
|
+
console.error(`No navigationStart in ${path}`);
|
|
504
|
+
process.exit(1);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const marks = timingMarks(events, ns);
|
|
508
|
+
const doc = htmlDoc(events);
|
|
509
|
+
const sfn = serverFnPayload(events);
|
|
510
|
+
const imgs = imageStats(events, ns);
|
|
511
|
+
const js = jsBundleSize(events);
|
|
512
|
+
const layout = layoutTree(events);
|
|
513
|
+
const v8 = v8Compile(events);
|
|
514
|
+
const paint = paintEvents(events);
|
|
515
|
+
const lcp = lcpCandidate(events, ns);
|
|
516
|
+
const ph = parseHTMLStats(events);
|
|
517
|
+
const lt = longTasks(events, ns);
|
|
518
|
+
const scripts = scriptEval(events, ns);
|
|
519
|
+
const clsData = cls(events);
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
events: events.length,
|
|
523
|
+
marks,
|
|
524
|
+
doc,
|
|
525
|
+
serverFn: sfn,
|
|
526
|
+
images: imgs,
|
|
527
|
+
js,
|
|
528
|
+
requests: requestCount(events),
|
|
529
|
+
domains: domainBreakdown(events),
|
|
530
|
+
cls: clsData.score,
|
|
531
|
+
clsShifts: clsData.shifts,
|
|
532
|
+
tbt: tbt(events),
|
|
533
|
+
longTasks: lt,
|
|
534
|
+
layout,
|
|
535
|
+
v8,
|
|
536
|
+
paint,
|
|
537
|
+
lcp,
|
|
538
|
+
parseHTML: ph,
|
|
539
|
+
scripts,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── output buffer ────────────────────────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
const output = [];
|
|
546
|
+
const log = (s = "") => output.push(s);
|
|
547
|
+
|
|
548
|
+
// ── shared formatting ────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
function fmt(val, unit) {
|
|
551
|
+
if (val == null || val === "?") return "-";
|
|
552
|
+
const n = typeof val === "number" ? val : parseFloat(val);
|
|
553
|
+
if (isNaN(n)) return "-";
|
|
554
|
+
if (unit === "") return Number.isInteger(n) ? String(n) : n.toFixed(4);
|
|
555
|
+
return n.toFixed(1) + unit;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function delta(a, b, lowerBetter = true) {
|
|
559
|
+
if (a == null || b == null) return { text: "-", icon: " ", mdIcon: "" };
|
|
560
|
+
const na = typeof a === "number" ? a : parseFloat(a);
|
|
561
|
+
const nb = typeof b === "number" ? b : parseFloat(b);
|
|
562
|
+
if (isNaN(na) || isNaN(nb)) return { text: "-", icon: " ", mdIcon: "" };
|
|
563
|
+
const diff = na - nb;
|
|
564
|
+
const pct = nb !== 0 ? ((diff / nb) * 100).toFixed(0) : "∞";
|
|
565
|
+
const sign = diff > 0 ? "+" : "";
|
|
566
|
+
const better = lowerBetter ? diff < -0.001 : diff > 0.001;
|
|
567
|
+
const worse = lowerBetter ? diff > 0.001 : diff < -0.001;
|
|
568
|
+
const icon = better ? "✅" : worse ? "❌" : "🟰";
|
|
569
|
+
const mdIcon = better ? "✅" : worse ? "❌" : "🟰";
|
|
570
|
+
return { text: `${sign}${pct}%`, icon, mdIcon };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function row(label, valA, valB, unit = "ms", lowerBetter = true) {
|
|
574
|
+
const d = delta(valA, valB, lowerBetter);
|
|
575
|
+
return { label, a: fmt(valA, unit), b: fmt(valB, unit), delta: d.text, icon: d.icon, mdIcon: d.mdIcon };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function infoRow(label, valA, valB, unit = "ms") {
|
|
579
|
+
return { label, a: fmt(valA, unit), b: fmt(valB, unit), delta: "-", icon: "ℹ️", mdIcon: "ℹ️" };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── terminal table ───────────────────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
function printTable(title, rows) {
|
|
585
|
+
if (!rows.length) return;
|
|
586
|
+
|
|
587
|
+
const colW = {
|
|
588
|
+
icon: 2,
|
|
589
|
+
label: Math.max(20, ...rows.map((r) => r.label.length)),
|
|
590
|
+
a: Math.max(nameA.length, ...rows.map((r) => r.a.length)),
|
|
591
|
+
b: Math.max(nameB.length, ...rows.map((r) => r.b.length)),
|
|
592
|
+
delta: Math.max(5, ...rows.map((r) => r.delta.length)),
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const topBorder = `┌${"─".repeat(colW.icon + 2)}┬${"─".repeat(colW.label + 2)}┬${"─".repeat(colW.a + 2)}┬${"─".repeat(colW.b + 2)}┬${"─".repeat(colW.delta + 2)}┐`;
|
|
596
|
+
const sep = `├${"─".repeat(colW.icon + 2)}┼${"─".repeat(colW.label + 2)}┼${"─".repeat(colW.a + 2)}┼${"─".repeat(colW.b + 2)}┼${"─".repeat(colW.delta + 2)}┤`;
|
|
597
|
+
const bottomBorder = `└${"─".repeat(colW.icon + 2)}┴${"─".repeat(colW.label + 2)}┴${"─".repeat(colW.a + 2)}┴${"─".repeat(colW.b + 2)}┴${"─".repeat(colW.delta + 2)}┘`;
|
|
598
|
+
const pad = (s, w, align = "right") =>
|
|
599
|
+
align === "left" ? s.padEnd(w) : s.padStart(w);
|
|
600
|
+
|
|
601
|
+
log(`\n ${title}`);
|
|
602
|
+
log(topBorder);
|
|
603
|
+
log(`│ ${pad("", colW.icon, "left")} │ ${pad("Métrica", colW.label, "left")} │ ${pad(nameA, colW.a)} │ ${pad(nameB, colW.b)} │ ${pad("Delta", colW.delta)} │`);
|
|
604
|
+
log(sep);
|
|
605
|
+
for (const r of rows) {
|
|
606
|
+
log(`│ ${pad(r.icon, colW.icon, "left")} │ ${pad(r.label, colW.label, "left")} │ ${pad(r.a, colW.a)} │ ${pad(r.b, colW.b)} │ ${pad(r.delta, colW.delta)} │`);
|
|
607
|
+
}
|
|
608
|
+
log(bottomBorder);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function printList(title, items) {
|
|
612
|
+
if (!items.length) return;
|
|
613
|
+
log(`\n ${title}`);
|
|
614
|
+
for (const item of items) log(` ${item}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ── markdown table ───────────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
function mdTable(title, rows) {
|
|
620
|
+
if (!rows.length) return "";
|
|
621
|
+
const lines = [];
|
|
622
|
+
lines.push(`### ${title}\n`);
|
|
623
|
+
lines.push(`| | Métrica | ${nameA} | ${nameB} | Delta |`);
|
|
624
|
+
lines.push(`|---|---|---:|---:|---:|`);
|
|
625
|
+
for (const r of rows) {
|
|
626
|
+
lines.push(`| ${r.mdIcon} | ${r.label} | ${r.a} | ${r.b} | ${r.delta} |`);
|
|
627
|
+
}
|
|
628
|
+
lines.push("");
|
|
629
|
+
return lines.join("\n");
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function mdList(title, items) {
|
|
633
|
+
if (!items.length) return "";
|
|
634
|
+
const lines = [`**${title}**\n`];
|
|
635
|
+
for (const item of items) lines.push(`- ${item}`);
|
|
636
|
+
lines.push("");
|
|
637
|
+
return lines.join("\n");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function mdDetailBlock(title, sections) {
|
|
641
|
+
const lines = [`<details>\n<summary>${title}</summary>\n`];
|
|
642
|
+
for (const [label, items] of sections) {
|
|
643
|
+
lines.push(`**${label}:**\n`);
|
|
644
|
+
lines.push("```");
|
|
645
|
+
for (const item of items) lines.push(item);
|
|
646
|
+
lines.push("```\n");
|
|
647
|
+
}
|
|
648
|
+
lines.push("</details>\n");
|
|
649
|
+
return lines.join("\n");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ── main ─────────────────────────────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
log(`\n ⚡ Chrome Trace Comparison`);
|
|
655
|
+
log(` ${nameA} vs ${nameB}`);
|
|
656
|
+
log(` Mode: ${modeLabel}`);
|
|
657
|
+
if (outputFile) log(` Output: ${outputFile}`);
|
|
658
|
+
log("");
|
|
659
|
+
|
|
660
|
+
const a = extract(pathA);
|
|
661
|
+
const b = extract(pathB);
|
|
662
|
+
|
|
663
|
+
// ── build rows ───────────────────────────────────────────────────────────────
|
|
664
|
+
|
|
665
|
+
const cwvRows = [
|
|
666
|
+
row("FCP", a.marks.firstContentfulPaint, b.marks.firstContentfulPaint),
|
|
667
|
+
row("LCP", a.lcp?.ts_ms, b.lcp?.ts_ms),
|
|
668
|
+
row("CLS", a.cls, b.cls, "", true),
|
|
669
|
+
row("TBT", a.tbt, b.tbt),
|
|
670
|
+
];
|
|
671
|
+
|
|
672
|
+
const docRows = [
|
|
673
|
+
row("HTML encoded", a.doc?.encoded_kb, b.doc?.encoded_kb, "KB"),
|
|
674
|
+
row("HTML decoded", a.doc?.decoded_kb, b.doc?.decoded_kb, "KB"),
|
|
675
|
+
row("TTFB", a.doc?.ttfb_ms, b.doc?.ttfb_ms),
|
|
676
|
+
row("Doc download", a.doc?.total_ms, b.doc?.total_ms),
|
|
677
|
+
infoRow("_serverFn payload", a.serverFn.decoded_kb, b.serverFn.decoded_kb, "KB"),
|
|
678
|
+
infoRow("_serverFn calls", a.serverFn.count, b.serverFn.count, ""),
|
|
679
|
+
row("ParseHTML total", a.parseHTML.total_ms, b.parseHTML.total_ms),
|
|
680
|
+
];
|
|
681
|
+
|
|
682
|
+
const domRows = [
|
|
683
|
+
row("domInteractive", a.marks.domInteractive, b.marks.domInteractive),
|
|
684
|
+
row("domContentLoaded", a.marks.domContentLoadedEventEnd, b.marks.domContentLoadedEventEnd),
|
|
685
|
+
row("domComplete", a.marks.domComplete, b.marks.domComplete),
|
|
686
|
+
row("loadEventEnd", a.marks.loadEventEnd, b.marks.loadEventEnd),
|
|
687
|
+
];
|
|
688
|
+
|
|
689
|
+
const imgRows = [
|
|
690
|
+
row("Total images", a.images.total, b.images.total, "", true),
|
|
691
|
+
row("Before deferred", a.images.before_deferred, b.images.before_deferred, "", true),
|
|
692
|
+
row("After deferred", a.images.after_deferred, b.images.after_deferred, "", true),
|
|
693
|
+
row("Images size", a.images.total_kb, b.images.total_kb, "KB", true),
|
|
694
|
+
];
|
|
695
|
+
|
|
696
|
+
const jsRows = [
|
|
697
|
+
row("JS bundles", a.js.count, b.js.count, "", true),
|
|
698
|
+
row("Total JS size", a.js.total_kb, b.js.total_kb, "KB", true),
|
|
699
|
+
];
|
|
700
|
+
if (!MODE_ALL) {
|
|
701
|
+
jsRows.push(
|
|
702
|
+
row("1st-party JS", a.js.firstParty_kb, b.js.firstParty_kb, "KB", true),
|
|
703
|
+
row("3rd-party JS", a.js.thirdParty_kb, b.js.thirdParty_kb, "KB", true)
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const layoutRows = [
|
|
708
|
+
row("Long tasks (>50ms)", a.longTasks.length, b.longTasks.length, "", true),
|
|
709
|
+
row("LayoutTree count", a.layout.count, b.layout.count, "", true),
|
|
710
|
+
row("LayoutTree total", a.layout.total_ms, b.layout.total_ms),
|
|
711
|
+
row("Paint events", a.paint.count, b.paint.count, "", true),
|
|
712
|
+
row("Paint total", a.paint.total_ms, b.paint.total_ms),
|
|
713
|
+
];
|
|
714
|
+
|
|
715
|
+
const v8Rows = [
|
|
716
|
+
row("Compile count", a.v8.count, b.v8.count, "", true),
|
|
717
|
+
row("Compile total", a.v8.total_ms, b.v8.total_ms),
|
|
718
|
+
];
|
|
719
|
+
|
|
720
|
+
const netRows = [
|
|
721
|
+
row("Total requests", a.requests, b.requests, "", true),
|
|
722
|
+
];
|
|
723
|
+
|
|
724
|
+
// Domain rows
|
|
725
|
+
const allDomains = new Set([
|
|
726
|
+
...a.domains.map((d) => d.domain),
|
|
727
|
+
...b.domains.map((d) => d.domain),
|
|
728
|
+
]);
|
|
729
|
+
const domainData = [...allDomains]
|
|
730
|
+
.map((d) => {
|
|
731
|
+
const ac = a.domains.find((x) => x.domain === d)?.count || 0;
|
|
732
|
+
const bc = b.domains.find((x) => x.domain === d)?.count || 0;
|
|
733
|
+
return { domain: d, a: ac, b: bc, total: ac + bc };
|
|
734
|
+
})
|
|
735
|
+
.sort((x, y) => y.total - x.total)
|
|
736
|
+
.slice(0, 15);
|
|
737
|
+
const domainTableRows = domainData.map((d) =>
|
|
738
|
+
row(d.domain.slice(0, 40), d.a, d.b, "", true)
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
const aOnly = a.domains.filter((d) => !b.domains.find((x) => x.domain === d.domain)).map((d) => d.domain);
|
|
742
|
+
const bOnly = b.domains.filter((d) => !a.domains.find((x) => x.domain === d.domain)).map((d) => d.domain);
|
|
743
|
+
|
|
744
|
+
// Score
|
|
745
|
+
const scoredMetrics = [
|
|
746
|
+
{ label: "LCP", a: a.lcp?.ts_ms, b: b.lcp?.ts_ms, weight: 25 },
|
|
747
|
+
{ label: "CLS", a: a.cls, b: b.cls, weight: 25 },
|
|
748
|
+
{ label: "TBT", a: a.tbt, b: b.tbt, weight: 30 },
|
|
749
|
+
{ label: "Requests", a: a.requests, b: b.requests, weight: 10 },
|
|
750
|
+
{ label: "JS Size", a: a.js.total_kb, b: b.js.total_kb, weight: 10 },
|
|
751
|
+
];
|
|
752
|
+
|
|
753
|
+
let wins = 0, losses = 0, ties = 0;
|
|
754
|
+
for (const m of scoredMetrics) {
|
|
755
|
+
if (m.a == null || m.b == null) continue;
|
|
756
|
+
if (m.a < m.b - 0.001) wins++;
|
|
757
|
+
else if (m.a > m.b + 0.001) losses++;
|
|
758
|
+
else ties++;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const scoreA = scoredMetrics.reduce((sum, m) => {
|
|
762
|
+
if (m.a == null || m.b == null || m.b === 0) return sum;
|
|
763
|
+
const ratio = m.a / m.b;
|
|
764
|
+
return sum + (ratio < 1 ? m.weight : -m.weight * (ratio - 1));
|
|
765
|
+
}, 0);
|
|
766
|
+
|
|
767
|
+
const verdict =
|
|
768
|
+
scoreA > 10 ? `${nameA} is significantly better`
|
|
769
|
+
: scoreA > 0 ? `${nameA} is slightly better`
|
|
770
|
+
: scoreA < -10 ? `${nameB} is significantly better`
|
|
771
|
+
: scoreA < 0 ? `${nameB} is slightly better`
|
|
772
|
+
: "Roughly equal";
|
|
773
|
+
|
|
774
|
+
// ── terminal output ──────────────────────────────────────────────────────────
|
|
775
|
+
|
|
776
|
+
printTable("Core Web Vitals", cwvRows);
|
|
777
|
+
printTable("Document & SSR", docRows);
|
|
778
|
+
printTable("DOM Timing", domRows);
|
|
779
|
+
printTable("Images", imgRows);
|
|
780
|
+
printTable("JavaScript", jsRows);
|
|
781
|
+
printTable("Layout & Rendering", layoutRows);
|
|
782
|
+
printTable("V8 Compile", v8Rows);
|
|
783
|
+
printTable("Network", netRows);
|
|
784
|
+
printTable("Top Domains", domainTableRows);
|
|
785
|
+
|
|
786
|
+
if (aOnly.length) printList(`Domains ONLY in ${nameA}:`, aOnly);
|
|
787
|
+
if (bOnly.length) printList(`Domains ONLY in ${nameB}:`, bOnly);
|
|
788
|
+
|
|
789
|
+
if (a.scripts.length || b.scripts.length) {
|
|
790
|
+
log(`\n Top Script Evaluation`);
|
|
791
|
+
for (const [label, scripts] of [[nameA, a.scripts], [nameB, b.scripts]]) {
|
|
792
|
+
log(` ${label}:`);
|
|
793
|
+
for (const s of scripts.slice(0, 8)) {
|
|
794
|
+
const tag = s.isThirdParty ? " [3P]" : "";
|
|
795
|
+
log(` ${s.dur_ms.toFixed(1)}ms @${s.ts_ms.toFixed(0)}ms ${s.url}${tag}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (a.v8.top.length || b.v8.top.length) {
|
|
801
|
+
log(`\n Top V8 Compile`);
|
|
802
|
+
for (const [label, top] of [[nameA, a.v8.top], [nameB, b.v8.top]]) {
|
|
803
|
+
log(` ${label}:`);
|
|
804
|
+
for (const c of top.slice(0, 5)) {
|
|
805
|
+
log(` ${c.dur_ms.toFixed(1)}ms ${c.url}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (a.js.top.length || b.js.top.length) {
|
|
811
|
+
log(`\n Top JS Bundles by Size`);
|
|
812
|
+
for (const [label, top] of [[nameA, a.js.top], [nameB, b.js.top]]) {
|
|
813
|
+
log(` ${label}:`);
|
|
814
|
+
for (const j of top.slice(0, 10)) {
|
|
815
|
+
const short = j.url.length > 80 ? "..." + j.url.slice(-77) : j.url;
|
|
816
|
+
const tag = j.isThirdParty ? " [3P]" : "";
|
|
817
|
+
log(` ${j.kb.toFixed(1)}KB ${short}${tag}`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (a.longTasks.length || b.longTasks.length) {
|
|
823
|
+
log(`\n Long Tasks (>50ms)`);
|
|
824
|
+
for (const [label, tasks] of [[nameA, a.longTasks], [nameB, b.longTasks]]) {
|
|
825
|
+
log(` ${label}: ${tasks.length} tasks`);
|
|
826
|
+
for (const t of tasks.slice(0, 8)) {
|
|
827
|
+
const script = t.script ? ` ${t.script}` : "";
|
|
828
|
+
log(` ${t.dur_ms.toFixed(1)}ms @${t.ts_ms.toFixed(0)}ms${script}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
log(`\n ${"═".repeat(50)}`);
|
|
834
|
+
log(` SUMMARY`);
|
|
835
|
+
log(` ${"═".repeat(50)}`);
|
|
836
|
+
log(`\n ${nameA} wins: ${wins} | ${nameB} wins: ${losses} | ties: ${ties}`);
|
|
837
|
+
log(` Weighted score: ${scoreA.toFixed(1)} → ${verdict}`);
|
|
838
|
+
log("");
|
|
839
|
+
|
|
840
|
+
// Print terminal output
|
|
841
|
+
console.log(output.join("\n"));
|
|
842
|
+
|
|
843
|
+
// ── markdown output ──────────────────────────────────────────────────────────
|
|
844
|
+
|
|
845
|
+
if (outputFile) {
|
|
846
|
+
const md = [];
|
|
847
|
+
const date = new Date().toISOString().split("T")[0];
|
|
848
|
+
|
|
849
|
+
md.push(`# Performance Trace Comparison`);
|
|
850
|
+
md.push(`> **${nameA}** vs **${nameB}** | ${date} | Mode: ${modeLabel}\n`);
|
|
851
|
+
|
|
852
|
+
md.push(mdTable("Core Web Vitals", cwvRows));
|
|
853
|
+
md.push(mdTable("Document & SSR", docRows));
|
|
854
|
+
md.push(mdTable("DOM Timing", domRows));
|
|
855
|
+
md.push(mdTable("Images", imgRows));
|
|
856
|
+
md.push(mdTable("JavaScript", jsRows));
|
|
857
|
+
md.push(mdTable("Layout & Rendering", layoutRows));
|
|
858
|
+
md.push(mdTable("V8 Compile", v8Rows));
|
|
859
|
+
md.push(mdTable("Network", netRows));
|
|
860
|
+
md.push(mdTable("Top Domains", domainTableRows));
|
|
861
|
+
|
|
862
|
+
if (aOnly.length) md.push(mdList(`Domains only in ${nameA}`, aOnly));
|
|
863
|
+
if (bOnly.length) md.push(mdList(`Domains only in ${nameB}`, bOnly));
|
|
864
|
+
|
|
865
|
+
// Detail blocks
|
|
866
|
+
const scriptSections = [];
|
|
867
|
+
for (const [label, scripts] of [[nameA, a.scripts], [nameB, b.scripts]]) {
|
|
868
|
+
if (scripts.length) {
|
|
869
|
+
scriptSections.push([
|
|
870
|
+
label,
|
|
871
|
+
scripts.slice(0, 8).map((s) => {
|
|
872
|
+
const tag = s.isThirdParty ? " [3P]" : "";
|
|
873
|
+
return `${s.dur_ms.toFixed(1)}ms @${s.ts_ms.toFixed(0)}ms ${s.url}${tag}`;
|
|
874
|
+
}),
|
|
875
|
+
]);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (scriptSections.length) md.push(mdDetailBlock("Top Script Evaluation", scriptSections));
|
|
879
|
+
|
|
880
|
+
const jsSections = [];
|
|
881
|
+
for (const [label, top] of [[nameA, a.js.top], [nameB, b.js.top]]) {
|
|
882
|
+
if (top.length) {
|
|
883
|
+
jsSections.push([
|
|
884
|
+
label,
|
|
885
|
+
top.slice(0, 10).map((j) => {
|
|
886
|
+
const short = j.url.length > 80 ? "..." + j.url.slice(-77) : j.url;
|
|
887
|
+
const tag = j.isThirdParty ? " [3P]" : "";
|
|
888
|
+
return `${j.kb.toFixed(1)}KB ${short}${tag}`;
|
|
889
|
+
}),
|
|
890
|
+
]);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (jsSections.length) md.push(mdDetailBlock("Top JS Bundles by Size", jsSections));
|
|
894
|
+
|
|
895
|
+
const taskSections = [];
|
|
896
|
+
for (const [label, tasks] of [[nameA, a.longTasks], [nameB, b.longTasks]]) {
|
|
897
|
+
if (tasks.length) {
|
|
898
|
+
taskSections.push([
|
|
899
|
+
`${label} (${tasks.length} tasks)`,
|
|
900
|
+
tasks.slice(0, 8).map((t) => {
|
|
901
|
+
const script = t.script ? ` ${t.script}` : "";
|
|
902
|
+
return `${t.dur_ms.toFixed(1)}ms @${t.ts_ms.toFixed(0)}ms${script}`;
|
|
903
|
+
}),
|
|
904
|
+
]);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (taskSections.length) md.push(mdDetailBlock("Long Tasks (>50ms)", taskSections));
|
|
908
|
+
|
|
909
|
+
md.push(`---\n`);
|
|
910
|
+
md.push(`## Summary\n`);
|
|
911
|
+
md.push(`**${nameA}** wins: ${wins} | **${nameB}** wins: ${losses} | ties: ${ties}\n`);
|
|
912
|
+
md.push(`Weighted score: **${scoreA.toFixed(1)}** → ${verdict}\n`);
|
|
913
|
+
|
|
914
|
+
writeFileSync(outputFile, md.join("\n"), "utf-8");
|
|
915
|
+
console.log(`\n 📄 Report saved to ${outputFile}`);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ── AI context output ────────────────────────────────────────────────────────
|
|
919
|
+
|
|
920
|
+
if (MODE_AI) {
|
|
921
|
+
const aiFile = outputFile
|
|
922
|
+
? outputFile.replace(/\.md$/, "-ai-context.md")
|
|
923
|
+
: "trace-ai-context.md";
|
|
924
|
+
|
|
925
|
+
const date = new Date().toISOString().split("T")[0];
|
|
926
|
+
const ai = [];
|
|
927
|
+
|
|
928
|
+
ai.push(`# Performance Trace Analysis Context`);
|
|
929
|
+
ai.push(`> Generated ${date} | ${nameA} vs ${nameB} | Mode: ${modeLabel}\n`);
|
|
930
|
+
ai.push(`## Instructions\n`);
|
|
931
|
+
ai.push(`You are analyzing Chrome Performance traces comparing two versions of a website.`);
|
|
932
|
+
ai.push(`**${nameA}** is the variant being tested. **${nameB}** is the baseline/control.`);
|
|
933
|
+
ai.push(`Use the raw data below to:`);
|
|
934
|
+
ai.push(`1. Identify the top 3-5 performance wins and losses`);
|
|
935
|
+
ai.push(`2. Find root causes for regressions (CLS, TBT, long tasks, large bundles)`);
|
|
936
|
+
ai.push(`3. Suggest concrete, actionable fixes ranked by impact`);
|
|
937
|
+
ai.push(`4. Call out any third-party scripts causing disproportionate impact`);
|
|
938
|
+
ai.push(`5. Note any anomalies in the data (missing metrics, unexpected patterns)\n`);
|
|
939
|
+
|
|
940
|
+
// Raw metrics as structured data
|
|
941
|
+
ai.push(`## Raw Metrics\n`);
|
|
942
|
+
ai.push("```json");
|
|
943
|
+
|
|
944
|
+
const rawData = {
|
|
945
|
+
date,
|
|
946
|
+
mode: modeLabel,
|
|
947
|
+
traceA: { label: nameA, file: pathA },
|
|
948
|
+
traceB: { label: nameB, file: pathB },
|
|
949
|
+
coreWebVitals: {
|
|
950
|
+
[nameA]: {
|
|
951
|
+
FCP: a.marks.firstContentfulPaint ?? null,
|
|
952
|
+
LCP: a.lcp?.ts_ms ?? null,
|
|
953
|
+
CLS: a.cls,
|
|
954
|
+
TBT: a.tbt,
|
|
955
|
+
},
|
|
956
|
+
[nameB]: {
|
|
957
|
+
FCP: b.marks.firstContentfulPaint ?? null,
|
|
958
|
+
LCP: b.lcp?.ts_ms ?? null,
|
|
959
|
+
CLS: b.cls,
|
|
960
|
+
TBT: b.tbt,
|
|
961
|
+
},
|
|
962
|
+
},
|
|
963
|
+
document: {
|
|
964
|
+
[nameA]: {
|
|
965
|
+
htmlEncoded_kb: a.doc?.encoded_kb ?? null,
|
|
966
|
+
htmlDecoded_kb: a.doc?.decoded_kb ?? null,
|
|
967
|
+
ttfb_ms: a.doc?.ttfb_ms ?? null,
|
|
968
|
+
docDownload_ms: a.doc?.total_ms ?? null,
|
|
969
|
+
serverFnPayload_kb: a.serverFn.decoded_kb,
|
|
970
|
+
serverFnCalls: a.serverFn.count,
|
|
971
|
+
parseHTML_ms: a.parseHTML.total_ms,
|
|
972
|
+
},
|
|
973
|
+
[nameB]: {
|
|
974
|
+
htmlEncoded_kb: b.doc?.encoded_kb ?? null,
|
|
975
|
+
htmlDecoded_kb: b.doc?.decoded_kb ?? null,
|
|
976
|
+
ttfb_ms: b.doc?.ttfb_ms ?? null,
|
|
977
|
+
docDownload_ms: b.doc?.total_ms ?? null,
|
|
978
|
+
serverFnPayload_kb: b.serverFn.decoded_kb,
|
|
979
|
+
serverFnCalls: b.serverFn.count,
|
|
980
|
+
parseHTML_ms: b.parseHTML.total_ms,
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
domTiming: {
|
|
984
|
+
[nameA]: {
|
|
985
|
+
domInteractive: a.marks.domInteractive,
|
|
986
|
+
domContentLoaded: a.marks.domContentLoadedEventEnd,
|
|
987
|
+
domComplete: a.marks.domComplete,
|
|
988
|
+
loadEventEnd: a.marks.loadEventEnd,
|
|
989
|
+
},
|
|
990
|
+
[nameB]: {
|
|
991
|
+
domInteractive: b.marks.domInteractive,
|
|
992
|
+
domContentLoaded: b.marks.domContentLoadedEventEnd,
|
|
993
|
+
domComplete: b.marks.domComplete,
|
|
994
|
+
loadEventEnd: b.marks.loadEventEnd,
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
images: {
|
|
998
|
+
[nameA]: a.images,
|
|
999
|
+
[nameB]: b.images,
|
|
1000
|
+
},
|
|
1001
|
+
javascript: {
|
|
1002
|
+
[nameA]: {
|
|
1003
|
+
bundles: a.js.count,
|
|
1004
|
+
total_kb: a.js.total_kb,
|
|
1005
|
+
firstParty_kb: a.js.firstParty_kb,
|
|
1006
|
+
thirdParty_kb: a.js.thirdParty_kb,
|
|
1007
|
+
topBundles: a.js.top.slice(0, 10).map((j) => ({
|
|
1008
|
+
url: j.url.length > 120 ? "..." + j.url.slice(-117) : j.url,
|
|
1009
|
+
kb: j.kb,
|
|
1010
|
+
thirdParty: j.isThirdParty,
|
|
1011
|
+
})),
|
|
1012
|
+
},
|
|
1013
|
+
[nameB]: {
|
|
1014
|
+
bundles: b.js.count,
|
|
1015
|
+
total_kb: b.js.total_kb,
|
|
1016
|
+
firstParty_kb: b.js.firstParty_kb,
|
|
1017
|
+
thirdParty_kb: b.js.thirdParty_kb,
|
|
1018
|
+
topBundles: b.js.top.slice(0, 10).map((j) => ({
|
|
1019
|
+
url: j.url.length > 120 ? "..." + j.url.slice(-117) : j.url,
|
|
1020
|
+
kb: j.kb,
|
|
1021
|
+
thirdParty: j.isThirdParty,
|
|
1022
|
+
})),
|
|
1023
|
+
},
|
|
1024
|
+
},
|
|
1025
|
+
layout: {
|
|
1026
|
+
[nameA]: {
|
|
1027
|
+
longTasks: a.longTasks.length,
|
|
1028
|
+
longTasksDetail: a.longTasks.slice(0, 10).map((t) => ({
|
|
1029
|
+
dur_ms: t.dur_ms,
|
|
1030
|
+
ts_ms: t.ts_ms,
|
|
1031
|
+
script: t.script || null,
|
|
1032
|
+
})),
|
|
1033
|
+
layoutTreeCount: a.layout.count,
|
|
1034
|
+
layoutTreeTotal_ms: a.layout.total_ms,
|
|
1035
|
+
paintEvents: a.paint.count,
|
|
1036
|
+
paintTotal_ms: a.paint.total_ms,
|
|
1037
|
+
},
|
|
1038
|
+
[nameB]: {
|
|
1039
|
+
longTasks: b.longTasks.length,
|
|
1040
|
+
longTasksDetail: b.longTasks.slice(0, 10).map((t) => ({
|
|
1041
|
+
dur_ms: t.dur_ms,
|
|
1042
|
+
ts_ms: t.ts_ms,
|
|
1043
|
+
script: t.script || null,
|
|
1044
|
+
})),
|
|
1045
|
+
layoutTreeCount: b.layout.count,
|
|
1046
|
+
layoutTreeTotal_ms: b.layout.total_ms,
|
|
1047
|
+
paintEvents: b.paint.count,
|
|
1048
|
+
paintTotal_ms: b.paint.total_ms,
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
v8Compile: {
|
|
1052
|
+
[nameA]: {
|
|
1053
|
+
count: a.v8.count,
|
|
1054
|
+
total_ms: a.v8.total_ms,
|
|
1055
|
+
top: a.v8.top.slice(0, 8),
|
|
1056
|
+
},
|
|
1057
|
+
[nameB]: {
|
|
1058
|
+
count: b.v8.count,
|
|
1059
|
+
total_ms: b.v8.total_ms,
|
|
1060
|
+
top: b.v8.top.slice(0, 8),
|
|
1061
|
+
},
|
|
1062
|
+
},
|
|
1063
|
+
scriptEvaluation: {
|
|
1064
|
+
[nameA]: a.scripts.slice(0, 10).map((s) => ({
|
|
1065
|
+
url: s.url,
|
|
1066
|
+
dur_ms: s.dur_ms,
|
|
1067
|
+
ts_ms: s.ts_ms,
|
|
1068
|
+
thirdParty: s.isThirdParty,
|
|
1069
|
+
})),
|
|
1070
|
+
[nameB]: b.scripts.slice(0, 10).map((s) => ({
|
|
1071
|
+
url: s.url,
|
|
1072
|
+
dur_ms: s.dur_ms,
|
|
1073
|
+
ts_ms: s.ts_ms,
|
|
1074
|
+
thirdParty: s.isThirdParty,
|
|
1075
|
+
})),
|
|
1076
|
+
},
|
|
1077
|
+
network: {
|
|
1078
|
+
[nameA]: { totalRequests: a.requests, domains: a.domains.slice(0, 20) },
|
|
1079
|
+
[nameB]: { totalRequests: b.requests, domains: b.domains.slice(0, 20) },
|
|
1080
|
+
domainsOnlyInA: aOnly,
|
|
1081
|
+
domainsOnlyInB: bOnly,
|
|
1082
|
+
},
|
|
1083
|
+
summary: {
|
|
1084
|
+
wins: { [nameA]: wins, [nameB]: losses, ties },
|
|
1085
|
+
weightedScore: scoreA,
|
|
1086
|
+
verdict,
|
|
1087
|
+
},
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
ai.push(JSON.stringify(rawData, null, 2));
|
|
1091
|
+
ai.push("```\n");
|
|
1092
|
+
|
|
1093
|
+
// Also include the formatted comparison tables for quick reference
|
|
1094
|
+
ai.push(`## Formatted Comparison\n`);
|
|
1095
|
+
ai.push(`The tables below are the same data formatted for readability.\n`);
|
|
1096
|
+
|
|
1097
|
+
const tables = [
|
|
1098
|
+
["Core Web Vitals", cwvRows],
|
|
1099
|
+
["Document & SSR", docRows],
|
|
1100
|
+
["DOM Timing", domRows],
|
|
1101
|
+
["Images", imgRows],
|
|
1102
|
+
["JavaScript", jsRows],
|
|
1103
|
+
["Layout & Rendering", layoutRows],
|
|
1104
|
+
["V8 Compile", v8Rows],
|
|
1105
|
+
["Network", netRows],
|
|
1106
|
+
];
|
|
1107
|
+
for (const [title, rows] of tables) {
|
|
1108
|
+
ai.push(mdTable(title, rows));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
ai.push(`## Summary\n`);
|
|
1112
|
+
ai.push(`**${nameA}** wins: ${wins} | **${nameB}** wins: ${losses} | ties: ${ties}`);
|
|
1113
|
+
ai.push(`Weighted score: **${scoreA.toFixed(1)}** → ${verdict}\n`);
|
|
1114
|
+
|
|
1115
|
+
writeFileSync(aiFile, ai.join("\n"), "utf-8");
|
|
1116
|
+
console.log(`\n 🤖 AI context saved to ${aiFile}`);
|
|
1117
|
+
}
|