@progen-labs/spectra 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +100 -2
- package/bin/spectra.js +215 -0
- package/dist/assets/index-D0Lxrmdt.js +46 -0
- package/dist/assets/index-Da3Le5Px.css +1 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +76 -4
- package/server/analysis/cpu.js +369 -0
- package/server/analysis/findings.js +570 -0
- package/server/analysis/locate.js +99 -0
- package/server/analysis/report.js +775 -0
- package/server/analysis/sources.js +249 -0
- package/server/analysis/trace.js +314 -0
- package/server/api.js +292 -0
- package/server/chrome.js +81 -0
- package/server/cli.js +94 -0
- package/server/config.js +59 -0
- package/server/dashboard.js +154 -0
- package/server/explore/boot.js +150 -0
- package/server/explore/classify.js +169 -0
- package/server/explore/discover.js +162 -0
- package/server/explore/evaluate-cli.js +101 -0
- package/server/explore/evaluate.js +165 -0
- package/server/explore/paths.js +105 -0
- package/server/explore/runner.js +94 -0
- package/server/explore/test-explore.js +139 -0
- package/server/explore/test-paths.js +75 -0
- package/server/framework.js +67 -0
- package/server/git.js +59 -0
- package/server/pdf/render.js +60 -0
- package/server/pdf/template.js +419 -0
- package/server/plugin.js +19 -0
- package/server/probe.browser.js +828 -0
- package/server/recorder.js +610 -0
- package/server/signin.js +27 -0
- package/server/test-analysis.js +424 -0
- package/server/test-compare.js +473 -0
- package/server/test-sources.js +136 -0
- package/shared/compare.js +977 -0
- package/shared/snapshot.js +77 -0
- package/src/App.jsx +361 -0
- package/src/api.js +51 -0
- package/src/components/AppearanceMenu.jsx +76 -0
- package/src/components/CodeRef.jsx +48 -0
- package/src/components/ComparePanel.jsx +143 -0
- package/src/components/CompareResults.jsx +473 -0
- package/src/components/ControlBar.jsx +336 -0
- package/src/components/CulpritSphere.jsx +218 -0
- package/src/components/Culprits.jsx +120 -0
- package/src/components/CulpritsHero.jsx +109 -0
- package/src/components/EvaluationPanel.jsx +160 -0
- package/src/components/FilePanel.jsx +225 -0
- package/src/components/FindingsList.jsx +381 -0
- package/src/components/HeatTrees.jsx +222 -0
- package/src/components/Icons.jsx +58 -0
- package/src/components/LongTasks.jsx +199 -0
- package/src/components/NetworkPanel.jsx +98 -0
- package/src/components/PageMap.jsx +329 -0
- package/src/components/Popover.jsx +51 -0
- package/src/components/ReactPanel.jsx +206 -0
- package/src/components/SidePicker.jsx +207 -0
- package/src/components/SnapshotButton.jsx +140 -0
- package/src/components/SpectraCulprits.jsx +39 -0
- package/src/components/TimeBreakdown.jsx +45 -0
- package/src/components/TraceButton.jsx +76 -0
- package/src/components/TreeView.jsx +173 -0
- package/src/components/Vitals.jsx +163 -0
- package/src/culprits.js +127 -0
- package/src/download.js +18 -0
- package/src/format.js +26 -0
- package/src/heat.js +177 -0
- package/src/highlight.js +45 -0
- package/src/hooks/useCompare.js +187 -0
- package/src/hooks/useSession.js +73 -0
- package/src/layout.js +55 -0
- package/src/main.jsx +15 -0
- package/src/styles.css +2478 -0
- package/src/theme.js +75 -0
- package/src/themes.css +286 -0
- package/vite.config.js +8 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Joins the four data sources into one report:
|
|
3
|
+
* trace (main-thread work) + CPU samples (which code) + React probe
|
|
4
|
+
* (which components, why) + browser vitals (what users feel).
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
buildCallTree,
|
|
8
|
+
analyzeCpuProfile,
|
|
9
|
+
extractCpuProfile,
|
|
10
|
+
nodeAt,
|
|
11
|
+
pathTo,
|
|
12
|
+
windowSelf,
|
|
13
|
+
} from './cpu.js';
|
|
14
|
+
import { buildFindings } from './findings.js';
|
|
15
|
+
import {
|
|
16
|
+
CATEGORIES,
|
|
17
|
+
LONG_TASK_US,
|
|
18
|
+
analyzeTrace,
|
|
19
|
+
findMainThread,
|
|
20
|
+
} from './trace.js';
|
|
21
|
+
import { packageOf } from './sources.js';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { FRONTEND_ROOT } from '../config.js';
|
|
24
|
+
|
|
25
|
+
const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d;
|
|
26
|
+
|
|
27
|
+
/** Turn an absolute component file (e.g. Vue SFC __file) into a project path. */
|
|
28
|
+
function toProjectRelative(abs) {
|
|
29
|
+
try {
|
|
30
|
+
if (!abs || !path.isAbsolute(abs)) return abs || '';
|
|
31
|
+
const rel = path.relative(FRONTEND_ROOT, abs);
|
|
32
|
+
return rel.startsWith('..') ? abs : rel;
|
|
33
|
+
} catch {
|
|
34
|
+
return abs || '';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const ms = us => us / 1000;
|
|
38
|
+
|
|
39
|
+
function newFile(path, vendor, pkg) {
|
|
40
|
+
return {
|
|
41
|
+
path,
|
|
42
|
+
vendor,
|
|
43
|
+
pkg,
|
|
44
|
+
selfMs: 0,
|
|
45
|
+
totalMs: 0,
|
|
46
|
+
blockMs: 0,
|
|
47
|
+
reactMs: 0,
|
|
48
|
+
renders: 0,
|
|
49
|
+
wasted: 0,
|
|
50
|
+
forcedMs: 0,
|
|
51
|
+
forcedCount: 0,
|
|
52
|
+
functions: [],
|
|
53
|
+
components: [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fileEntry(map, loc) {
|
|
58
|
+
let f = map.get(loc.file);
|
|
59
|
+
if (!f) {
|
|
60
|
+
f = newFile(loc.file, loc.vendor, loc.pkg);
|
|
61
|
+
map.set(loc.file, f);
|
|
62
|
+
}
|
|
63
|
+
return f;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pickBlameFrame(frames) {
|
|
67
|
+
return (
|
|
68
|
+
frames.find(f => !f.vendor && f.file !== '(native)') ||
|
|
69
|
+
frames.find(f => f.file !== '(native)') ||
|
|
70
|
+
frames[0] ||
|
|
71
|
+
null
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isAnonymous = name => !name || name === '(anonymous)';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Best line to show for a function. A source-mapped line is exact; otherwise the
|
|
79
|
+
* function's definition is looked up by name in the real file ("located").
|
|
80
|
+
*/
|
|
81
|
+
function locate(sourceIndex, file, name, line, exact) {
|
|
82
|
+
const real = file && !file.startsWith('(') && !file.startsWith('bundle:');
|
|
83
|
+
const abs = real && sourceIndex ? sourceIndex.absolutePath(file) : null;
|
|
84
|
+
if (exact && line) return { line, lineKind: 'exact', abs };
|
|
85
|
+
const hit = real && sourceIndex ? sourceIndex.lookup(file, name) : null;
|
|
86
|
+
if (hit?.line) return { line: hit.line, lineKind: 'located', abs: hit.abs };
|
|
87
|
+
return { line: line ?? null, lineKind: line ? 'exact' : null, abs };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectScriptIds(events, profile, locations) {
|
|
91
|
+
const ids = new Set();
|
|
92
|
+
for (const n of profile?.nodes || [])
|
|
93
|
+
if (n.callFrame?.scriptId) ids.add(String(n.callFrame.scriptId));
|
|
94
|
+
for (const e of events) {
|
|
95
|
+
const stack = e.args?.beginData?.stackTrace || e.args?.data?.stackTrace;
|
|
96
|
+
if (stack)
|
|
97
|
+
for (const f of stack) if (f.scriptId) ids.add(String(f.scriptId));
|
|
98
|
+
}
|
|
99
|
+
for (const loc of locations.values())
|
|
100
|
+
if (loc?.scriptId) ids.add(String(loc.scriptId));
|
|
101
|
+
return ids;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {object} input
|
|
106
|
+
* @param {object[]} input.events raw trace events
|
|
107
|
+
* @param {object|null} input.probe __PERF_PROBE__.snapshot()
|
|
108
|
+
* @param {object} input.metrics {before, after, dom}
|
|
109
|
+
* @param {Map<number, {scriptId: string, lineNumber: number, columnNumber: number}>} input.locations component id -> function location
|
|
110
|
+
* @param {import('./sources.js').SourceResolver} input.resolver
|
|
111
|
+
* @param {object} input.meta
|
|
112
|
+
*/
|
|
113
|
+
export async function buildReport({
|
|
114
|
+
events,
|
|
115
|
+
probe,
|
|
116
|
+
metrics,
|
|
117
|
+
locations,
|
|
118
|
+
resolver,
|
|
119
|
+
meta,
|
|
120
|
+
sourceIndex = null,
|
|
121
|
+
}) {
|
|
122
|
+
const thread = findMainThread(events);
|
|
123
|
+
if (!thread)
|
|
124
|
+
throw new Error(
|
|
125
|
+
'No renderer main thread found in the trace (was the page idle or closed?).',
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const trace = analyzeTrace(events, thread);
|
|
129
|
+
const profile = extractCpuProfile(events, thread.pid, thread.tid);
|
|
130
|
+
await resolver.prepare(collectScriptIds(events, profile, locations));
|
|
131
|
+
|
|
132
|
+
const cpu = profile
|
|
133
|
+
? analyzeCpuProfile(profile, cf =>
|
|
134
|
+
resolver.resolve(
|
|
135
|
+
String(cf.scriptId),
|
|
136
|
+
cf.url,
|
|
137
|
+
cf.lineNumber,
|
|
138
|
+
cf.columnNumber,
|
|
139
|
+
cf.functionName,
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
: null;
|
|
143
|
+
const origin = trace.firstTs;
|
|
144
|
+
const rel = us => ms(us - origin);
|
|
145
|
+
const files = new Map();
|
|
146
|
+
|
|
147
|
+
// ----- CPU per file / function ------------------------------------------
|
|
148
|
+
if (cpu) {
|
|
149
|
+
for (const f of cpu.files) {
|
|
150
|
+
if (f.file.startsWith('(')) continue;
|
|
151
|
+
const entry = fileEntry(files, f);
|
|
152
|
+
entry.selfMs += ms(f.selfUs);
|
|
153
|
+
entry.totalMs += ms(f.totalUs);
|
|
154
|
+
}
|
|
155
|
+
for (const fn of cpu.functions) {
|
|
156
|
+
if (fn.file.startsWith('(')) continue;
|
|
157
|
+
const entry = files.get(fn.file);
|
|
158
|
+
if (entry) {
|
|
159
|
+
entry.functions.push({
|
|
160
|
+
name: fn.name,
|
|
161
|
+
line: fn.line,
|
|
162
|
+
bundleLine: fn.bundleLine + 1,
|
|
163
|
+
exact: fn.exact,
|
|
164
|
+
selfMs: ms(fn.selfUs),
|
|
165
|
+
totalMs: ms(fn.totalUs),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const f of files.values()) {
|
|
170
|
+
f.functions = f.functions
|
|
171
|
+
.filter(fn => fn.selfMs >= 0.3 || fn.totalMs >= 3)
|
|
172
|
+
.sort((a, b) => b.selfMs - a.selfMs)
|
|
173
|
+
.slice(0, 30);
|
|
174
|
+
if (!f.vendor) {
|
|
175
|
+
f.functions = f.functions.map(fn => ({
|
|
176
|
+
...fn,
|
|
177
|
+
...locate(sourceIndex, f.path, fn.name, fn.line, fn.exact),
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Starting the CPU profiler makes V8 index every function of a large app, which
|
|
184
|
+
// stalls the main thread at the very start of a live recording. It looks like a
|
|
185
|
+
// long task with no JavaScript in it; it is measurement overhead, not the app.
|
|
186
|
+
const jsUsIn = t => {
|
|
187
|
+
if (!cpu) return 0;
|
|
188
|
+
let sum = 0;
|
|
189
|
+
for (const [node, us] of windowSelf(cpu, t.ts, t.ts + t.durUs)) {
|
|
190
|
+
if (!node.native) sum += us;
|
|
191
|
+
}
|
|
192
|
+
return sum;
|
|
193
|
+
};
|
|
194
|
+
const isStartupArtifact = t =>
|
|
195
|
+
meta.mode === 'live' &&
|
|
196
|
+
rel(t.ts) < 500 &&
|
|
197
|
+
t.breakdown.other >= 0.8 * t.durUs &&
|
|
198
|
+
jsUsIn(t) < 0.1 * t.durUs;
|
|
199
|
+
const artifactTasks = new Set(trace.longTasks.filter(isStartupArtifact));
|
|
200
|
+
const artifactMs = ms([...artifactTasks].reduce((s, t) => s + t.durUs, 0));
|
|
201
|
+
const realLongTasks = trace.longTasks.filter(t => !artifactTasks.has(t));
|
|
202
|
+
|
|
203
|
+
// ----- long tasks (with blame from the samples inside them) ---------------
|
|
204
|
+
const longTasks = realLongTasks.slice(0, 40).map(t => {
|
|
205
|
+
const topFiles = new Map();
|
|
206
|
+
const topFns = new Map();
|
|
207
|
+
if (cpu) {
|
|
208
|
+
for (const [node, us] of windowSelf(cpu, t.ts, t.ts + t.durUs)) {
|
|
209
|
+
if (node.native) continue;
|
|
210
|
+
topFiles.set(node.loc.file, (topFiles.get(node.loc.file) || 0) + us);
|
|
211
|
+
const key = `${node.name}|${node.loc.file}|${node.loc.line ?? ''}`;
|
|
212
|
+
topFns.set(key, {
|
|
213
|
+
name: node.name,
|
|
214
|
+
file: node.loc.file,
|
|
215
|
+
line: node.loc.line,
|
|
216
|
+
exact: node.loc.exact,
|
|
217
|
+
ms: (topFns.get(key)?.ms || 0) + ms(us),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const blocking = ms(t.durUs) - ms(LONG_TASK_US);
|
|
222
|
+
for (const [file, us] of topFiles) {
|
|
223
|
+
const f = files.get(file);
|
|
224
|
+
if (f) f.blockMs += ms(us) * (blocking / ms(t.durUs));
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
startMs: round(rel(t.ts)),
|
|
228
|
+
durMs: round(ms(t.durUs)),
|
|
229
|
+
blockingMs: round(blocking),
|
|
230
|
+
trigger: t.trigger,
|
|
231
|
+
breakdown: Object.fromEntries(
|
|
232
|
+
CATEGORIES.map(c => [c, round(ms(t.breakdown[c]))]),
|
|
233
|
+
),
|
|
234
|
+
topFiles: [...topFiles]
|
|
235
|
+
.sort((a, b) => b[1] - a[1])
|
|
236
|
+
.slice(0, 5)
|
|
237
|
+
.map(([file, us]) => ({ file, ms: round(ms(us)) })),
|
|
238
|
+
topFunctions: [...topFns.values()]
|
|
239
|
+
.sort((a, b) => b.ms - a.ms)
|
|
240
|
+
.slice(0, 6)
|
|
241
|
+
.map(f => ({
|
|
242
|
+
name: f.name,
|
|
243
|
+
file: f.file,
|
|
244
|
+
ms: round(f.ms),
|
|
245
|
+
...locate(sourceIndex, f.file, f.name, f.line, f.exact),
|
|
246
|
+
})),
|
|
247
|
+
forcedCount: t.forcedCount,
|
|
248
|
+
thrash: t.thrash,
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ----- forced reflow ----------------------------------------------------
|
|
253
|
+
const forcedReflows = trace.forced.map(f => {
|
|
254
|
+
let frames = [];
|
|
255
|
+
if (f.stack) {
|
|
256
|
+
frames = f.stack.map(fr => ({
|
|
257
|
+
...resolver.resolve(
|
|
258
|
+
String(fr.scriptId),
|
|
259
|
+
fr.url,
|
|
260
|
+
fr.lineNumber,
|
|
261
|
+
fr.columnNumber,
|
|
262
|
+
fr.functionName,
|
|
263
|
+
),
|
|
264
|
+
fnName: fr.functionName,
|
|
265
|
+
}));
|
|
266
|
+
} else if (cpu) {
|
|
267
|
+
const node = nodeAt(cpu, f.ts + f.durUs / 2);
|
|
268
|
+
if (node) {
|
|
269
|
+
frames = pathTo(node, cpu.nodes)
|
|
270
|
+
.reverse()
|
|
271
|
+
.map(n => ({ ...n.loc, fnName: n.name }));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const blame = pickBlameFrame(frames);
|
|
275
|
+
const caller = blame ? frames[frames.indexOf(blame) + 1] : null;
|
|
276
|
+
const callerName = caller?.name || caller?.fnName || '';
|
|
277
|
+
let fn = blame?.name || blame?.fnName || '';
|
|
278
|
+
if (isAnonymous(fn) && callerName) fn = `(anonymous in ${callerName})`;
|
|
279
|
+
return {
|
|
280
|
+
kind: f.kind,
|
|
281
|
+
ms: ms(f.durUs),
|
|
282
|
+
startMs: rel(f.ts),
|
|
283
|
+
file: blame?.file || '',
|
|
284
|
+
fn,
|
|
285
|
+
line: blame?.line ?? null,
|
|
286
|
+
exact: Boolean(blame?.exact),
|
|
287
|
+
caller: callerName
|
|
288
|
+
? { fn: callerName, file: caller.file, line: caller.line ?? null }
|
|
289
|
+
: null,
|
|
290
|
+
trigger: f.scriptParent,
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
for (const f of forcedReflows) {
|
|
294
|
+
const entry = f.file && files.get(f.file);
|
|
295
|
+
if (entry) {
|
|
296
|
+
entry.forcedMs += f.ms;
|
|
297
|
+
entry.forcedCount += 1;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ----- React ------------------------------------------------------------
|
|
302
|
+
const reactReport = summarizeReact(
|
|
303
|
+
probe,
|
|
304
|
+
locations,
|
|
305
|
+
resolver,
|
|
306
|
+
files,
|
|
307
|
+
meta.durationMs,
|
|
308
|
+
sourceIndex,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// ----- Framework-agnostic summary (Vue/Svelte/Angular/Next) ----------------
|
|
312
|
+
function summarizeFramework(probe) {
|
|
313
|
+
if (!probe || !probe.framework) return { detected: false };
|
|
314
|
+
const f = probe.framework;
|
|
315
|
+
return {
|
|
316
|
+
detected: !!f.detected,
|
|
317
|
+
name: f.name || 'unknown',
|
|
318
|
+
version: f.version || '',
|
|
319
|
+
commitCount: probe.commitCount || 0,
|
|
320
|
+
components: f.components || [],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const frameworkReport = summarizeFramework(probe);
|
|
324
|
+
|
|
325
|
+
// ----- vitals -----------------------------------------------------------
|
|
326
|
+
const v = probe?.vitals;
|
|
327
|
+
const tbtMs = realLongTasks.reduce((s, t) => s + (ms(t.durUs) - 50), 0);
|
|
328
|
+
const tbtCulprits = mergeFunctions(longTasks);
|
|
329
|
+
const loadMode = meta.mode === 'load';
|
|
330
|
+
const shifts = (v?.shifts || []).sort((a, b) => b.value - a.value);
|
|
331
|
+
const interactions = summarizeInteractions(v?.events || []);
|
|
332
|
+
const m = metrics;
|
|
333
|
+
const delta = key => (m.after[key] ?? 0) - (m.before[key] ?? 0);
|
|
334
|
+
const activeMs = cpu ? ms(cpu.totals.activeUs) : ms(trace.busyUs);
|
|
335
|
+
|
|
336
|
+
const network = summarizeNetwork(
|
|
337
|
+
probe?.resources || [],
|
|
338
|
+
meta.mode === 'load' ? 0 : probe?.since || 0,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const report = {
|
|
342
|
+
id: meta.id,
|
|
343
|
+
createdAt: meta.createdAt,
|
|
344
|
+
meta: {
|
|
345
|
+
url: meta.url,
|
|
346
|
+
title: meta.title,
|
|
347
|
+
mode: meta.mode,
|
|
348
|
+
durationMs: meta.durationMs,
|
|
349
|
+
cpuThrottle: meta.cpuThrottle,
|
|
350
|
+
userAgent: meta.userAgent,
|
|
351
|
+
git: meta.git ?? null,
|
|
352
|
+
label: meta.label || '',
|
|
353
|
+
navStatus: meta.navStatus ?? null,
|
|
354
|
+
navUrl: meta.navUrl || '',
|
|
355
|
+
attribution: [...resolver.kinds].join(' + ') || 'none',
|
|
356
|
+
sampleCount: cpu?.totals.sampleCount || 0,
|
|
357
|
+
probeOverheadMs: round(probe?.overheadMs || 0),
|
|
358
|
+
},
|
|
359
|
+
vitals: {
|
|
360
|
+
// FCP/LCP belong to page load; in an interaction recording they describe an earlier load.
|
|
361
|
+
fcpMs: loadMode && v?.fcp ? round(v.fcp) : null,
|
|
362
|
+
lcpMs: loadMode && v?.lcp ? round(v.lcp.start) : null,
|
|
363
|
+
lcpElement: loadMode ? v?.lcp?.el || '' : '',
|
|
364
|
+
cls: round(
|
|
365
|
+
shifts.reduce((s, x) => s + x.value, 0),
|
|
366
|
+
4,
|
|
367
|
+
),
|
|
368
|
+
shifts: shifts.slice(0, 8).map(s => ({
|
|
369
|
+
...s,
|
|
370
|
+
value: round(s.value, 4),
|
|
371
|
+
start: round(s.start),
|
|
372
|
+
sources: s.sources.filter(Boolean),
|
|
373
|
+
})),
|
|
374
|
+
tbtMs: round(tbtMs),
|
|
375
|
+
tbtCulprits,
|
|
376
|
+
longTaskCount: realLongTasks.length,
|
|
377
|
+
maxLongTaskMs: round(
|
|
378
|
+
realLongTasks.length ? ms(realLongTasks[0].durUs) : 0,
|
|
379
|
+
),
|
|
380
|
+
inpMs: interactions.length ? interactions[0].durMs : null,
|
|
381
|
+
ttfbMs: probe?.navigation ? round(probe.navigation.ttfb) : null,
|
|
382
|
+
loadMs: probe?.navigation?.load ? round(probe.navigation.load) : null,
|
|
383
|
+
},
|
|
384
|
+
totals: {
|
|
385
|
+
activeMs: round(activeMs),
|
|
386
|
+
busyMs: round(Math.max(0, ms(trace.busyUs) - artifactMs)),
|
|
387
|
+
scriptingMs: round(ms(trace.totals.scripting)),
|
|
388
|
+
compileMs: round(ms(trace.totals.compile)),
|
|
389
|
+
styleMs: round(ms(trace.totals.style)),
|
|
390
|
+
layoutMs: round(ms(trace.totals.layout)),
|
|
391
|
+
paintMs: round(ms(trace.totals.paint)),
|
|
392
|
+
gcMs: round(cpu ? ms(cpu.totals.gcUs) : ms(trace.totals.gc)),
|
|
393
|
+
parseMs: round(ms(trace.totals.parse)),
|
|
394
|
+
otherMs: round(Math.max(0, ms(trace.totals.other) - artifactMs)),
|
|
395
|
+
layoutCount: trace.counts.layout,
|
|
396
|
+
styleRecalcCount: trace.counts.style,
|
|
397
|
+
forcedLayoutCount: forcedReflows.filter(f => f.kind === 'layout').length,
|
|
398
|
+
forcedStyleCount: forcedReflows.filter(f => f.kind === 'style').length,
|
|
399
|
+
forcedMs: round(forcedReflows.reduce((s, f) => s + f.ms, 0)),
|
|
400
|
+
maxLayoutObjects: trace.maxLayoutObjects,
|
|
401
|
+
},
|
|
402
|
+
memory: {
|
|
403
|
+
domNodes: m.dom?.nodes ?? m.after.Nodes ?? 0,
|
|
404
|
+
listeners: m.dom?.jsEventListeners ?? m.after.JSEventListeners ?? 0,
|
|
405
|
+
heapUsedMb: round((m.after.JSHeapUsedSize || 0) / 1048576, 1),
|
|
406
|
+
heapGrowthMb: round(delta('JSHeapUsedSize') / 1048576, 1),
|
|
407
|
+
layoutCount: delta('LayoutCount'),
|
|
408
|
+
recalcStyleCount: delta('RecalcStyleCount'),
|
|
409
|
+
},
|
|
410
|
+
longTasks,
|
|
411
|
+
thrashTasks:
|
|
412
|
+
realLongTasks.filter(t => t.thrash).length +
|
|
413
|
+
trace.tasks.filter(t => t.thrash && t.durUs < LONG_TASK_US).length,
|
|
414
|
+
forcedReflows: aggregateForced(forcedReflows, sourceIndex),
|
|
415
|
+
interactions,
|
|
416
|
+
react: reactReport,
|
|
417
|
+
framework: frameworkReport,
|
|
418
|
+
network,
|
|
419
|
+
callTree: cpu
|
|
420
|
+
? buildCallTree(cpu, {
|
|
421
|
+
minUs: Math.max(1000, cpu.totals.activeUs * 0.004),
|
|
422
|
+
})
|
|
423
|
+
: null,
|
|
424
|
+
files: [],
|
|
425
|
+
timeline: trace.tasks
|
|
426
|
+
.filter(t => t.durUs >= 8000 && !artifactTasks.has(t))
|
|
427
|
+
.map(t => ({ startMs: round(rel(t.ts)), durMs: round(ms(t.durUs)) }))
|
|
428
|
+
.slice(0, 2000),
|
|
429
|
+
hotFunctions: hotFunctions(cpu, sourceIndex),
|
|
430
|
+
vendorDrivers: vendorDrivers(cpu, sourceIndex),
|
|
431
|
+
notes: [],
|
|
432
|
+
};
|
|
433
|
+
if (artifactMs > 0) {
|
|
434
|
+
report.notes.push(
|
|
435
|
+
`Ignored a ${round(artifactMs)} ms stall at the very start of the recording: Chrome was indexing the app's code for the CPU profiler, so it is measurement overhead, not something your app did.`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
report.files = [...files.values()]
|
|
440
|
+
.filter(f => f.selfMs >= 0.2 || f.renders || f.forcedCount || f.blockMs)
|
|
441
|
+
.map(f => ({
|
|
442
|
+
...f,
|
|
443
|
+
selfMs: round(f.selfMs),
|
|
444
|
+
totalMs: round(f.totalMs),
|
|
445
|
+
blockMs: round(f.blockMs),
|
|
446
|
+
reactMs: round(f.reactMs),
|
|
447
|
+
forcedMs: round(f.forcedMs),
|
|
448
|
+
}))
|
|
449
|
+
.sort((a, b) => b.selfMs - a.selfMs)
|
|
450
|
+
.slice(0, 2500);
|
|
451
|
+
|
|
452
|
+
if (!cpu)
|
|
453
|
+
report.notes.push(
|
|
454
|
+
'No CPU samples were captured; file attribution is limited to React and layout data.',
|
|
455
|
+
);
|
|
456
|
+
if (
|
|
457
|
+
report.meta.attribution === 'urls' ||
|
|
458
|
+
report.meta.attribution === 'none'
|
|
459
|
+
) {
|
|
460
|
+
report.notes.push(
|
|
461
|
+
'Scripts have no source maps and no webpack module markers, so time is attributed to bundles. Profile a development build for per-file results.',
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
report.findings = buildFindings(report);
|
|
465
|
+
return report;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function aggregateForced(list, sourceIndex) {
|
|
469
|
+
const map = new Map();
|
|
470
|
+
for (const f of list) {
|
|
471
|
+
const key = `${f.kind}|${f.file}|${f.fn}|${f.line}`;
|
|
472
|
+
const agg = map.get(key) || {
|
|
473
|
+
kind: f.kind,
|
|
474
|
+
file: f.file,
|
|
475
|
+
fn: f.fn,
|
|
476
|
+
line: f.line,
|
|
477
|
+
exact: f.exact,
|
|
478
|
+
caller: f.caller,
|
|
479
|
+
count: 0,
|
|
480
|
+
ms: 0,
|
|
481
|
+
};
|
|
482
|
+
agg.count += 1;
|
|
483
|
+
agg.ms += f.ms;
|
|
484
|
+
map.set(key, agg);
|
|
485
|
+
}
|
|
486
|
+
return [...map.values()]
|
|
487
|
+
.sort((a, b) => b.ms - a.ms)
|
|
488
|
+
.slice(0, 60)
|
|
489
|
+
.map(a => ({
|
|
490
|
+
...a,
|
|
491
|
+
ms: round(a.ms),
|
|
492
|
+
...locate(sourceIndex, a.file, a.fn, a.line, a.exact),
|
|
493
|
+
}));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The same function across several long tasks, summed and ranked. */
|
|
497
|
+
function mergeFunctions(longTasks) {
|
|
498
|
+
const merged = new Map();
|
|
499
|
+
for (const t of longTasks) {
|
|
500
|
+
for (const f of t.topFunctions) {
|
|
501
|
+
if (f.file.startsWith('(')) continue;
|
|
502
|
+
const key = `${f.file}|${f.name}`;
|
|
503
|
+
const cur = merged.get(key) || { ...f, ms: 0 };
|
|
504
|
+
cur.ms += f.ms;
|
|
505
|
+
merged.set(key, cur);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return [...merged.values()]
|
|
509
|
+
.sort((a, b) => b.ms - a.ms)
|
|
510
|
+
.slice(0, 4)
|
|
511
|
+
.map(f => ({ ...f, ms: round(f.ms) }));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The slowest functions by self time: the exact code to look at first. */
|
|
515
|
+
function hotFunctions(cpu, sourceIndex) {
|
|
516
|
+
if (!cpu) return [];
|
|
517
|
+
const active = cpu.totals.activeUs || 1;
|
|
518
|
+
return cpu.functions
|
|
519
|
+
.filter(fn => !fn.file.startsWith('(') && fn.selfUs >= 1000)
|
|
520
|
+
.sort((a, b) => b.selfUs - a.selfUs)
|
|
521
|
+
.slice(0, 25)
|
|
522
|
+
.map(fn => {
|
|
523
|
+
const vendor = fn.file.includes('node_modules/');
|
|
524
|
+
return {
|
|
525
|
+
name: fn.name,
|
|
526
|
+
file: fn.file,
|
|
527
|
+
vendor,
|
|
528
|
+
selfMs: round(ms(fn.selfUs)),
|
|
529
|
+
totalMs: round(ms(fn.totalUs)),
|
|
530
|
+
share: round((fn.selfUs / active) * 100, 1),
|
|
531
|
+
bundleLine: fn.bundleLine + 1,
|
|
532
|
+
...(vendor
|
|
533
|
+
? { line: null, lineKind: null, abs: null }
|
|
534
|
+
: locate(sourceIndex, fn.file, fn.name, fn.line, fn.exact)),
|
|
535
|
+
};
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** For each hot library, the application functions that call into it. */
|
|
540
|
+
function vendorDrivers(cpu, sourceIndex) {
|
|
541
|
+
const out = {};
|
|
542
|
+
if (!cpu) return out;
|
|
543
|
+
const totals = [...cpu.vendorDrivers].map(([pkg, drivers]) => [
|
|
544
|
+
pkg,
|
|
545
|
+
drivers,
|
|
546
|
+
[...drivers.values()].reduce((s, d) => s + d.us, 0),
|
|
547
|
+
]);
|
|
548
|
+
for (const [pkg, drivers] of totals
|
|
549
|
+
.sort((a, b) => b[2] - a[2])
|
|
550
|
+
.slice(0, 15)) {
|
|
551
|
+
out[pkg] = [...drivers.values()]
|
|
552
|
+
.sort((a, b) => b.us - a.us)
|
|
553
|
+
.slice(0, 3)
|
|
554
|
+
.map(d => ({
|
|
555
|
+
fn: d.name,
|
|
556
|
+
file: d.file,
|
|
557
|
+
ms: round(ms(d.us)),
|
|
558
|
+
...locate(sourceIndex, d.file, d.name, d.line, d.exact),
|
|
559
|
+
}));
|
|
560
|
+
}
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function summarizeInteractions(events) {
|
|
565
|
+
const byInteraction = new Map();
|
|
566
|
+
for (const e of events) {
|
|
567
|
+
// Only events with an interactionId are user interactions (hover events have none);
|
|
568
|
+
// pointerdown/pointerup/click of one gesture share the id.
|
|
569
|
+
if (!e.interaction) continue;
|
|
570
|
+
const key = e.interaction;
|
|
571
|
+
const cur = byInteraction.get(key);
|
|
572
|
+
if (!cur || e.dur > cur.dur) byInteraction.set(key, e);
|
|
573
|
+
}
|
|
574
|
+
return [...byInteraction.values()]
|
|
575
|
+
.sort((a, b) => b.dur - a.dur)
|
|
576
|
+
.slice(0, 15)
|
|
577
|
+
.map(e => ({
|
|
578
|
+
type: e.name,
|
|
579
|
+
target: e.target,
|
|
580
|
+
startMs: round(e.start),
|
|
581
|
+
durMs: round(e.dur),
|
|
582
|
+
inputDelayMs: round(e.input),
|
|
583
|
+
processingMs: round(e.processing),
|
|
584
|
+
presentMs: round(e.present),
|
|
585
|
+
}));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function shortName(url) {
|
|
589
|
+
try {
|
|
590
|
+
const u = new URL(url);
|
|
591
|
+
return (
|
|
592
|
+
decodeURIComponent(
|
|
593
|
+
u.pathname.split('/').filter(Boolean).pop() || u.host,
|
|
594
|
+
) + (u.search ? '?…' : '')
|
|
595
|
+
);
|
|
596
|
+
} catch {
|
|
597
|
+
return url.slice(0, 60);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function summarizeNetwork(resources, sinceMs) {
|
|
602
|
+
const list = resources
|
|
603
|
+
.filter(r => r.start >= sinceMs)
|
|
604
|
+
.map(r => ({
|
|
605
|
+
url: r.url,
|
|
606
|
+
name: shortName(r.url),
|
|
607
|
+
type: r.type,
|
|
608
|
+
startMs: round(r.start),
|
|
609
|
+
durMs: round(r.dur),
|
|
610
|
+
transfer: r.transfer,
|
|
611
|
+
encoded: r.encoded,
|
|
612
|
+
decoded: r.decoded,
|
|
613
|
+
blocking: r.blocking,
|
|
614
|
+
}));
|
|
615
|
+
const compressible = /\.(?:js|mjs|css|json|svg|html)(?:$|\?)/i;
|
|
616
|
+
const dupes = new Map();
|
|
617
|
+
for (const r of list) dupes.set(r.url, (dupes.get(r.url) || 0) + 1);
|
|
618
|
+
return {
|
|
619
|
+
count: list.length,
|
|
620
|
+
totalEncoded: list.reduce((s, r) => s + (r.encoded || 0), 0),
|
|
621
|
+
totalDecoded: list.reduce((s, r) => s + (r.decoded || 0), 0),
|
|
622
|
+
slow: [...list].sort((a, b) => b.durMs - a.durMs).slice(0, 15),
|
|
623
|
+
large: [...list].sort((a, b) => b.encoded - a.encoded).slice(0, 15),
|
|
624
|
+
uncompressed: list
|
|
625
|
+
.filter(
|
|
626
|
+
r =>
|
|
627
|
+
compressible.test(r.url) &&
|
|
628
|
+
r.decoded > 50 * 1024 &&
|
|
629
|
+
r.encoded >= r.decoded * 0.95 &&
|
|
630
|
+
r.transfer > 0,
|
|
631
|
+
)
|
|
632
|
+
.sort((a, b) => b.decoded - a.decoded)
|
|
633
|
+
.slice(0, 10),
|
|
634
|
+
duplicates: [...dupes]
|
|
635
|
+
.filter(([, n]) => n >= 3)
|
|
636
|
+
.map(([url, n]) => ({ url, name: shortName(url), count: n }))
|
|
637
|
+
.slice(0, 10),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function summarizeReact(
|
|
642
|
+
probe,
|
|
643
|
+
locations,
|
|
644
|
+
resolver,
|
|
645
|
+
files,
|
|
646
|
+
durationMs,
|
|
647
|
+
sourceIndex,
|
|
648
|
+
) {
|
|
649
|
+
const empty = {
|
|
650
|
+
detected: false,
|
|
651
|
+
framework: probe?.info?.framework || '',
|
|
652
|
+
hasTimings: false,
|
|
653
|
+
version: '',
|
|
654
|
+
components: [],
|
|
655
|
+
commits: { count: 0, totalMs: 0, maxMs: 0 },
|
|
656
|
+
slowCommits: [],
|
|
657
|
+
commitSeries: [],
|
|
658
|
+
};
|
|
659
|
+
if (!probe || !probe.info?.detected) return empty;
|
|
660
|
+
const originMs = probe.since || 0;
|
|
661
|
+
const components = probe.components
|
|
662
|
+
.map(c => {
|
|
663
|
+
let loc;
|
|
664
|
+
if (c.file) {
|
|
665
|
+
// Adapters that know the component's source file directly (e.g. Vue SFCs).
|
|
666
|
+
const rel = toProjectRelative(c.file);
|
|
667
|
+
loc = {
|
|
668
|
+
file: rel,
|
|
669
|
+
line: c.line || null,
|
|
670
|
+
vendor: /(^|\/)node_modules\//.test(rel),
|
|
671
|
+
pkg: null,
|
|
672
|
+
exact: false,
|
|
673
|
+
};
|
|
674
|
+
} else if (locations.has(c.id)) {
|
|
675
|
+
const l = locations.get(c.id);
|
|
676
|
+
const script = resolver.scripts.get(String(l.scriptId));
|
|
677
|
+
loc = resolver.resolve(
|
|
678
|
+
String(l.scriptId),
|
|
679
|
+
script?.url || '',
|
|
680
|
+
l.lineNumber,
|
|
681
|
+
l.columnNumber,
|
|
682
|
+
);
|
|
683
|
+
} else loc = { file: '', line: null, vendor: false, pkg: null };
|
|
684
|
+
|
|
685
|
+
const unstableProps = Object.entries(c.props)
|
|
686
|
+
.map(([prop, r]) => {
|
|
687
|
+
const kind = r.fn >= r.obj ? 'function' : 'object';
|
|
688
|
+
// Equality was only checked for the first renders; scale the ratio up to all changes.
|
|
689
|
+
const count = r.sampled
|
|
690
|
+
? Math.round((r.n * Math.max(r.fn, r.obj)) / r.sampled)
|
|
691
|
+
: 0;
|
|
692
|
+
return { prop, count, kind, changed: r.n };
|
|
693
|
+
})
|
|
694
|
+
.filter(p => p.count > 0)
|
|
695
|
+
.sort((a, b) => b.count - a.count);
|
|
696
|
+
const changedProps = Object.entries(c.props)
|
|
697
|
+
.map(([prop, r]) => ({ prop, count: r.n }))
|
|
698
|
+
.sort((a, b) => b.count - a.count)
|
|
699
|
+
.slice(0, 6);
|
|
700
|
+
const topParent =
|
|
701
|
+
Object.entries(c.parents).sort((a, b) => b[1] - a[1])[0]?.[0] || '';
|
|
702
|
+
const wastedMs = c.renders ? (c.selfMs * c.wasted) / c.renders : 0;
|
|
703
|
+
return {
|
|
704
|
+
id: c.id,
|
|
705
|
+
name: c.name,
|
|
706
|
+
kind: c.kind,
|
|
707
|
+
file: loc.file,
|
|
708
|
+
...(loc.vendor
|
|
709
|
+
? { line: loc.line, lineKind: null, abs: null }
|
|
710
|
+
: locate(sourceIndex, loc.file, c.name, loc.line, loc.exact)),
|
|
711
|
+
vendor: loc.vendor,
|
|
712
|
+
renders: c.renders,
|
|
713
|
+
mounts: c.mounts,
|
|
714
|
+
wasted: c.wasted,
|
|
715
|
+
selfMs: round(c.selfMs),
|
|
716
|
+
totalMs: round(c.totalMs),
|
|
717
|
+
maxMs: round(c.maxMs),
|
|
718
|
+
wastedMs: round(wastedMs),
|
|
719
|
+
reasons: c.reasons,
|
|
720
|
+
unstableProps,
|
|
721
|
+
changedProps,
|
|
722
|
+
topParent,
|
|
723
|
+
};
|
|
724
|
+
})
|
|
725
|
+
.sort((a, b) => b.selfMs - a.selfMs || b.renders - a.renders);
|
|
726
|
+
|
|
727
|
+
for (const c of components) {
|
|
728
|
+
if (!c.file) continue;
|
|
729
|
+
const entry = fileEntry(files, {
|
|
730
|
+
file: c.file,
|
|
731
|
+
vendor: c.vendor,
|
|
732
|
+
pkg: packageOf(c.file),
|
|
733
|
+
});
|
|
734
|
+
entry.reactMs += c.selfMs;
|
|
735
|
+
entry.renders += c.renders;
|
|
736
|
+
entry.wasted += c.wasted;
|
|
737
|
+
entry.components.push({
|
|
738
|
+
name: c.name,
|
|
739
|
+
renders: c.renders,
|
|
740
|
+
wasted: c.wasted,
|
|
741
|
+
selfMs: c.selfMs,
|
|
742
|
+
line: c.line,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
const commits = probe.commits;
|
|
746
|
+
const sorted = [...commits].sort((a, b) => b.dur - a.dur);
|
|
747
|
+
return {
|
|
748
|
+
detected: true,
|
|
749
|
+
framework: probe.info.framework || 'react',
|
|
750
|
+
hasTimings: probe.info.timings,
|
|
751
|
+
version: probe.info.version,
|
|
752
|
+
components: components.slice(0, 600),
|
|
753
|
+
commits: {
|
|
754
|
+
count: probe.commitCount,
|
|
755
|
+
totalMs: round(commits.reduce((s, c) => s + c.dur, 0)),
|
|
756
|
+
maxMs: round(sorted[0]?.dur || 0),
|
|
757
|
+
},
|
|
758
|
+
slowCommits: sorted.slice(0, 10).map(c => ({
|
|
759
|
+
tMs: round(c.t - originMs),
|
|
760
|
+
dur: round(c.dur),
|
|
761
|
+
rendered: c.rendered,
|
|
762
|
+
wasted: c.wasted,
|
|
763
|
+
})),
|
|
764
|
+
commitSeries: commits
|
|
765
|
+
.filter(c => c.dur >= 4)
|
|
766
|
+
.slice(0, 1500)
|
|
767
|
+
.map(c => ({
|
|
768
|
+
tMs: round(c.t - originMs),
|
|
769
|
+
dur: round(c.dur),
|
|
770
|
+
rendered: c.rendered,
|
|
771
|
+
wasted: c.wasted,
|
|
772
|
+
})),
|
|
773
|
+
durationMs,
|
|
774
|
+
};
|
|
775
|
+
}
|