webperf-snippets 1.9.0 → 1.10.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/README.md +16 -2
- package/package.json +1 -1
- package/src/bin.js +20 -8
- package/src/crux.js +186 -1
- package/src/mcp/server.js +25 -7
- package/src/reporters/human.js +38 -3
- package/src/reporters/markdown.js +37 -2
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ npx webperf-snippets https://example.com --json
|
|
|
73
73
|
The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait, the version of the CLI and the machine: its CPU model, cores, architecture and memory, as the operating system reports them, and a benchmark index, a short CPU test in Node before Chromium starts, which only informs, since on the same machine it moves by up to 40 % with the load.
|
|
74
74
|
|
|
75
75
|
```json
|
|
76
|
-
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.
|
|
76
|
+
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.10.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Single snippet:
|
|
@@ -136,6 +136,19 @@ Field data (CrUX p75, mobile)
|
|
|
136
136
|
|
|
137
137
|
Get a key for the [Chrome UX Report API](https://developer.chrome.com/docs/crux/api) and pass it with `--crux-key` or the `CRUX_API_KEY` environment variable; prefer the variable, because a flag ends up in shell history and CI logs. The section appears in the human and markdown output and under `crux` in the `--json` output.
|
|
138
138
|
|
|
139
|
+
With a key, the field data also gives what CrUX knows of the LCP when it is an image: the p75 of its sub-parts, TTFB, load delay, load duration and render delay. When the measurement ran LCP-Subparts, with `--snippet LCP-Subparts` or the `core-web-vitals` workflow over 2.5 s, a table puts each one next to its lab value, with a note where one side is at least 1.5 times the other and 100 ms apart: a slower server or network for users, an image the lab finds sooner, slower connections, slower devices, or a lab slower than users. The field sub-parts describe only the loads whose LCP is an image, and the table says which share that is; when the LCP of the lab is not an image, only TTFB and render delay compare. Each p75 is taken on its own, so the sub-parts do not add up to the p75 of LCP. A line gives the navigation types of the users, by share, and points to Back-Forward-Cache when over 5 % of the visits come back without the back/forward cache. The JSON output carries them in `crux.subParts`, `crux.lcpImageShare`, `crux.roundTripTime` and `crux.navigationTypes`.
|
|
140
|
+
|
|
141
|
+
`--crux-history <weeks>` adds the trend of the field data over the last 1 to 40 weeks, from the [CrUX History API](https://developer.chrome.com/docs/crux/history-api), with the same key. Each week is the p75 of the 28 days before it, so the weeks overlap and the series is smooth. For LCP, CLS and INP the section gives the first and the last week, the change between them and its direction, `stable` within 5 %, and the weeks as a sparkline, where a gap is a week with too little data:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
Field data trend (CrUX p75, 40 weeks, mobile, origin-level)
|
|
145
|
+
LCP 2.88s → 3.10s ▲ +7.7% worsening ▃▃▄▃▃▃▄▆▇██▇▆▄▂▁▂▃▃▄▅▆▆▆▆▆▆
|
|
146
|
+
CLS 0.2600 → 0.2100 ▼ −19.2% improving ▅▆▆▇▇█▇▆▆▅▄▃▃▃▃▃▂▂▁▂▂▂▂▂▂▂
|
|
147
|
+
INP 253ms → 251ms ▼ −0.8% stable ▇▅▃▂▂▄▆██▇▅▃▂▃▄▆▅▃▂▁▂▃▃▄▅▆
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The JSON output carries it in `crux.history`, with the periods, the p75 of each week and the trend of each metric. Like the p75, it uses the page when it has data of its own and its origin otherwise, and says which in `scope`.
|
|
151
|
+
|
|
139
152
|
- The population follows `--viewport`: `desktop` uses desktop data, `mobile` and `tablet` use phone data. The heading says which one.
|
|
140
153
|
- When the page itself has no data, the CLI looks the origin up instead and labels the section `origin-level`, so it is not mistaken for page-level data. If the origin has none either, the output says `Field data: not available`.
|
|
141
154
|
- A CrUX error (quota, network, no answer within 10 seconds) only skips the section. It never changes the exit code, and the key is never printed.
|
|
@@ -158,7 +171,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
158
171
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
159
172
|
"navMs": 1240,
|
|
160
173
|
"results": [],
|
|
161
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
174
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.10.0" }
|
|
162
175
|
}
|
|
163
176
|
```
|
|
164
177
|
|
|
@@ -327,6 +340,7 @@ Each budget compares the value measured, or the median with `--runs`, and fails
|
|
|
327
340
|
| `--threshold <metric>=<v>` | The threshold of a metric for `--baseline`, such as `LCP=15%`, `LCP=200ms` or `CLS=0.1`. Repeat it for more metrics. |
|
|
328
341
|
| `--fail-on-regression` | Exit `1` when a metric regressed or an error is new against `--baseline`. |
|
|
329
342
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
343
|
+
| `--crux-history <weeks>` | Add the trend of the field data over the last 1 to 40 weeks. Needs a CrUX key. |
|
|
330
344
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
331
345
|
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
|
332
346
|
| `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value or was not measured. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"mcpName": "io.github.nucliweb/webperf-snippets",
|
|
5
5
|
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
|
|
6
6
|
"type": "module",
|
package/src/bin.js
CHANGED
|
@@ -10,7 +10,7 @@ import { reportHuman, reportMatrixHuman } from "./reporters/human.js";
|
|
|
10
10
|
import { reportJson } from "./reporters/json.js";
|
|
11
11
|
import { reportMarkdown, reportMatrixMarkdown } from "./reporters/markdown.js";
|
|
12
12
|
import { cliVersion, reportTo, validateReportUrl } from "./report-to.js";
|
|
13
|
-
import { fetchCrux, formFactorFor } from "./crux.js";
|
|
13
|
+
import { fetchCrux, fetchCruxHistory, formFactorFor } from "./crux.js";
|
|
14
14
|
import { MissingBrowserError } from "./browser.js";
|
|
15
15
|
import { parseHeader } from "./headers.js";
|
|
16
16
|
import { NETWORK_PRESETS, parseCpuFactor } from "./throttling.js";
|
|
@@ -80,6 +80,7 @@ Options:
|
|
|
80
80
|
LCP=200ms or CLS=0.1. Repeat it for more metrics
|
|
81
81
|
--fail-on-regression Exit 1 when a metric regressed or an error is new against --baseline
|
|
82
82
|
--crux-key <key> Add CrUX field data (p75) next to the measured values.
|
|
83
|
+
--crux-history <weeks> Add the trend of the field data over the last 1 to 40 weeks (needs a CrUX key).
|
|
83
84
|
Also read from the CRUX_API_KEY environment variable
|
|
84
85
|
--report-to <url> POST the results to this https URL after the run
|
|
85
86
|
(without it the CLI makes no external calls)
|
|
@@ -175,6 +176,7 @@ async function main() {
|
|
|
175
176
|
threshold: { type: "string", multiple: true },
|
|
176
177
|
"fail-on-regression": { type: "boolean" },
|
|
177
178
|
"crux-key": { type: "string" },
|
|
179
|
+
"crux-history": { type: "string" },
|
|
178
180
|
"report-to": { type: "string" },
|
|
179
181
|
"api-key": { type: "string" },
|
|
180
182
|
verbose: { type: "boolean" },
|
|
@@ -253,6 +255,19 @@ async function main() {
|
|
|
253
255
|
}
|
|
254
256
|
if (medianBy != null && runCount == null && !matrixPath) fail("--median-by needs --runs");
|
|
255
257
|
|
|
258
|
+
// The field data: the p75 of the last 28 days with a key, and its trend week by week with --crux-history
|
|
259
|
+
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
260
|
+
let cruxWeeks;
|
|
261
|
+
if (values["crux-history"] !== undefined) {
|
|
262
|
+
cruxWeeks = /^\d+$/.test(values["crux-history"]) ? Number(values["crux-history"]) : NaN;
|
|
263
|
+
if (!(cruxWeeks >= 1 && cruxWeeks <= 40)) fail(`--crux-history takes 1 to 40 weeks, not "${values["crux-history"]}"`);
|
|
264
|
+
if (!cruxKey) fail("--crux-history needs a CrUX key: --crux-key or CRUX_API_KEY");
|
|
265
|
+
}
|
|
266
|
+
const fieldDataOf = async (url, formFactor) => {
|
|
267
|
+
const crux = await fetchCrux({ url, apiKey: cruxKey, formFactor });
|
|
268
|
+
return cruxWeeks ? { ...crux, history: await fetchCruxHistory({ url, apiKey: cruxKey, formFactor, weeks: cruxWeeks }) } : crux;
|
|
269
|
+
};
|
|
270
|
+
|
|
256
271
|
// The baseline is read and checked before the browser opens: a wrong file fails at once
|
|
257
272
|
let baseline;
|
|
258
273
|
let thresholds;
|
|
@@ -301,7 +316,7 @@ async function main() {
|
|
|
301
316
|
} catch (err) {
|
|
302
317
|
fail(err.message);
|
|
303
318
|
}
|
|
304
|
-
await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets });
|
|
319
|
+
await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets, cruxKey, fieldDataOf });
|
|
305
320
|
return;
|
|
306
321
|
}
|
|
307
322
|
|
|
@@ -316,9 +331,7 @@ async function main() {
|
|
|
316
331
|
}
|
|
317
332
|
payload.conditions = conditionsFor(viewportName);
|
|
318
333
|
|
|
319
|
-
|
|
320
|
-
const crux = cruxKey ? await fetchCrux({ url, apiKey: cruxKey, formFactor: formFactorFor(viewportName) }) : undefined;
|
|
321
|
-
if (crux) payload.crux = crux;
|
|
334
|
+
if (cruxKey) payload.crux = await fieldDataOf(url, formFactorFor(viewportName));
|
|
322
335
|
|
|
323
336
|
if (baseline) {
|
|
324
337
|
payload.baseline = compareRun(baseline.data, payload, { file: baseline.file, thresholds });
|
|
@@ -394,7 +407,7 @@ async function measureRuns(measure, count, medianBy, progress) {
|
|
|
394
407
|
|
|
395
408
|
// Every combination of the matrix, in order, each one `matrix.runs` times. Each combination reports
|
|
396
409
|
// its median run, and the exit code follows the same rules as one page, for every combination.
|
|
397
|
-
async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets }) {
|
|
410
|
+
async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets, cruxKey, fieldDataOf }) {
|
|
398
411
|
for (const warning of matrix.warnings) process.stderr.write(`Warning: ${warning}\n`);
|
|
399
412
|
const viewportOf = new Map(matrix.combinations.map((c) => [c.id, c.viewport]));
|
|
400
413
|
|
|
@@ -414,13 +427,12 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
|
|
|
414
427
|
}
|
|
415
428
|
|
|
416
429
|
// The field data of a URL depends on the form factor, not on the variant: one query for each pair
|
|
417
|
-
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
418
430
|
if (cruxKey) {
|
|
419
431
|
const queries = new Map();
|
|
420
432
|
for (const c of combinations.filter((c) => !c.error)) {
|
|
421
433
|
const formFactor = formFactorFor(viewportOf.get(c.id));
|
|
422
434
|
const key = `${c.url} ${formFactor}`;
|
|
423
|
-
if (!queries.has(key)) queries.set(key,
|
|
435
|
+
if (!queries.has(key)) queries.set(key, fieldDataOf(c.url, formFactor));
|
|
424
436
|
const crux = await queries.get(key);
|
|
425
437
|
if (crux) c.crux = crux;
|
|
426
438
|
}
|
package/src/crux.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { rate } from "./ratings.js";
|
|
2
2
|
|
|
3
3
|
const DEFAULT_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
|
|
4
|
+
// The History API lives next to the daily one; an endpoint given for tests or a proxy points at both
|
|
5
|
+
const historyEndpointOf = (endpoint) => endpoint.replace(/records:queryRecord$/, "records:queryHistoryRecord");
|
|
4
6
|
const CRUX_TIMEOUT_MS = 10000;
|
|
5
7
|
|
|
6
8
|
const METRICS = [
|
|
@@ -27,6 +29,42 @@ function parseMetrics(record) {
|
|
|
27
29
|
return metrics;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
// The sub-parts of the LCP as CrUX reports them, for the loads whose LCP is an image, by the names of
|
|
33
|
+
// the lab sub-parts of LCP-Subparts
|
|
34
|
+
const SUB_PARTS = [
|
|
35
|
+
{ part: "ttfb", label: "TTFB", key: "largest_contentful_paint_image_time_to_first_byte" },
|
|
36
|
+
{ part: "resourceLoadDelay", label: "Load delay", key: "largest_contentful_paint_image_resource_load_delay" },
|
|
37
|
+
{ part: "resourceLoadTime", label: "Load duration", key: "largest_contentful_paint_image_resource_load_duration" },
|
|
38
|
+
{ part: "elementRenderDelay", label: "Render delay", key: "largest_contentful_paint_image_element_render_delay" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const p75Of = (record, key) => {
|
|
42
|
+
const value = Number(record?.metrics?.[key]?.percentiles?.p75);
|
|
43
|
+
return Number.isFinite(value) ? value : undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// What else the record of a page says besides LCP, CLS and INP: the sub-parts of an image LCP, the
|
|
47
|
+
// share of loads whose LCP is an image, the round trip time and the navigation types, by share
|
|
48
|
+
export function parseFieldExtras(record) {
|
|
49
|
+
const extras = {};
|
|
50
|
+
const subParts = {};
|
|
51
|
+
for (const { part, key } of SUB_PARTS) {
|
|
52
|
+
const value = p75Of(record, key);
|
|
53
|
+
if (value !== undefined) subParts[part] = value;
|
|
54
|
+
}
|
|
55
|
+
if (Object.keys(subParts).length) extras.subParts = subParts;
|
|
56
|
+
const imageShare = record?.metrics?.largest_contentful_paint_resource_type?.fractions?.image;
|
|
57
|
+
if (typeof imageShare === "number") extras.lcpImageShare = imageShare;
|
|
58
|
+
const rtt = p75Of(record, "round_trip_time");
|
|
59
|
+
if (rtt !== undefined) extras.roundTripTime = rtt;
|
|
60
|
+
const fractions = record?.metrics?.navigation_types?.fractions;
|
|
61
|
+
if (fractions) {
|
|
62
|
+
const sorted = Object.entries(fractions).filter(([, share]) => share > 0).sort((a, b) => b[1] - a[1]);
|
|
63
|
+
if (sorted.length) extras.navigationTypes = Object.fromEntries(sorted);
|
|
64
|
+
}
|
|
65
|
+
return extras;
|
|
66
|
+
}
|
|
67
|
+
|
|
30
68
|
async function query({ endpoint, apiKey, body, timeoutMs }) {
|
|
31
69
|
const res = await fetch(`${endpoint}?key=${encodeURIComponent(apiKey)}`, {
|
|
32
70
|
method: "POST",
|
|
@@ -61,7 +99,78 @@ export async function fetchCrux({
|
|
|
61
99
|
if (answer.status) return { unavailable: `the CrUX API answered ${answer.status}`, failed: true };
|
|
62
100
|
const metrics = parseMetrics(answer.record);
|
|
63
101
|
if (Object.keys(metrics).length === 0) return { unavailable: "no Core Web Vitals in the CrUX record" };
|
|
64
|
-
return { scope, formFactor, metrics };
|
|
102
|
+
return { scope, formFactor, metrics, ...parseFieldExtras(answer.record) };
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? "request failed");
|
|
105
|
+
return { unavailable: reason, failed: true };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A trend compares the first and the last periods with data: each period already covers 28 days, so the
|
|
110
|
+
// series is smooth enough. Within 5 % it is stable; lower is better for every metric.
|
|
111
|
+
const STABLE_WITHIN = 5;
|
|
112
|
+
|
|
113
|
+
export function trendOf(p75s) {
|
|
114
|
+
const values = p75s.filter((v) => v !== null);
|
|
115
|
+
const first = values[0];
|
|
116
|
+
const last = values.at(-1);
|
|
117
|
+
if (values.length < 2) return { first, last, change: null, trend: null };
|
|
118
|
+
const change = first === 0 ? null : Math.round(((last - first) / first) * 1000) / 10;
|
|
119
|
+
const trend = change === null || Math.abs(change) <= STABLE_WITHIN ? "stable" : change < 0 ? "improving" : "worsening";
|
|
120
|
+
return { first, last, change, trend };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const BARS = "▁▂▃▄▅▆▇█";
|
|
124
|
+
|
|
125
|
+
// The series as bars between its lowest and highest values, a space for a period without data
|
|
126
|
+
export function sparkline(values) {
|
|
127
|
+
const known = values.filter((v) => v !== null);
|
|
128
|
+
const min = Math.min(...known);
|
|
129
|
+
const max = Math.max(...known);
|
|
130
|
+
return values.map((v) => (v === null ? " " : max === min ? BARS[3] : BARS[Math.round(((v - min) / (max - min)) * 7)])).join("");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const dateOf = ({ year, month, day }) => `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
134
|
+
|
|
135
|
+
// The record of the History API as periods, oldest first, and the p75s of each Core Web Vital
|
|
136
|
+
export function parseHistory(record) {
|
|
137
|
+
const periods = (record?.collectionPeriods ?? []).map((p) => ({ start: dateOf(p.firstDate), end: dateOf(p.lastDate) }));
|
|
138
|
+
const metrics = {};
|
|
139
|
+
for (const m of METRICS) {
|
|
140
|
+
const raw = record?.metrics?.[m.key]?.percentilesTimeseries?.p75s;
|
|
141
|
+
if (!raw) continue;
|
|
142
|
+
// CLS comes as strings ("0.21"), and a period with too little data as null
|
|
143
|
+
const p75s = raw.map((v) => (v === null || v === undefined || !Number.isFinite(Number(v)) ? null : Number(v)));
|
|
144
|
+
if (p75s.every((v) => v === null)) continue;
|
|
145
|
+
metrics[m.name] = { unit: m.unit, p75s, ...trendOf(p75s) };
|
|
146
|
+
}
|
|
147
|
+
return { periods, metrics };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The p75 of LCP, CLS and INP week by week over the last `weeks` periods (1 to 40), from the History
|
|
151
|
+
// API: the URL, or its origin when the URL has too little traffic. Never throws, and like fetchCrux
|
|
152
|
+
// never puts the key in a message.
|
|
153
|
+
export async function fetchCruxHistory({
|
|
154
|
+
url,
|
|
155
|
+
apiKey,
|
|
156
|
+
weeks,
|
|
157
|
+
formFactor = "PHONE",
|
|
158
|
+
endpoint = process.env.WEBPERF_CRUX_ENDPOINT ?? DEFAULT_ENDPOINT,
|
|
159
|
+
timeoutMs = CRUX_TIMEOUT_MS,
|
|
160
|
+
}) {
|
|
161
|
+
const history = historyEndpointOf(endpoint);
|
|
162
|
+
try {
|
|
163
|
+
let scope = "url";
|
|
164
|
+
let answer = await query({ endpoint: history, apiKey, body: { url, formFactor, collectionPeriodCount: weeks }, timeoutMs });
|
|
165
|
+
if (answer.notFound) {
|
|
166
|
+
scope = "origin";
|
|
167
|
+
answer = await query({ endpoint: history, apiKey, body: { origin: new URL(url).origin, formFactor, collectionPeriodCount: weeks }, timeoutMs });
|
|
168
|
+
}
|
|
169
|
+
if (answer.notFound) return { unavailable: "not in the CrUX dataset" };
|
|
170
|
+
if (answer.status) return { unavailable: `the CrUX History API answered ${answer.status}`, failed: true };
|
|
171
|
+
const { periods, metrics } = parseHistory(answer.record);
|
|
172
|
+
if (Object.keys(metrics).length === 0) return { unavailable: "no Core Web Vitals in the CrUX history" };
|
|
173
|
+
return { scope, formFactor, weeks: periods.length, periods, metrics };
|
|
65
174
|
} catch (err) {
|
|
66
175
|
const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? "request failed");
|
|
67
176
|
return { unavailable: reason, failed: true };
|
|
@@ -84,3 +193,79 @@ export function fieldDataView(crux, results) {
|
|
|
84
193
|
});
|
|
85
194
|
return { title, rows };
|
|
86
195
|
}
|
|
196
|
+
|
|
197
|
+
// The trend of the field data, shared by both reporters: per metric the first and last p75 of the
|
|
198
|
+
// weeks, the change and its direction, and the series as a sparkline
|
|
199
|
+
export function historyView(history) {
|
|
200
|
+
const label = FORM_FACTOR_LABEL[history.formFactor] ?? history.formFactor.toLowerCase();
|
|
201
|
+
const title = `Field data trend (CrUX p75, ${history.weeks} weeks, ${label}${history.scope === "origin" ? ", origin-level" : ""})`;
|
|
202
|
+
const rows = METRICS.filter((m) => history.metrics[m.name]).map((m) => {
|
|
203
|
+
const { p75s, first, last, change, trend } = history.metrics[m.name];
|
|
204
|
+
const changeText = change === null ? "·" : `${change > 0 ? "▲ +" : change < 0 ? "▼ −" : "= "}${Math.abs(change)}%`;
|
|
205
|
+
return { metric: m.name, unit: m.unit, first, last, change: changeText, trend: trend ?? "·", sparkline: sparkline(p75s) };
|
|
206
|
+
});
|
|
207
|
+
return { title, rows };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const share = (fraction) => (fraction < 0.01 ? `${Math.round(fraction * 1000) / 10}%` : `${Math.round(fraction * 100)}%`);
|
|
211
|
+
|
|
212
|
+
// A sub-part counts as different when one side is at least 1.5 times the other and 100 ms apart: less
|
|
213
|
+
// than that is within the noise of the lab and of a percentile
|
|
214
|
+
const differs = (slow, fast) => slow >= fast * 1.5 && slow - fast >= 100;
|
|
215
|
+
|
|
216
|
+
const rttNote = (rtt) => (rtt === undefined ? "" : ` (RTT p75 ${rtt} ms)`);
|
|
217
|
+
const FIELD_SLOWER = {
|
|
218
|
+
ttfb: (rtt) => `real users wait longer for the server: network distance${rttNote(rtt)}, the CDN or cache misses the lab does not see`,
|
|
219
|
+
resourceLoadDelay: () => 'the lab finds the image sooner: preload it or give it fetchpriority="high", or users run more scripts before it',
|
|
220
|
+
resourceLoadTime: (rtt) => `slower connections in the field${rttNote(rtt)}: compress or resize the LCP image`,
|
|
221
|
+
elementRenderDelay: () => "slower devices or more main-thread work in the field",
|
|
222
|
+
};
|
|
223
|
+
const LAB_SLOWER = "the lab is slower than users: check the CPU and network throttling of the lab";
|
|
224
|
+
|
|
225
|
+
// The sub-parts of the field next to those of LCP-Subparts in the lab, when it ran, with a note where
|
|
226
|
+
// they differ, and what the field sub-parts describe. Null without field sub-parts.
|
|
227
|
+
export function subPartsView(extras, results) {
|
|
228
|
+
if (!extras.subParts) return null;
|
|
229
|
+
const labResult = results.find((r) => r.script === "LCP-Subparts" && r.status === "ok");
|
|
230
|
+
const labParts = labResult?.details?.subParts ?? {};
|
|
231
|
+
// The field sub-parts are those of image LCPs: a lab LCP without a resource, such as text, has no load
|
|
232
|
+
// delay or duration to set against them
|
|
233
|
+
const labImage = !labResult || Boolean(labResult.details?.url);
|
|
234
|
+
const rows = SUB_PARTS.filter(({ part }) => extras.subParts[part] !== undefined).map(({ part, label }) => {
|
|
235
|
+
const field = extras.subParts[part];
|
|
236
|
+
const comparable = labImage || part === "ttfb" || part === "elementRenderDelay";
|
|
237
|
+
const lab = comparable ? (labParts[part]?.value ?? null) : null;
|
|
238
|
+
let note = null;
|
|
239
|
+
if (lab !== null && differs(field, lab)) note = FIELD_SLOWER[part](extras.roundTripTime);
|
|
240
|
+
else if (lab !== null && differs(lab, field)) note = LAB_SLOWER;
|
|
241
|
+
return { part, label, field, lab, note };
|
|
242
|
+
});
|
|
243
|
+
const caveats = [
|
|
244
|
+
...(extras.lcpImageShare !== undefined ? [`The field sub-parts describe the ${share(extras.lcpImageShare)} of loads whose LCP is an image.`] : []),
|
|
245
|
+
...(labImage ? [] : ["The LCP of the lab is not an image, so it has no load delay or duration to compare."]),
|
|
246
|
+
"Each p75 is taken on its own, so the sub-parts do not add up to the p75 of LCP.",
|
|
247
|
+
];
|
|
248
|
+
return { rows, caveats };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const NAVIGATION_LABELS = {
|
|
252
|
+
navigate: "navigate",
|
|
253
|
+
navigate_cache: "navigate from cache",
|
|
254
|
+
reload: "reload",
|
|
255
|
+
restore: "restore",
|
|
256
|
+
back_forward: "back/forward",
|
|
257
|
+
back_forward_cache: "back/forward cache",
|
|
258
|
+
prerender: "prerender",
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// How users arrive at the page, by share, and a pointer to Back-Forward-Cache when over 5 % of the
|
|
262
|
+
// visits come back without the back/forward cache, which would have shown the page at once
|
|
263
|
+
export function navigationView(extras) {
|
|
264
|
+
if (!extras.navigationTypes) return null;
|
|
265
|
+
const parts = Object.entries(extras.navigationTypes).map(([type, fraction]) => `${NAVIGATION_LABELS[type] ?? type} ${share(fraction)}`);
|
|
266
|
+
const missed = extras.navigationTypes.back_forward ?? 0;
|
|
267
|
+
return {
|
|
268
|
+
line: `Navigations: ${parts.join(", ")}`,
|
|
269
|
+
hint: missed > 0.05 ? `${share(missed)} of the visits come back without the back/forward cache: run Back-Forward-Cache to see what blocks it.` : null,
|
|
270
|
+
};
|
|
271
|
+
}
|
package/src/mcp/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { isTrackingSnippet } from "../tracking.js";
|
|
|
7
7
|
import { runSnippets, runMeasurement, evaluateItems, collectTrackingData, VIEWPORT_PRESETS } from "../runner.js";
|
|
8
8
|
import { WORKFLOWS } from "../workflows/index.js";
|
|
9
9
|
import { RULES } from "../decision-tree.js";
|
|
10
|
-
import { fetchCrux } from "../crux.js";
|
|
10
|
+
import { fetchCrux, fetchCruxHistory } from "../crux.js";
|
|
11
11
|
import { runInteractions, isInputStep, settle } from "../interactions.js";
|
|
12
12
|
import { createSessions, SessionError } from "./sessions.js";
|
|
13
13
|
import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js";
|
|
@@ -295,10 +295,11 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
295
295
|
{
|
|
296
296
|
title: "Get CrUX field data",
|
|
297
297
|
description:
|
|
298
|
-
"Returns the p75 of LCP, CLS and INP that real Chrome users had on a URL over the last 28 days, from the Chrome UX Report, with their rating. Falls back to the origin when the URL has no data of its own. Compare it with the lab values of run_snippet: field data is what users get.",
|
|
298
|
+
"Returns the p75 of LCP, CLS and INP that real Chrome users had on a URL over the last 28 days, from the Chrome UX Report, with their rating. Falls back to the origin when the URL has no data of its own. Compare it with the lab values of run_snippet: field data is what users get. With weeks, it adds the trend: the p75 of each week, each the 28 days before it, with the change from the first week to the last.",
|
|
299
299
|
inputSchema: z.object({
|
|
300
300
|
url: HttpUrl,
|
|
301
301
|
formFactor: z.enum(Object.keys(CRUX_FORM_FACTORS)).default("phone").describe("The devices the data describes"),
|
|
302
|
+
weeks: z.number().int().min(1).max(40).optional().describe("Add the trend of the last 1 to 40 weeks"),
|
|
302
303
|
}),
|
|
303
304
|
outputSchema: z.object({
|
|
304
305
|
url: z.string(),
|
|
@@ -309,21 +310,38 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
309
310
|
.optional()
|
|
310
311
|
.describe("By metric name: LCP, CLS, INP"),
|
|
311
312
|
unavailable: z.string().optional().describe("Why there is no field data, such as a page with too little traffic"),
|
|
313
|
+
subParts: z
|
|
314
|
+
.record(z.string(), z.number())
|
|
315
|
+
.optional()
|
|
316
|
+
.describe("The p75 of the sub-parts of an image LCP: ttfb, resourceLoadDelay, resourceLoadTime, elementRenderDelay"),
|
|
317
|
+
lcpImageShare: z.number().optional().describe("The share of loads whose LCP is an image, which the sub-parts describe"),
|
|
318
|
+
roundTripTime: z.number().optional().describe("The p75 of the round trip time of the users, in ms"),
|
|
319
|
+
navigationTypes: z.record(z.string(), z.number()).optional().describe("The share of each navigation type, such as navigate, reload or back_forward_cache"),
|
|
320
|
+
history: z
|
|
321
|
+
.looseObject({})
|
|
322
|
+
.meta({ additionalProperties: true })
|
|
323
|
+
.optional()
|
|
324
|
+
.describe("With weeks: scope, weeks, periods, and per metric its p75s, first, last, change in percent and trend"),
|
|
312
325
|
}),
|
|
313
326
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
314
327
|
},
|
|
315
|
-
async ({ url, formFactor }) => {
|
|
328
|
+
async ({ url, formFactor, weeks }) => {
|
|
316
329
|
const apiKey = env.CRUX_API_KEY;
|
|
317
330
|
if (!apiKey) return toolError(CRUX_SETUP);
|
|
318
|
-
const
|
|
331
|
+
const options = {
|
|
319
332
|
url,
|
|
320
333
|
apiKey,
|
|
321
334
|
formFactor: CRUX_FORM_FACTORS[formFactor],
|
|
322
335
|
...(env.WEBPERF_CRUX_ENDPOINT ? { endpoint: env.WEBPERF_CRUX_ENDPOINT } : {}),
|
|
323
|
-
}
|
|
336
|
+
};
|
|
337
|
+
const crux = await fetchCrux(options);
|
|
324
338
|
if (crux.failed) return toolError(`Could not read the CrUX data: ${crux.unavailable}.`);
|
|
325
|
-
|
|
326
|
-
|
|
339
|
+
const history = weeks ? await fetchCruxHistory({ ...options, weeks }) : undefined;
|
|
340
|
+
// A history that failed says why in its own unavailable, next to the p75 that did come
|
|
341
|
+
const trend = history ? { history: history.failed ? { unavailable: history.unavailable } : history } : {};
|
|
342
|
+
if (crux.unavailable) return toolResult({ url, formFactor, unavailable: crux.unavailable, ...trend });
|
|
343
|
+
const { scope, metrics, subParts, lcpImageShare, roundTripTime, navigationTypes } = crux;
|
|
344
|
+
return toolResult({ url, formFactor, scope, metrics, subParts, lcpImageShare, roundTripTime, navigationTypes, ...trend });
|
|
327
345
|
}
|
|
328
346
|
);
|
|
329
347
|
|
package/src/reporters/human.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { styleText } from "node:util";
|
|
2
2
|
import { describeResult } from "../view.js";
|
|
3
3
|
import { isSummaryResult, summaryNumbers } from "./summary-view.js";
|
|
4
|
-
import { fieldDataView } from "../crux.js";
|
|
4
|
+
import { fieldDataView, historyView, subPartsView, navigationView } from "../crux.js";
|
|
5
5
|
import { describeThrottling } from "../throttling.js";
|
|
6
6
|
import { describeRuns, describeAgreement } from "../runs.js";
|
|
7
7
|
import { matrixView } from "./matrix-view.js";
|
|
@@ -197,7 +197,8 @@ function renderResult(r, verbose) {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
function renderFieldData(crux, results) {
|
|
200
|
-
|
|
200
|
+
const trend = crux.history ? ["", ...renderFieldTrend(crux.history)] : [];
|
|
201
|
+
if (crux.unavailable) return [styleText("dim", `Field data: not available (${crux.unavailable})`), ...trend];
|
|
201
202
|
const { title, rows } = fieldDataView(crux, results);
|
|
202
203
|
const lines = [styleText("bold", title)];
|
|
203
204
|
for (const row of rows) {
|
|
@@ -207,7 +208,41 @@ function renderFieldData(crux, results) {
|
|
|
207
208
|
` ${icon} ${pad(row.metric, 6)} ${pad(paint(row.rating, formatValue(row.p75, row.unit)), 10)} ${pad(styleText("dim", row.rating), 20)} ${styleText("dim", `(synthetic: ${synthetic})`)}`
|
|
208
209
|
);
|
|
209
210
|
}
|
|
210
|
-
return lines;
|
|
211
|
+
return [...lines, ...renderFieldSubParts(crux, results), ...renderNavigations(crux), ...trend];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// "TTFB 1.53s lab 400ms real users wait longer for the server…"
|
|
215
|
+
function renderFieldSubParts(crux, results) {
|
|
216
|
+
const view = subPartsView(crux, results);
|
|
217
|
+
if (!view) return [];
|
|
218
|
+
return [
|
|
219
|
+
"",
|
|
220
|
+
styleText("bold", "LCP sub-parts (CrUX p75 against the lab)"),
|
|
221
|
+
...view.rows.map(
|
|
222
|
+
(row) => ` ${pad(row.label, 14)}${pad(formatValue(row.field, "ms"), 10)}${pad(`lab ${row.lab === null ? "n/a" : formatValue(row.lab, "ms")}`, 12)}${row.note ?? ""}`.trimEnd()
|
|
223
|
+
),
|
|
224
|
+
...view.caveats.map((caveat) => styleText("dim", ` ${caveat}`)),
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderNavigations(crux) {
|
|
229
|
+
const view = navigationView(crux);
|
|
230
|
+
if (!view) return [];
|
|
231
|
+
return ["", styleText("dim", view.line), ...(view.hint ? [` ${view.hint}`] : [])];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// "LCP 2.88s → 3.10s ▲ +7.7% worsening ▁▅█"
|
|
235
|
+
function renderFieldTrend(history) {
|
|
236
|
+
if (history.unavailable) return [styleText("dim", `Field data trend: not available (${history.unavailable})`)];
|
|
237
|
+
const { title, rows } = historyView(history);
|
|
238
|
+
const style = { improving: "green", worsening: "red" };
|
|
239
|
+
return [
|
|
240
|
+
styleText("bold", title),
|
|
241
|
+
...rows.map(
|
|
242
|
+
(row) =>
|
|
243
|
+
` ${pad(row.metric, 6)}${pad(`${formatValue(row.first, row.unit)} → ${formatValue(row.last, row.unit)}`, 20)}${pad(row.change, 10)}${pad(style[row.trend] ? styleText(style[row.trend], row.trend) : row.trend, 12)}${row.sparkline}`
|
|
244
|
+
),
|
|
245
|
+
];
|
|
211
246
|
}
|
|
212
247
|
|
|
213
248
|
// The runs line, and the median, min and max of each metric across the runs, with how much they agree
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fieldDataView } from "../crux.js";
|
|
1
|
+
import { fieldDataView, historyView, subPartsView, navigationView } from "../crux.js";
|
|
2
2
|
import { describeResult } from "../view.js";
|
|
3
3
|
import { isSummaryResult, summaryNumbers } from "./summary-view.js";
|
|
4
4
|
import { describeThrottling } from "../throttling.js";
|
|
@@ -102,7 +102,8 @@ function renderSection(title, results) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function renderFieldData(crux, results) {
|
|
105
|
-
|
|
105
|
+
const trend = crux.history ? ["", ...renderFieldTrend(crux.history)] : [];
|
|
106
|
+
if (crux.unavailable) return [`> Field data: not available (${crux.unavailable})`, ...trend];
|
|
106
107
|
const { title, rows } = fieldDataView(crux, results);
|
|
107
108
|
return [
|
|
108
109
|
`### ${title}`,
|
|
@@ -112,6 +113,40 @@ function renderFieldData(crux, results) {
|
|
|
112
113
|
const synthetic = row.synthetic == null ? "n/a" : formatValue(row.synthetic, row.unit);
|
|
113
114
|
return `| ${row.metric} | ${formatValue(row.p75, row.unit)} | ${RATING_ICON[row.rating] ?? "·"} ${row.rating} | ${synthetic} |`;
|
|
114
115
|
}),
|
|
116
|
+
...renderFieldSubParts(crux, results),
|
|
117
|
+
...renderNavigations(crux),
|
|
118
|
+
...trend,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderFieldSubParts(crux, results) {
|
|
123
|
+
const view = subPartsView(crux, results);
|
|
124
|
+
if (!view) return [];
|
|
125
|
+
return [
|
|
126
|
+
"",
|
|
127
|
+
"### LCP sub-parts (CrUX p75 against the lab)",
|
|
128
|
+
"| Sub-part | Field p75 | Lab | Note |",
|
|
129
|
+
"|----------|-----------|-----|------|",
|
|
130
|
+
...view.rows.map((row) => `| ${row.label} | ${formatValue(row.field, "ms")} | ${row.lab === null ? "n/a" : formatValue(row.lab, "ms")} | ${row.note ?? ""} |`),
|
|
131
|
+
"",
|
|
132
|
+
...view.caveats.map((caveat) => `> ${caveat}`),
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderNavigations(crux) {
|
|
137
|
+
const view = navigationView(crux);
|
|
138
|
+
if (!view) return [];
|
|
139
|
+
return ["", `> ${view.line}`, ...(view.hint ? [">", `> ${view.hint}`] : [])];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderFieldTrend(history) {
|
|
143
|
+
if (history.unavailable) return [`> Field data trend: not available (${history.unavailable})`];
|
|
144
|
+
const { title, rows } = historyView(history);
|
|
145
|
+
return [
|
|
146
|
+
`### ${title}`,
|
|
147
|
+
"| Metric | First | Last | Change | Trend | Weeks |",
|
|
148
|
+
"|--------|-------|------|--------|-------|-------|",
|
|
149
|
+
...rows.map((row) => `| ${row.metric} | ${formatValue(row.first, row.unit)} | ${formatValue(row.last, row.unit)} | ${row.change} | ${row.trend} | ${row.sparkline} |`),
|
|
115
150
|
];
|
|
116
151
|
}
|
|
117
152
|
|