browsertime 27.5.0 → 28.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -2
- package/bin/browsertime.js +28 -4
- package/browserscripts/pageinfo/loaf.js +15 -0
- package/browserscripts/pageinfo/loafSummary.js +24 -0
- package/lib/chrome/coverage.js +89 -19
- package/lib/chrome/mediawikiResourceLoader.js +225 -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 +128 -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
|
@@ -3,10 +3,28 @@ import {
|
|
|
3
3
|
computeMainThreadTasks,
|
|
4
4
|
computeScriptCosts,
|
|
5
5
|
computeForcedReflows,
|
|
6
|
-
computeNonCompositedAnimations
|
|
6
|
+
computeNonCompositedAnimations,
|
|
7
|
+
computeBlockingTime,
|
|
8
|
+
computeDomainBreakdown,
|
|
9
|
+
computeFrameStability,
|
|
10
|
+
computeStyleInvalidations,
|
|
11
|
+
computeSelectorStats,
|
|
12
|
+
computeTimerCosts
|
|
7
13
|
} from './trace/index.js';
|
|
14
|
+
import { labelForUrl } from './mediawikiResourceLoader.js';
|
|
8
15
|
const log = getLogger('browsertime.chrome.cpu');
|
|
9
16
|
|
|
17
|
+
// Additive label field for MediaWiki ResourceLoader URLs so per-bundle
|
|
18
|
+
// time series survive the weekly version= churn. The url field is
|
|
19
|
+
// untouched (browsertime.json shape is a public contract); non-MediaWiki
|
|
20
|
+
// URLs get no label and the output stays byte-identical to before.
|
|
21
|
+
function addResourceLoaderLabels(entries, urlField = 'url', field = 'label') {
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
const label = labelForUrl(entry[urlField]);
|
|
24
|
+
if (label) entry[field] = label;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
10
28
|
function round(number_, decimals = 3) {
|
|
11
29
|
const pow = Math.pow(10, decimals);
|
|
12
30
|
return Math.round(number_ * pow) / pow;
|
|
@@ -105,6 +123,67 @@ export async function parseCPUTrace(tracelog, url) {
|
|
|
105
123
|
} catch (error) {
|
|
106
124
|
log.debug('computeNonCompositedAnimations failed: %s', error.message);
|
|
107
125
|
}
|
|
126
|
+
// These return objects with summary totals, so on failure the
|
|
127
|
+
// key is omitted instead of publishing a misleading zero total.
|
|
128
|
+
let blocking;
|
|
129
|
+
try {
|
|
130
|
+
blocking = computeBlockingTime(tracelog);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
log.debug('computeBlockingTime failed: %s', error.message);
|
|
133
|
+
}
|
|
134
|
+
let domains;
|
|
135
|
+
try {
|
|
136
|
+
domains = computeDomainBreakdown(tracelog, url);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
log.debug('computeDomainBreakdown failed: %s', error.message);
|
|
139
|
+
}
|
|
140
|
+
let frames;
|
|
141
|
+
try {
|
|
142
|
+
frames = computeFrameStability(tracelog);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
log.debug('computeFrameStability failed: %s', error.message);
|
|
145
|
+
}
|
|
146
|
+
let styleInvalidations;
|
|
147
|
+
try {
|
|
148
|
+
styleInvalidations = computeStyleInvalidations(tracelog);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
log.debug('computeStyleInvalidations failed: %s', error.message);
|
|
151
|
+
}
|
|
152
|
+
// Only produces output when the SelectorStats trace category is
|
|
153
|
+
// on (--enableProfileRun); undefined omits the key.
|
|
154
|
+
let selectorStats;
|
|
155
|
+
try {
|
|
156
|
+
selectorStats = computeSelectorStats(tracelog);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
log.debug('computeSelectorStats failed: %s', error.message);
|
|
159
|
+
}
|
|
160
|
+
let timers;
|
|
161
|
+
try {
|
|
162
|
+
timers = computeTimerCosts(tracelog);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
log.debug('computeTimerCosts failed: %s', error.message);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
addResourceLoaderLabels(cleanedUrls);
|
|
168
|
+
addResourceLoaderLabels(scriptCosts);
|
|
169
|
+
addResourceLoaderLabels(
|
|
170
|
+
forcedReflows,
|
|
171
|
+
'triggeredByUrl',
|
|
172
|
+
'triggeredByUrlLabel'
|
|
173
|
+
);
|
|
174
|
+
if (blocking) {
|
|
175
|
+
addResourceLoaderLabels(blocking.urls);
|
|
176
|
+
if (blocking.beforeLargestContentfulPaint) {
|
|
177
|
+
addResourceLoaderLabels(blocking.beforeLargestContentfulPaint.urls);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (styleInvalidations) {
|
|
181
|
+
addResourceLoaderLabels(styleInvalidations.sources);
|
|
182
|
+
}
|
|
183
|
+
if (timers) {
|
|
184
|
+
addResourceLoaderLabels(timers.byUrl);
|
|
185
|
+
addResourceLoaderLabels(timers.sites);
|
|
186
|
+
}
|
|
108
187
|
|
|
109
188
|
log.debug('Chrome trace log finished parsed and sorted.');
|
|
110
189
|
return {
|
|
@@ -113,7 +192,13 @@ export async function parseCPUTrace(tracelog, url) {
|
|
|
113
192
|
urls: cleanedUrls,
|
|
114
193
|
scriptCosts,
|
|
115
194
|
forcedReflows,
|
|
116
|
-
nonCompositedAnimations
|
|
195
|
+
nonCompositedAnimations,
|
|
196
|
+
...(blocking ? { blocking } : {}),
|
|
197
|
+
...(domains ? { domains } : {}),
|
|
198
|
+
...(frames ? { frames } : {}),
|
|
199
|
+
...(styleInvalidations ? { styleInvalidations } : {}),
|
|
200
|
+
...(selectorStats ? { selectorStats } : {}),
|
|
201
|
+
...(timers ? { timers } : {})
|
|
117
202
|
};
|
|
118
203
|
} catch (error) {
|
|
119
204
|
log.error(
|
|
@@ -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
|
+
}
|