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 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.9.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
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.9.0" }
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.9.0",
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
- const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
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, fetchCrux({ url: c.url, apiKey: cruxKey, formFactor }));
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 crux = await fetchCrux({
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
- if (crux.unavailable) return toolResult({ url, formFactor, unavailable: crux.unavailable });
326
- return toolResult({ url, formFactor, scope: crux.scope, metrics: crux.metrics });
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
 
@@ -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
- if (crux.unavailable) return [styleText("dim", `Field data: not available (${crux.unavailable})`)];
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
- if (crux.unavailable) return [`> Field data: not available (${crux.unavailable})`];
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