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.
- package/CHANGELOG.md +27 -3
- package/bin/browsertime.js +28 -4
- package/lib/chrome/coverage.js +89 -19
- package/lib/chrome/mediawikiResourceLoader.js +223 -0
- package/lib/chrome/parseCpuTrace.js +87 -2
- package/lib/chrome/trace/blocking-time.js +189 -0
- package/lib/chrome/trace/domain-breakdown.js +86 -0
- package/lib/chrome/trace/frame-stability.js +135 -0
- package/lib/chrome/trace/function-costs.js +281 -0
- package/lib/chrome/trace/index.js +7 -0
- package/lib/chrome/trace/module-costs.js +137 -0
- package/lib/chrome/trace/non-composited-animations.js +50 -5
- package/lib/chrome/trace/selector-stats.js +110 -0
- package/lib/chrome/trace/style-invalidations.js +162 -0
- package/lib/chrome/trace/task-groups.js +28 -2
- package/lib/chrome/trace/timer-costs.js +183 -0
- package/lib/chrome/webdriver/chromium.js +114 -0
- package/lib/chrome/webdriver/traceUtilities.js +11 -1
- package/lib/core/engine/collector.js +12 -0
- package/lib/core/engine/index.js +6 -0
- package/lib/support/cli.js +1 -1
- package/lib/support/har/index.js +44 -1
- package/package.json +4 -4
- package/types/chrome/mediawikiResourceLoader.d.ts +11 -0
- package/types/chrome/mediawikiResourceLoader.d.ts.map +1 -0
- package/types/chrome/parseCpuTrace.d.ts +1 -26
- package/types/chrome/parseCpuTrace.d.ts.map +1 -1
- package/types/chrome/trace/blocking-time.d.ts +7 -0
- package/types/chrome/trace/blocking-time.d.ts.map +1 -0
- package/types/chrome/trace/domain-breakdown.d.ts +11 -0
- package/types/chrome/trace/domain-breakdown.d.ts.map +1 -0
- package/types/chrome/trace/frame-stability.d.ts +11 -0
- package/types/chrome/trace/frame-stability.d.ts.map +1 -0
- package/types/chrome/trace/function-costs.d.ts +6 -0
- package/types/chrome/trace/function-costs.d.ts.map +1 -0
- package/types/chrome/trace/index.d.ts +7 -0
- package/types/chrome/trace/index.d.ts.map +1 -1
- package/types/chrome/trace/module-costs.d.ts +20 -0
- package/types/chrome/trace/module-costs.d.ts.map +1 -0
- package/types/chrome/trace/non-composited-animations.d.ts +1 -25
- package/types/chrome/trace/non-composited-animations.d.ts.map +1 -1
- package/types/chrome/trace/selector-stats.d.ts +8 -0
- package/types/chrome/trace/selector-stats.d.ts.map +1 -0
- package/types/chrome/trace/style-invalidations.d.ts +9 -0
- package/types/chrome/trace/style-invalidations.d.ts.map +1 -0
- package/types/chrome/trace/task-groups.d.ts.map +1 -1
- package/types/chrome/trace/timer-costs.d.ts +10 -0
- package/types/chrome/trace/timer-costs.d.ts.map +1 -0
- 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
|
+
}
|