browsertime 27.5.0 → 28.0.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +27 -3
  2. package/bin/browsertime.js +28 -4
  3. package/lib/chrome/coverage.js +89 -19
  4. package/lib/chrome/mediawikiResourceLoader.js +223 -0
  5. package/lib/chrome/parseCpuTrace.js +87 -2
  6. package/lib/chrome/trace/blocking-time.js +189 -0
  7. package/lib/chrome/trace/domain-breakdown.js +86 -0
  8. package/lib/chrome/trace/frame-stability.js +135 -0
  9. package/lib/chrome/trace/function-costs.js +281 -0
  10. package/lib/chrome/trace/index.js +7 -0
  11. package/lib/chrome/trace/module-costs.js +137 -0
  12. package/lib/chrome/trace/non-composited-animations.js +50 -5
  13. package/lib/chrome/trace/selector-stats.js +110 -0
  14. package/lib/chrome/trace/style-invalidations.js +162 -0
  15. package/lib/chrome/trace/task-groups.js +28 -2
  16. package/lib/chrome/trace/timer-costs.js +183 -0
  17. package/lib/chrome/webdriver/chromium.js +114 -0
  18. package/lib/chrome/webdriver/traceUtilities.js +11 -1
  19. package/lib/core/engine/collector.js +12 -0
  20. package/lib/core/engine/index.js +6 -0
  21. package/lib/support/cli.js +1 -1
  22. package/lib/support/har/index.js +44 -1
  23. package/package.json +4 -4
  24. package/types/chrome/mediawikiResourceLoader.d.ts +11 -0
  25. package/types/chrome/mediawikiResourceLoader.d.ts.map +1 -0
  26. package/types/chrome/parseCpuTrace.d.ts +1 -26
  27. package/types/chrome/parseCpuTrace.d.ts.map +1 -1
  28. package/types/chrome/trace/blocking-time.d.ts +7 -0
  29. package/types/chrome/trace/blocking-time.d.ts.map +1 -0
  30. package/types/chrome/trace/domain-breakdown.d.ts +11 -0
  31. package/types/chrome/trace/domain-breakdown.d.ts.map +1 -0
  32. package/types/chrome/trace/frame-stability.d.ts +11 -0
  33. package/types/chrome/trace/frame-stability.d.ts.map +1 -0
  34. package/types/chrome/trace/function-costs.d.ts +6 -0
  35. package/types/chrome/trace/function-costs.d.ts.map +1 -0
  36. package/types/chrome/trace/index.d.ts +7 -0
  37. package/types/chrome/trace/index.d.ts.map +1 -1
  38. package/types/chrome/trace/module-costs.d.ts +20 -0
  39. package/types/chrome/trace/module-costs.d.ts.map +1 -0
  40. package/types/chrome/trace/non-composited-animations.d.ts +1 -25
  41. package/types/chrome/trace/non-composited-animations.d.ts.map +1 -1
  42. package/types/chrome/trace/selector-stats.d.ts +8 -0
  43. package/types/chrome/trace/selector-stats.d.ts.map +1 -0
  44. package/types/chrome/trace/style-invalidations.d.ts +9 -0
  45. package/types/chrome/trace/style-invalidations.d.ts.map +1 -0
  46. package/types/chrome/trace/task-groups.d.ts.map +1 -1
  47. package/types/chrome/trace/timer-costs.d.ts +10 -0
  48. package/types/chrome/trace/timer-costs.d.ts.map +1 -0
  49. package/types/chrome/webdriver/traceUtilities.d.ts.map +1 -1
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Blocking-time attribution per URL and per kind of work.
3
+ *
4
+ * A top-level main-thread task longer than 50 ms blocks input for
5
+ * (duration - 50) ms — the same definition Lighthouse's Total
6
+ * Blocking Time uses. Knowing the page's TBT is one thing; knowing
7
+ * WHICH script produced it is what tells you what to fix first, so
8
+ * each blocking task is attributed to a URL:
9
+ *
10
+ * Top-level tasks are often anonymous wrappers (RunTask et al.) whose
11
+ * own attributableURLs is empty while the real work sits in child
12
+ * tasks. So instead of only looking at the top-level task's own URL
13
+ * chain, the whole subtree is walked and the task is attributed to
14
+ * the URL that contributed the most self-time (each node credited to
15
+ * its most-specific URL, attributableURLs.at(-1), the same choice
16
+ * script-costs.js makes). ParseHTML nodes carry no attributable URL
17
+ * (main-thread-tasks.js only reads URLs from script events and stack
18
+ * frames, and stays byte-equivalent for cpu.urls consumers), so here
19
+ * they fall back to the document URL in args.beginData.url — a page
20
+ * blocked by parsing its own big HTML gets attributed to that
21
+ * document instead of disappearing. Tasks where no node carries any
22
+ * URL go to the 'unknown' bucket — browser-internal work (GC, style,
23
+ * layout without a JS trigger) no script owns.
24
+ *
25
+ * Blocking time is also split by KIND of work: each blocking task's
26
+ * subtree self-time per task group (parseHTML, styleLayout,
27
+ * scriptEvaluation, …) is scaled to the task's blocking share and
28
+ * summed per window into `kinds`. The per-URL list says who to blame;
29
+ * the kinds split says what kind of fix applies — script blocking
30
+ * needs code changes, parse/style blocking needs a smaller document
31
+ * or cheaper CSS.
32
+ *
33
+ * Two windows are reported: the whole trace, and navigationStart →
34
+ * the last largestContentfulPaint::Candidate event (blocking time in
35
+ * that window is what delayed LCP). Task times from
36
+ * main-thread-tasks.js are ms rebased to the first task, while the
37
+ * navigationStart / LCP timestamps are raw trace µs — the window
38
+ * check therefore uses the task's raw event.ts instead of converting
39
+ * back and forth. When the trace has no LCP candidate the LCP window
40
+ * is omitted rather than guessed.
41
+ *
42
+ * Returns:
43
+ * { totalBlockingTime, tasks, urls: [{ url, value, tasks }, …],
44
+ * kinds: { styleLayout: ms, … },
45
+ * beforeLargestContentfulPaint?: { totalBlockingTime, tasks,
46
+ * urls: [...], kinds: {...} } }
47
+ * Times in ms rounded to one decimal; url lists sorted by value desc
48
+ * and noise-filtered to entries above 10 ms, matching cpu.urls.
49
+ * Kinds that round to 0 ms are omitted.
50
+ */
51
+
52
+ import { getMainThreadTasks } from './main-thread-tasks.js';
53
+ import { computeTraceOfTab } from './trace-of-tab.js';
54
+
55
+ const BLOCKING_THRESHOLD_MS = 50;
56
+ const REPORT_LIMIT_MS = 10;
57
+ const UNKNOWN = 'unknown';
58
+
59
+ function round(ms) {
60
+ return Math.round(ms * 10) / 10;
61
+ }
62
+
63
+ function nodeUrl(node) {
64
+ const url = node.attributableURLs.at(-1);
65
+ if (url) return url;
66
+ if (node.event.name === 'ParseHTML') {
67
+ const beginData = node.event.args && node.event.args.beginData;
68
+ return beginData && beginData.url;
69
+ }
70
+ }
71
+
72
+ // One subtree walk computing both attributions for a blocking task:
73
+ // the dominant URL (most self-time) and the self-time per task kind.
74
+ function analyzeTask(task) {
75
+ const selfTimeByUrl = new Map();
76
+ const selfTimeByKind = new Map();
77
+ const stack = [task];
78
+ while (stack.length > 0) {
79
+ const node = stack.pop();
80
+ const url = nodeUrl(node);
81
+ if (url) {
82
+ selfTimeByUrl.set(url, (selfTimeByUrl.get(url) || 0) + node.selfTime);
83
+ }
84
+ const kind = node.group.id;
85
+ selfTimeByKind.set(kind, (selfTimeByKind.get(kind) || 0) + node.selfTime);
86
+ stack.push(...node.children);
87
+ }
88
+ let bestUrl;
89
+ let bestTime = 0;
90
+ for (const [url, time] of selfTimeByUrl) {
91
+ if (time > bestTime) {
92
+ bestUrl = url;
93
+ bestTime = time;
94
+ }
95
+ }
96
+ return { url: bestUrl || UNKNOWN, selfTimeByKind };
97
+ }
98
+
99
+ function newWindow() {
100
+ return {
101
+ totalBlockingTime: 0,
102
+ tasks: 0,
103
+ byUrl: new Map(),
104
+ byKind: new Map()
105
+ };
106
+ }
107
+
108
+ function addToWindow(window, task, blocking, analysis) {
109
+ window.totalBlockingTime += blocking;
110
+ window.tasks += 1;
111
+ let entry = window.byUrl.get(analysis.url);
112
+ if (!entry) {
113
+ entry = { url: analysis.url, value: 0, tasks: 0 };
114
+ window.byUrl.set(analysis.url, entry);
115
+ }
116
+ entry.value += blocking;
117
+ entry.tasks += 1;
118
+ // Scale each kind's subtree self-time to the task's blocking share,
119
+ // so the kinds sum to totalBlockingTime instead of raw durations.
120
+ for (const [kind, selfTime] of analysis.selfTimeByKind) {
121
+ const share = (selfTime / task.duration) * blocking;
122
+ window.byKind.set(kind, (window.byKind.get(kind) || 0) + share);
123
+ }
124
+ }
125
+
126
+ function finishWindow(window) {
127
+ const urls = [];
128
+ for (const entry of window.byUrl.values()) {
129
+ if (entry.value > REPORT_LIMIT_MS) {
130
+ entry.value = round(entry.value);
131
+ urls.push(entry);
132
+ }
133
+ }
134
+ urls.sort((a, b) => b.value - a.value);
135
+ const kinds = {};
136
+ for (const [kind, value] of [...window.byKind.entries()].toSorted(
137
+ (a, b) => b[1] - a[1]
138
+ )) {
139
+ const rounded = round(value);
140
+ if (rounded > 0) kinds[kind] = rounded;
141
+ }
142
+ return {
143
+ totalBlockingTime: round(window.totalBlockingTime),
144
+ tasks: window.tasks,
145
+ urls,
146
+ kinds
147
+ };
148
+ }
149
+
150
+ export function computeBlockingTime(trace) {
151
+ const { mainThreadEvents, timestamps } = computeTraceOfTab(trace);
152
+ const tasks = getMainThreadTasks(mainThreadEvents, timestamps.traceEnd);
153
+
154
+ let lcpEvent;
155
+ for (const event of trace.traceEvents) {
156
+ if (
157
+ event.name === 'largestContentfulPaint::Candidate' &&
158
+ (!lcpEvent || event.ts > lcpEvent.ts)
159
+ ) {
160
+ lcpEvent = event;
161
+ }
162
+ }
163
+
164
+ const total = newWindow();
165
+ const beforeLcp = newWindow();
166
+
167
+ for (const task of tasks) {
168
+ if (task.parent) continue;
169
+ if (task.duration <= BLOCKING_THRESHOLD_MS) continue;
170
+
171
+ const blocking = task.duration - BLOCKING_THRESHOLD_MS;
172
+ const analysis = analyzeTask(task);
173
+ addToWindow(total, task, blocking, analysis);
174
+
175
+ if (
176
+ lcpEvent &&
177
+ task.event.ts >= timestamps.navigationStart &&
178
+ task.event.ts < lcpEvent.ts
179
+ ) {
180
+ addToWindow(beforeLcp, task, blocking, analysis);
181
+ }
182
+ }
183
+
184
+ const result = finishWindow(total);
185
+ if (lcpEvent) {
186
+ result.beforeLargestContentfulPaint = finishWindow(beforeLcp);
187
+ }
188
+ return result;
189
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * First-party vs third-party main-thread rollup.
3
+ *
4
+ * cpu.urls answers "which URL costs the most main-thread time";
5
+ * this answers the level above it — how much of the main thread the
6
+ * site's own code uses versus embedded third parties, and which
7
+ * domains those third parties are. Each task's self-time is credited
8
+ * to its most-specific attributable URL (attributableURLs.at(-1),
9
+ * same choice as script-costs.js) so a task counts once — cpu.urls
10
+ * deliberately credits the full URL chain, which would double-count
11
+ * across domains here. Tasks with no attributable URL are
12
+ * browser-internal work no domain owns and are skipped.
13
+ *
14
+ * First-party is decided by registrable domain: the last two labels
15
+ * of the hostname, so every subdomain of the tested page's domain is
16
+ * first-party (en.wikipedia.org and upload.wikimedia.org roll up to
17
+ * wikipedia.org / wikimedia.org). Deliberately NOT a full public
18
+ * suffix list — two-level public suffixes (co.uk, com.au, …) group
19
+ * one label too high, which is acceptable for a rollup and avoids
20
+ * carrying an eTLD+1 dependency.
21
+ *
22
+ * Returns:
23
+ * { firstPartyDomain, firstParty, thirdParty,
24
+ * domains: [{ domain, value, firstParty }, …] }
25
+ * Times in ms rounded to one decimal; domains sorted by value desc
26
+ * and noise-filtered to entries above 10 ms, matching cpu.urls. The
27
+ * firstParty / thirdParty totals include the filtered-out remainder.
28
+ */
29
+
30
+ import { compute } from './main-thread-tasks.js';
31
+
32
+ const REPORT_LIMIT_MS = 10;
33
+
34
+ function round(ms) {
35
+ return Math.round(ms * 10) / 10;
36
+ }
37
+
38
+ function registrableDomain(hostname) {
39
+ const labels = hostname.split('.');
40
+ return labels.length <= 2 ? hostname : labels.slice(-2).join('.');
41
+ }
42
+
43
+ function domainOf(url) {
44
+ try {
45
+ const { hostname } = new URL(url);
46
+ return hostname ? registrableDomain(hostname) : undefined;
47
+ } catch {
48
+ return;
49
+ }
50
+ }
51
+
52
+ export function computeDomainBreakdown(trace, pageUrl) {
53
+ const firstPartyDomain = registrableDomain(new URL(pageUrl).hostname);
54
+ const byDomain = new Map();
55
+
56
+ for (const task of compute(trace)) {
57
+ const url = task.attributableURLs.at(-1);
58
+ if (!url) continue;
59
+ const domain = domainOf(url);
60
+ if (!domain) continue;
61
+ byDomain.set(domain, (byDomain.get(domain) || 0) + task.selfTime);
62
+ }
63
+
64
+ let firstParty = 0;
65
+ let thirdParty = 0;
66
+ const domains = [];
67
+ for (const [domain, value] of byDomain) {
68
+ const isFirstParty = domain === firstPartyDomain;
69
+ if (isFirstParty) {
70
+ firstParty += value;
71
+ } else {
72
+ thirdParty += value;
73
+ }
74
+ if (value > REPORT_LIMIT_MS) {
75
+ domains.push({ domain, value: round(value), firstParty: isFirstParty });
76
+ }
77
+ }
78
+ domains.sort((a, b) => b.value - a.value);
79
+
80
+ return {
81
+ firstPartyDomain,
82
+ firstParty: round(firstParty),
83
+ thirdParty: round(thirdParty),
84
+ domains
85
+ };
86
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Frame stability — how smoothly the compositor produced frames
3
+ * during the traced window. Jank (dropped frames, long gaps between
4
+ * visual updates) is invisible in load metrics; this surfaces it
5
+ * from the trace browsertime already records.
6
+ *
7
+ * Event shapes sampled from Chrome 150.0.7871.115 with the default
8
+ * browsertime trace categories. The frame pipeline shows up as
9
+ * `PipelineReporter` async events (cat
10
+ * `cc,benchmark,disabled-by-default-devtools.timeline.frame`), one
11
+ * b/e pair per frame attempt, paired via `id2.local` within a
12
+ * process. The `b` event carries `args.frame_reporter` with the
13
+ * final outcome: `state` (STATE_PRESENTED_ALL /
14
+ * STATE_PRESENTED_PARTIAL / STATE_DROPPED / STATE_NO_UPDATE_DESIRED),
15
+ * `affects_smoothness`, and `frame_sequence`. The `e` timestamp is
16
+ * when the frame reached the screen (presentation) for presented
17
+ * frames. The same frame_sequence can have several reporters
18
+ * (forked / main + impl), so outcomes are merged per sequence. The
19
+ * legacy instant events (BeginFrame, DrawFrame, DroppedFrame) are
20
+ * still emitted and were used to cross-check: DroppedFrame instants
21
+ * match STATE_DROPPED reporters with affects_smoothness=true.
22
+ *
23
+ * Only STATE_DROPPED frames flagged `affects_smoothness` are counted
24
+ * as dropped — Chrome also marks no-damage idle frames STATE_DROPPED
25
+ * and counting those would alarm on every static page.
26
+ *
27
+ * Returns: { presented, partial, dropped, effectiveFps, longestGap }
28
+ * presented — frames that reached the screen after
29
+ * navigationStart
30
+ * partial — subset of presented where the compositor
31
+ * presented without the main-thread update
32
+ * (STATE_PRESENTED_PARTIAL only)
33
+ * dropped — frames Chrome flags as dropped AND affecting
34
+ * smoothness (its own jank signal)
35
+ * effectiveFps — presented frames over the first→last
36
+ * presentation span; omitted with <2 presentations
37
+ * longestGap — { duration, startTime } in ms relative to
38
+ * navigationStart, the longest time between two
39
+ * consecutive presented frames. On mostly-static
40
+ * pages this includes idle time (nothing to draw),
41
+ * so read it as "longest time between visual
42
+ * updates", not proof of jank; omitted with <2
43
+ * presentations
44
+ */
45
+
46
+ import { computeTraceOfTab } from './trace-of-tab.js';
47
+
48
+ const PRESENTED_STATES = new Set([
49
+ 'STATE_PRESENTED_ALL',
50
+ 'STATE_PRESENTED_PARTIAL'
51
+ ]);
52
+
53
+ function round1(number_) {
54
+ return Math.round(number_ * 10) / 10;
55
+ }
56
+
57
+ export function computeFrameStability(trace) {
58
+ const { mainFrameIds, timestamps } = computeTraceOfTab(trace);
59
+ const navStart = timestamps.navigationStart;
60
+
61
+ const open = new Map();
62
+ const bySequence = new Map();
63
+ for (const event of trace.traceEvents) {
64
+ if (event.name !== 'PipelineReporter' || event.pid !== mainFrameIds.pid)
65
+ continue;
66
+ const id = (event.id2 && event.id2.local) || event.id;
67
+ if (event.ph === 'b') {
68
+ open.set(id, event);
69
+ } else if (event.ph === 'e') {
70
+ const begin = open.get(id);
71
+ if (!begin) continue;
72
+ open.delete(id);
73
+ if (event.ts < navStart) continue;
74
+ const reporter = (begin.args && begin.args.frame_reporter) || {};
75
+ if (reporter.frame_sequence === undefined) continue;
76
+ let frame = bySequence.get(reporter.frame_sequence);
77
+ if (!frame) {
78
+ frame = { presentedAll: false, presentedPartial: false };
79
+ bySequence.set(reporter.frame_sequence, frame);
80
+ }
81
+ if (PRESENTED_STATES.has(reporter.state)) {
82
+ if (reporter.state === 'STATE_PRESENTED_ALL') frame.presentedAll = true;
83
+ else frame.presentedPartial = true;
84
+ frame.presentationTs = Math.min(
85
+ frame.presentationTs ?? Number.POSITIVE_INFINITY,
86
+ event.ts
87
+ );
88
+ } else if (
89
+ reporter.state === 'STATE_DROPPED' &&
90
+ reporter.affects_smoothness === true
91
+ ) {
92
+ frame.dropped = true;
93
+ }
94
+ }
95
+ }
96
+
97
+ let presented = 0;
98
+ let partial = 0;
99
+ let dropped = 0;
100
+ const presentationTimes = [];
101
+ for (const frame of bySequence.values()) {
102
+ if (frame.presentedAll || frame.presentedPartial) {
103
+ presented++;
104
+ if (!frame.presentedAll) partial++;
105
+ presentationTimes.push(frame.presentationTs);
106
+ } else if (frame.dropped) {
107
+ dropped++;
108
+ }
109
+ }
110
+ presentationTimes.sort((a, b) => a - b);
111
+
112
+ let effectiveFps;
113
+ let longestGap;
114
+ if (presentationTimes.length >= 2) {
115
+ const spanSeconds = (presentationTimes.at(-1) - presentationTimes[0]) / 1e6;
116
+ if (spanSeconds > 0) {
117
+ effectiveFps = round1((presentationTimes.length - 1) / spanSeconds);
118
+ }
119
+ let worst = 0;
120
+ let worstStart = presentationTimes[0];
121
+ for (let index = 1; index < presentationTimes.length; index++) {
122
+ const gap = presentationTimes[index] - presentationTimes[index - 1];
123
+ if (gap > worst) {
124
+ worst = gap;
125
+ worstStart = presentationTimes[index - 1];
126
+ }
127
+ }
128
+ longestGap = {
129
+ duration: round1(worst / 1000),
130
+ startTime: round1((worstStart - navStart) / 1000)
131
+ };
132
+ }
133
+
134
+ return { presented, partial, dropped, effectiveFps, longestGap };
135
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Per-function JS cost from the V8 sampling profiler
3
+ * (disabled-by-default-v8.cpu_profiler, enabled on --enableProfileRun).
4
+ * The task-based analyses (scriptCosts, moduleCosts) attribute
5
+ * main-thread time to a URL or module; the sampled stacks answer the
6
+ * next question — which function inside that script the time was
7
+ * spent in.
8
+ *
9
+ * The category emits one Profile event per profiled V8 thread and a
10
+ * stream of ProfileChunk events carrying incremental call-tree nodes,
11
+ * sampled node ids and µs deltas between samples. Only the profile
12
+ * started on the inspected tab's main thread is read: the Profile
13
+ * event is emitted on the profiled thread, while its chunks arrive on
14
+ * V8's sampling thread and are tied back via the shared profile id +
15
+ * pid.
16
+ *
17
+ * Each sample's delta is credited as self time to the sampled frame
18
+ * and as total time to every distinct frame on the sampled stack
19
+ * (recursion counted once) — the definitions DevTools' bottom-up view
20
+ * uses. Frames aggregate per function identity (name + script +
21
+ * source position), so a function called from many sites is one row.
22
+ *
23
+ * V8 meta frames ((root), (program), (idle), (garbage collector))
24
+ * carry codeType 'other' and are skipped — idle is not cost and GC is
25
+ * already in cpu.categories. Engine frames without a script URL
26
+ * (e.g. querySelectorAll) are kept: the cost is real, it just has no
27
+ * source position.
28
+ *
29
+ * When the optional `bundles` map (url → location resolver, built by
30
+ * the coverage path) knows a frame's URL, the frame's position is
31
+ * resolved to the owning module inside the concatenated bundle and
32
+ * reported as `module`. V8 call-frame positions are 0-based while the
33
+ * resolvers — and the reported line/column — are 1-based (the trace
34
+ * event / DevTools convention). Frame URLs longer than 1024 chars are
35
+ * truncated by V8; they are recovered via unique prefix match against
36
+ * the bundle map so the reported url matches cpu.urls / moduleCosts.
37
+ *
38
+ * Returns [{ functionName, url?, line?, column?, module?, selfTime,
39
+ * totalTime, callees? }, …] in ms rounded to one decimal, sorted by
40
+ * selfTime desc, noise-filtered to self time ≥ 1 ms and capped at 50
41
+ * rows. `callees` (top 5 ≥ 1 ms, [{ functionName, url?, module?,
42
+ * value }]) breaks a function's non-self time down by what it called,
43
+ * so orchestrators (loaders, dispatchers) whose total dwarfs their
44
+ * self time show what they actually ran. Empty array when the trace
45
+ * carries no profiler data (the category is only enabled on profile
46
+ * runs).
47
+ */
48
+
49
+ import { findMainFrameIds } from './tracing-processor.js';
50
+
51
+ const REPORT_LIMIT_US = 1000;
52
+ const MAX_FUNCTIONS = 50;
53
+ const CALLEE_LIMIT_US = 1000;
54
+ const MAX_CALLEES = 5;
55
+ // V8 truncates callFrame.url in the trace (observed at 1024 chars),
56
+ // so long bundle URLs (MediaWiki load.php easily exceeds it) won't
57
+ // exact-match the coverage-path bundle map or the rest of the result.
58
+ const V8_URL_TRUNCATION_LIMIT = 1024;
59
+
60
+ function round(ms) {
61
+ return Math.round(ms * 10) / 10;
62
+ }
63
+
64
+ function functionKey(callFrame) {
65
+ return `${callFrame.functionName}|${callFrame.scriptId}|${callFrame.lineNumber}|${callFrame.columnNumber}`;
66
+ }
67
+
68
+ function isMetaFrame(callFrame) {
69
+ return callFrame.codeType === 'other';
70
+ }
71
+
72
+ // Recover the full bundle URL + resolver for a (possibly truncated)
73
+ // frame URL. Exact match first; for truncation-length URLs fall back
74
+ // to a unique prefix match, skipping when ambiguous.
75
+ function findBundle(bundles, url) {
76
+ if (!bundles || !url) return;
77
+ const exact = bundles.get(url);
78
+ if (exact) return { url, resolver: exact };
79
+ if (url.length < V8_URL_TRUNCATION_LIMIT) return;
80
+ let match;
81
+ for (const [bundleUrl, resolver] of bundles) {
82
+ if (bundleUrl.startsWith(url)) {
83
+ if (match) return;
84
+ match = { url: bundleUrl, resolver };
85
+ }
86
+ }
87
+ return match;
88
+ }
89
+
90
+ export function computeFunctionCosts(trace, bundles) {
91
+ const events = trace.traceEvents;
92
+ const { pid, tid } = findMainFrameIds(events);
93
+
94
+ const profileIds = new Set();
95
+ for (const event of events) {
96
+ if (event.name === 'Profile' && event.pid === pid && event.tid === tid) {
97
+ profileIds.add(event.id);
98
+ }
99
+ }
100
+ if (profileIds.size === 0) return [];
101
+
102
+ const nodes = new Map();
103
+ const samples = [];
104
+ const timeDeltas = [];
105
+ for (const event of events) {
106
+ if (
107
+ event.name !== 'ProfileChunk' ||
108
+ event.pid !== pid ||
109
+ !profileIds.has(event.id)
110
+ ) {
111
+ continue;
112
+ }
113
+ const data = event.args && event.args.data;
114
+ if (!data) continue;
115
+ const cpuProfile = data.cpuProfile;
116
+ if (cpuProfile && cpuProfile.nodes) {
117
+ for (const node of cpuProfile.nodes) nodes.set(node.id, node);
118
+ }
119
+ if (cpuProfile && cpuProfile.samples) samples.push(...cpuProfile.samples);
120
+ if (data.timeDeltas) timeDeltas.push(...data.timeDeltas);
121
+ }
122
+ if (samples.length === 0) return [];
123
+
124
+ // Lazily-named frames can miss their url; recover it from another
125
+ // node in the same script.
126
+ const scriptUrls = new Map();
127
+ for (const node of nodes.values()) {
128
+ const callFrame = node.callFrame;
129
+ if (callFrame.url && callFrame.scriptId) {
130
+ scriptUrls.set(callFrame.scriptId, callFrame.url);
131
+ }
132
+ }
133
+
134
+ // Distinct function keys on each node's stack, for total time.
135
+ const stackKeys = new Map();
136
+ function keysForNode(nodeId) {
137
+ const cached = stackKeys.get(nodeId);
138
+ if (cached) return cached;
139
+ const node = nodes.get(nodeId);
140
+ if (!node) return new Set();
141
+ const keys = new Set(node.parent ? keysForNode(node.parent) : undefined);
142
+ if (!isMetaFrame(node.callFrame)) keys.add(functionKey(node.callFrame));
143
+ stackKeys.set(nodeId, keys);
144
+ return keys;
145
+ }
146
+
147
+ const functions = new Map();
148
+ const nodesByKey = new Map();
149
+ const childNodes = new Map();
150
+ for (const node of nodes.values()) {
151
+ if (node.parent !== undefined) {
152
+ let siblings = childNodes.get(node.parent);
153
+ if (!siblings) {
154
+ siblings = [];
155
+ childNodes.set(node.parent, siblings);
156
+ }
157
+ siblings.push(node);
158
+ }
159
+ const callFrame = node.callFrame;
160
+ if (isMetaFrame(callFrame)) continue;
161
+ const key = functionKey(callFrame);
162
+ if (!functions.has(key)) {
163
+ functions.set(key, { callFrame, selfUs: 0, totalUs: 0 });
164
+ }
165
+ let keyNodes = nodesByKey.get(key);
166
+ if (!keyNodes) {
167
+ keyNodes = [];
168
+ nodesByKey.set(key, keyNodes);
169
+ }
170
+ keyNodes.push(node);
171
+ }
172
+
173
+ // Per-node inclusive time (its subtree's samples) — the cost of the
174
+ // call-tree edge from its parent, which is what the callee
175
+ // breakdown aggregates.
176
+ const nodeTotals = new Map();
177
+ for (const [index, nodeId] of samples.entries()) {
178
+ const delta = timeDeltas[index];
179
+ // The first delta rebases against the profile start and can be
180
+ // negative or missing; skip rather than credit negative time.
181
+ if (!delta || delta <= 0) continue;
182
+ const node = nodes.get(nodeId);
183
+ if (!node) continue;
184
+ if (!isMetaFrame(node.callFrame)) {
185
+ functions.get(functionKey(node.callFrame)).selfUs += delta;
186
+ }
187
+ for (const key of keysForNode(nodeId)) {
188
+ functions.get(key).totalUs += delta;
189
+ }
190
+ for (
191
+ let ancestor = node;
192
+ ancestor;
193
+ ancestor =
194
+ ancestor.parent === undefined ? undefined : nodes.get(ancestor.parent)
195
+ ) {
196
+ nodeTotals.set(ancestor.id, (nodeTotals.get(ancestor.id) || 0) + delta);
197
+ }
198
+ }
199
+
200
+ // Where a function's non-self time went: for every call-tree node of
201
+ // the function, each child's inclusive time is credited to the
202
+ // child's function identity. Answers "what did this orchestrator
203
+ // run" for loader/dispatcher rows whose total dwarfs their self
204
+ // time. Direct recursion shows the inner occurrence as a callee of
205
+ // itself, same as DevTools' top-down tree.
206
+ function calleesFor(key) {
207
+ const byCallee = new Map();
208
+ for (const node of nodesByKey.get(key) || []) {
209
+ for (const child of childNodes.get(node.id) || []) {
210
+ if (isMetaFrame(child.callFrame)) continue;
211
+ const childUs = nodeTotals.get(child.id);
212
+ if (!childUs) continue;
213
+ const childKey = functionKey(child.callFrame);
214
+ let entry = byCallee.get(childKey);
215
+ if (!entry) {
216
+ entry = { callFrame: child.callFrame, us: 0 };
217
+ byCallee.set(childKey, entry);
218
+ }
219
+ entry.us += childUs;
220
+ }
221
+ }
222
+ const callees = [];
223
+ for (const { callFrame, us } of [...byCallee.values()].toSorted(
224
+ (a, b) => b.us - a.us
225
+ )) {
226
+ if (us < CALLEE_LIMIT_US || callees.length >= MAX_CALLEES) break;
227
+ const callee = {
228
+ functionName: callFrame.functionName || '(anonymous)',
229
+ value: round(us / 1000)
230
+ };
231
+ const url = callFrame.url || scriptUrls.get(callFrame.scriptId) || '';
232
+ const bundle = findBundle(bundles, url);
233
+ if (bundle) {
234
+ callee.url = bundle.url;
235
+ } else if (url) {
236
+ callee.url = url;
237
+ }
238
+ if (bundle && callFrame.lineNumber !== undefined) {
239
+ const module_ = bundle.resolver.resolve(
240
+ callFrame.lineNumber + 1,
241
+ callFrame.columnNumber === undefined ? 1 : callFrame.columnNumber + 1
242
+ );
243
+ if (module_) callee.module = module_.name;
244
+ }
245
+ callees.push(callee);
246
+ }
247
+ return callees;
248
+ }
249
+
250
+ const costs = [];
251
+ for (const [key, { callFrame, selfUs, totalUs }] of functions.entries()) {
252
+ if (selfUs < REPORT_LIMIT_US) continue;
253
+ const url = callFrame.url || scriptUrls.get(callFrame.scriptId) || '';
254
+ const cost = {
255
+ functionName: callFrame.functionName || '(anonymous)',
256
+ selfTime: round(selfUs / 1000),
257
+ totalTime: round(totalUs / 1000)
258
+ };
259
+ const bundle = findBundle(bundles, url);
260
+ if (bundle) {
261
+ cost.url = bundle.url;
262
+ } else if (url) {
263
+ cost.url = url;
264
+ }
265
+ if (callFrame.lineNumber !== undefined) {
266
+ cost.line = callFrame.lineNumber + 1;
267
+ cost.column =
268
+ callFrame.columnNumber === undefined ? 1 : callFrame.columnNumber + 1;
269
+ if (bundle) {
270
+ const module_ = bundle.resolver.resolve(cost.line, cost.column);
271
+ if (module_) cost.module = module_.name;
272
+ }
273
+ }
274
+ const callees = calleesFor(key);
275
+ if (callees.length > 0) cost.callees = callees;
276
+ costs.push(cost);
277
+ }
278
+
279
+ costs.sort((a, b) => b.selfTime - a.selfTime);
280
+ return costs.slice(0, MAX_FUNCTIONS);
281
+ }