@nytka/plugin-crux
v0.1.0
Published
Chrome UX Report connector for nytka projects. Collects field Core Web Vitals — what Chrome users experienced at the 75th percentile — for any origin or URL, with a 40-week weekly history, into datasets/ and registers them with provenance.
Readme
@nytka/plugin-crux
Chrome UX Report connector for nytka projects. Collects field Core Web Vitals — what
Chrome users actually experienced, at the 75th percentile, over the trailing 28 days — for
any origin or page with enough traffic, your own or a competitor's, into the project's
datasets/, registered in datasets/index.json with provenance. One call also returns the
same numbers week by week, up to 40 weeks back.
Read-only, by nature. The API has two methods, queryRecord and queryHistoryRecord,
and both are reads. There is no write surface to keep out of reach.
Field data, not lab data. This is the other half of
@nytka/plugin-pagespeed and of decision
0013: PageSpeed
Insights measures a page under emulation in Google's data centre; the Chrome UX Report
reports what real visitors on real devices and networks got. Google's Core Web Vitals
assessment — the one in Search Console, the one the ranking signal reads — is built on
this data. A lab score of 100 does not pass Core Web Vitals; a field p75 does.
Why this source
What it gives that nothing else in the line does:
- The assessment that counts. p75 LCP, INP and CLS with the good / needs-improvement / poor split, per device, and whether the origin passes — the same verdict as pagespeed.web.dev's "Core Web Vitals Assessment" and Search Console's report, for any origin, without a Search Console grant.
- INP. Interaction to Next Paint needs real interactions; no lab tool measures it. It replaced FID in 2024 and is the vital most sites fail.
- The audience.
round_trip_timesays how slow your visitors' networks are;form_factorshow they split between phone, desktop and tablet;navigation_typeswhat share of visits were back/forward-cache or prerender hits rather than cold loads. - History without a schedule. The History API answers 40 weekly periods in one call, so "did the fix land, and is it holding?" needs no cron and no accumulation.
Install
npm install @nytka/plugin-cruxOne dependency, @nytka/core, which has none. The API is JSON over HTTP and Node's built-in
fetch speaks it; there is no client library to carry.
Setup
About two minutes if plugin-pagespeed is already configured: the same key serves both.
1. Enable the API
Google Cloud console → the project that owns the key → APIs & Services → Library → search Chrome UX Report API → Enable.
2. Allow it on the key
APIs & Services → Credentials → the key → API restrictions: add Chrome UX Report API beside PageSpeed Insights API. A key restricted to one of the two is refused by the other with an "are blocked" message. Application restriction stays None — a command line sends no HTTP referrer.
No pagespeed key yet? Credentials → Create credentials → API key, restricted as above.
3. Configure
cat node_modules/@nytka/plugin-crux/.env.example >> .envCRUX_API_KEY=AIza… # or leave it unset and let PAGESPEED_API_KEY serve
CRUX_ORIGINS=https://example.com, https://competitor.exampleCRUX_API_KEY is read first; PAGESPEED_API_KEY is accepted in its place, so a project with
plugin-pagespeed configured needs only the origins line. CRUX_ORIGINS is scheme and host —
a path or trailing slash is dropped, because CrUX keys an origin without one. Each origin
collects into its own dataset per form factor.
Then name the system in references/systems.yaml, pointers only:
chromeUxReport: # @nytka/plugin-crux
origins:
- https://example.com
authEnvVar: PAGESPEED_API_KEY # or CRUX_API_KEY; a Cloud API key restricted to both APIs4. Verify
npx nytka-crux vitalsThere is no separate smoke test: a query is free and answers in a second, so vitals is it.
A small origin answers no data — that is CrUX's honest answer, not a fault; see
Troubleshooting.
Use
npx nytka-crux vitals # every origin × phone + desktop, the trailing 28 days
npx nytka-crux vitals --form-factor all # every device together
npx nytka-crux vitals --url https://example.com/pricing/ # a page instead of an origin
npx nytka-crux history # 40 weekly periods per origin and device, one call each
npx nytka-crux history --weeks 12 # fewer
npx nytka-crux vitals --no-register # write the payloads, leave the registry aloneThe surface, and the question each part answers
| Command | API method | Question | Refreshed by Google |
|---|---|---|---|
| vitals | queryRecord | Does this origin pass Core Web Vitals for real users, on this device — and what are its p75s? | daily, ~2 days behind |
| history | queryHistoryRecord | How have those p75s moved, week by week, over the last 40 weeks? | weekly (periods end on Saturdays) |
Not collected, and why. The LCP image sub-parts (largest_contentful_paint_image_*) and
the experimental ad metrics are diagnostics of a page already known to be slow; the lab
audits in plugin-pagespeed answer that question with the resources named. Histograms are
kept as their three densities, not their bin edges — the edges are Google's published
thresholds. Page-level queries come from --url, not from .env, because a page-level
record exists only for pages with real traffic and a list of them in .env would mostly
answer "no data".
What a query script is asked
- Does the site pass, and on which device does it not? →
vitalsrows,ratingoflcp/inp/cls; the payload'scwv.passesandcwv.failing. - What share of visitors get a poor LCP? →
vitalsrowlcp, columnpoor. - Did last month's fix land? →
historyrows,metric === 'lcp',p75byto. - Are we faster than the competitor for real users? → the same rows over their origin.
- Is the audience on slow networks? →
vitalsrowrtt; on phones? → the payload'sfractions.form_factors.
The two datasets
crux-vitals-<origin>-<device>-<YYYY-MM> — operation field-vitals
One row per metric, for one origin (or page) and one form factor.
| Column | Type | From |
|---|---|---|
| target | string | the origin (https://example.com) or the page URL as queried |
| targetType | origin | url | which of the two |
| formFactor | phone | desktop | tablet | all | all is every device together (the API's aggregate) |
| metric | lcp | inp | cls | fcp | ttfb | rtt | in this order; a metric CrUX did not report is absent, not zero |
| p75 | number or null | the 75th-percentile value — what three visitors in four got or better |
| unit | ms | unitless | CLS is unitless |
| rating | good | needs-improvement | poor | null | p75 against the record's own bins, at-or-below the threshold being good |
| good, needsImprovement, poor | fractions summing to 1 | the share of page loads in each bin |
| from, to | YYYY-MM-DD | the 28-day window the record covers |
The thresholds, as Google publishes them and the bins carry them: LCP 2500 / 4000 ms, INP 200 / 500 ms, CLS 0.10 / 0.25, FCP 1800 / 3000 ms, TTFB 800 / 1800 ms, RTT 75 / 275 ms.
The payload also carries, beside the rows: cwv — { passes, assessed, failing },
Google's rule: an origin passes when every one of LCP, INP and CLS it has data for is good,
and assessed says which were there, because passing on two of three is a different fact;
fractions — form_factors, navigation_types and
largest_contentful_paint_resource_type exactly as the API gives them; normalizedTo —
the URL CrUX actually answered for, when it rewrote the one asked.
crux-history-<origin>-<device>-<YYYY-MM>-to-<YYYY-MM> — operation field-history
One row per weekly period × metric; the same columns as vitals with from/to naming
each period's 28-day window. Up to 40 periods, so up to 240 rows. A period CrUX has no
data for keeps its nulls — p75, rating and the three densities all null — so a gap in
the series reads as a gap, not as a zero.
Several origins, several devices
CRUX_ORIGINS=https://example.com, https://competitor.exampleFour datasets per command: each origin × phone and desktop. The registry entry's subject
is the origin, formFactor the device, and it is subject rather than property because
the origin measured need not be one the project owns.
Lab and field, side by side
plugin-pagespeed and this package can measure the same origin on the same day and
disagree — a lab LCP of 1.4 s beside a field p75 of 2.9 s, both correct. The lab run is one
emulated load on a throttled mid-range phone; the field number is the 75th percentile of
every real load in 28 days, on the devices and networks the audience actually has. The two
source values (pagespeed-insights, chrome-ux-report) and the two operation prefixes
(lab-, field-) keep them apart in the registry so a reader never sums or compares them
by accident. The rule of thumb: field says whether there is a problem; lab says what to
fix.
Troubleshooting
| What you see | What it means |
|---|---|
| CRUX_API_KEY is not set … and exit 0 | Neither key is set — step 3. Not an error: capability is configuration presence |
| no origin to query | CRUX_ORIGINS is empty and no --origin was given — step 3 |
| Chrome UX Report has no data for https://… — too little Chrome traffic | The documented answer for an origin or page below CrUX's traffic threshold, per form factor. The other queries still collect; this one is in the payload's failures. A small site gets this on every device; try --form-factor all, which needs the least traffic |
| Chrome UX Report rejected the API key | The key is wrong, or was deleted in the console — step 2 |
| the Chrome UX Report API is not enabled in the Cloud project that owns this key | Step 1 |
| the API key's restriction does not allow the Chrome UX Report API | Step 2: the key is restricted to PageSpeed Insights only — the earlier pagespeed README said to do exactly that; add this API beside it |
| the API key is restricted to HTTP referrers | Application restriction is set. Change it to None and keep the API restriction |
| saw no API key on the request | The key value is blank after trimming. Set it |
| rate-limited the call (HTTP 429) | 150 queries per minute per project. Retry in a minute |
| nothing collected — N queries failed | Every query answered no data (or failed); nothing was written. The list says which |
| no project.yaml found walking up from cwd | Not inside a nytka project. cd to the project root |
What it writes
| Path | Committed? |
|---|---|
| datasets/payloads/<id>.json | no — the directory ships its own .gitignore |
| datasets/index.json | yes — one entry per origin and form factor, added or replaced |
The dataset id
vitals crux-vitals-example-com-phone-2026-09 keyed to the month the window ends in
history crux-history-example-com-phone-2025-11-to-2026-09 the months the periods spanA CrUX record has real dates on it, so — unlike a lab run — the entry's dateRange is
filled and there is no period. The vitals id is keyed to the month the 28-day window ends
in: the same window collected on two days of one month replaces one entry rather than laying
down two (PLG-007). The history id spans its periods at month granularity, as plugin-bing's
series do, so a weekly re-run keeps updating one entry until the span moves.
Rules it follows
- Payloads never enter agent context. Query them with a script; write conclusions to
research/. The CLI prints row counts and paths for this reason. - The key never appears in output. It travels as a query parameter, which is how the API takes it; no URL is printed, and any response body is redacted before it can reach an error message.
- No data is an answer. An origin below the threshold is recorded and skipped; the collection carries on. A key or quota refusal stops it at once, because every further call would fail the same way.
- Numbers are numbers: CrUX sends CLS bin edges and p75s as strings, and a period without
data as
nullp75s and"NaN"densities. All of it is normalised to numbers and nulls before it reaches a row. - The project is found by walking up for
project.yaml, so it works at any install depth. - No YAML parsing, no config file. Secrets from
.env, everything else from flags.
Limits worth knowing
- What has and has not been run against the live API. Confirmed without a credential
on 2026-09-20: both endpoints, Google's error envelope, the keyless 403 ("unregistered
callers") and the
API_KEY_INVALID400 for a made-up key — both bodies pinned verbatim in the tests. Not yet confirmed: a successful record, and the 404 for an origin without data. The fixtures are built from the documentation's own examples, quirks included. The first keyedvitalsagainst an origin with traffic is the check. - CrUX publishes only origins and pages with enough Chrome traffic. The threshold is not published. A small site gets "no data" on every device; page-level records are rarer still. This is the connector for client sites with visitors and for competitors, not for a new site's launch week.
- Chrome only, and only Chrome users who opted in. Safari, Firefox and in-app browsers are not in the data; a site with an iPhone-heavy audience is measured on its Android and desktop Chrome visitors.
- Two days behind, 28 days wide. A fix shipped today shows in the daily record from about two days on and takes four weeks to fully replace the old loads in the window; the weekly history is the place to watch it move.
- 150 queries a minute per Cloud project, free, shared between
vitalsandhistory, and the quota cannot be raised for money.
Tests
npm testnode --test against recorded responses. No credential, no network. Sixty-one tests,
including: CLS strings becoming numbers and "NaN" densities becoming nulls, the rating at
each threshold with Google's at-or-below rule, the assessment passing only when every vital
present is good and saying which were present, history keeping a period's nulls, the vitals
id holding still across a month and moving when the window does, the pagespeed key accepted
in the crux key's place and losing to it when both are set, the two live error bodies pinned
byte for byte, "no data" recorded per query without stopping the rest, the key absent from
every error the package can produce, and the CLI's exit codes at the process boundary.
The fixtures are synthesised from the shapes on developer.chrome.com/docs/crux/api and
/history-api, read 2026-09-20 — bin edges at the published thresholds, the last bin without
end, CLS as strings, {year, month, day} dates, 40 weekly periods ending on Saturdays with
the first three empty. error-403-unregistered.json and error-400-key-invalid.json are
live bodies, verbatim; error-404-not-found.json is the documented one.
