Skip to content

Commit 08e718a

Browse files
committed
Show CrUX field data alongside lab Web Vitals
A lab run measures one device, one network, one location — often a fast, cached, well-connected one. That makes the lab Web Vitals look healthier than what real users actually experience, and a regression real users already feel can stay invisible in the lab numbers. sitespeed.io already collects CrUX field data when configured, but it lived on a separate tab, so nobody compared the two. This puts the CrUX field p75 directly under the lab median in the Google Web Vitals card for TTFB, FCP, LCP, CLS and INP, graded against the same Good/Needs-improvement/Poor thresholds. The field line is matched to the device you tested (mobile run → PHONE, desktop → DESKTOP, falling back to ALL when the matching form factor wasn't collected) and always labels which form factor it used, so the two numbers are never silently mismatched. When the lab grades a metric better than the field does, the card flags it as "lab is more optimistic than real users" — the cue that your test conditions are hiding a real-world regression. Co-authored-by: Claude noreply@anthropic.com
1 parent ebec7aa commit 08e718a

7 files changed

Lines changed: 187 additions & 8 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ The report has accumulated several non-obvious behaviours that an agent should k
171171
- **VisualProgress is event-based, not continuous.** Browsertime emits a `VisualProgress` sample only when the similarity-to-final-frame percent changes. Filmstrip frames are snapshots at specific moments. The visual-progress curve must STEP (hold previous percent, step up at next sample), not linearly interpolate — interpolation falsely suggests rendering during a blank period. The scrubber must show the most recent frame whose timestamp is `<=` cursor, not the nearest in either direction. And cursor-to-time math uses the curve's coordinate system (which lives inside a ~38px horizontal inset), not the container width — otherwise the playhead drifts out of sync with the curve. [#4734](https://github.com/sitespeedio/sitespeed.io/pull/4734).
172172
- **Folder path aliases accept Unicode.** Non-ASCII URL components used to collapse to `-` in result paths (issue #3880). The path-alias regex now permits Unicode letters and digits. Don't tighten it back. [#4759](https://github.com/sitespeedio/sitespeed.io/pull/4759).
173173
- **CSS rebuilds are required.** Editing SCSS under `lib/plugins/html/src/sass/` does nothing on its own — run `npm run build:css` and commit the compiled `*.min.css` under `lib/plugins/html/assets/css/`. The build step writes both light and dark themes.
174+
- **Per-URL data is keyed by message type, stored nested.** `dataCollector.addDataForUrl` uses `set()` from `lib/support/objectPath.js`, which splits the message-type path on `.`. So `browsertime.pageSummary` and `crux.pageSummary` land as `pageInfo.data.browsertime.pageSummary` / `pageInfo.data.crux.pageSummary` — nested objects, not literal dotted keys. Read them with `h.get(pageInfo.data, 'crux.pageSummary…')` (also dot-splitting) or direct nested access; a literal `pageInfo.data['crux.pageSummary']` lookup returns nothing.
175+
- **CrUx field data exposes two shapes.** `crux/repackage.js` produces flat per-metric keys (`loadingExperience.ALL.LARGEST_CONTENTFUL_PAINT_MS.p75`, etc.) **and** keeps the raw API response under `loadingExperience.ALL.data.record.metrics.<snake_case>.percentiles.p75`. Each flat key only exists if that metric was in the CrUx response, so guard before reading. The summary template reads the raw `.data` path; the Web Vitals lab-vs-field line uses the flat keys.
176+
- **The Google Web Vitals hero is one shared partial.** `lib/plugins/html/templates/url/metrics/index.pug` builds the `wvTiles` scoreboard and is `include`d by both `url/summary/index.pug` and `url/iteration/index.pug`. Edit it once; it renders on both the median-summary and per-run pages. The Web Vitals thresholds (`gwvLcpClass`, … and `wvStatusLabel`) are defined inline near the top of that file — reuse them rather than re-deriving cutoffs.
174177

175178
## Project metadata
176179

docs/documentation/sitespeed.io/crux/index.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ sitespeed.io --crux.key $CRUX_API_KEY --plugins.remove browsertime --graphite.na
3535

3636
You will get a new tab in your result HTML with the CrUx data.
3737

38+
## Compare your lab data with the field
39+
When you collect CrUx data, the **Google Web Vitals** card on each URL's metrics tab also shows the CrUx field p75 underneath your own lab median for TTFB, FCP, LCP, CLS and INP. Both lines are graded against the same Good / Needs improvement / Poor thresholds, so you can see at a glance whether your test setup matches what real users experience. When the lab grade is better than the field grade, the card flags the metric as *"Lab is more optimistic than real users"* — a hint that your test conditions (network, device, location, cached state) are rosier than the field, and the lab number is hiding a regression real users already feel.
40+
41+
The field line is matched to the device you tested with: a mobile run (```--mobile```, a real Android device, or iOS) is compared against the CrUx **PHONE** form factor, and a desktop run against **DESKTOP**. To get that match you need to collect the matching form factor (see [Form factor](#form-factor) below) — e.g. ```--crux.formFactor PHONE``` for a mobile run. If the matching form factor wasn't collected, the comparison falls back to **ALL**. The field line always shows which form factor it used (for example *"Field p75 · phone"* or *"Field p75 · all devices"*) so the two lines are never silently mismatched.
42+
43+
The comparison only appears for metrics that exist in both your run and the CrUx response; if CrUx has no data for a Vital, only the lab line is shown.
44+
3845
## Form factor
3946
The CrUx data has four different buckets depending on device: DESKTOP, PHONE, TABLET and ALL. You can choose which data to get with ```--crux.formFactor```. Getting only phone data: ```--crux.formFactor PHONE```. If you want to collect data for multiple form factors, add ```--crux.formFactor``` multiple times.
4047

lib/plugins/html/assets/css/index-dark.min.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/plugins/html/assets/css/index-light.min.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/plugins/html/src/sass/dark/components/metricsCards.scss

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,3 +743,74 @@ $lcp--render-delay: #a78bfa;
743743
.web-vital--warning & { color: #facc15; }
744744
.web-vital--error & { color: $color--error; }
745745
}
746+
747+
// Lab / Field eyebrow label that prefixes the lab status word and the
748+
// CrUX field line, so the two measurements can't be confused.
749+
.web-vital-source {
750+
font-size: 0.7rem;
751+
font-weight: $font--weight--medium;
752+
text-transform: uppercase;
753+
letter-spacing: 0.04em;
754+
color: $color--text-muted;
755+
margin-right: 6px;
756+
}
757+
758+
// CrUX field p75 line below the lab status. Its own dot is coloured by
759+
// the field's own Web Vitals class, independent of the tile background
760+
// (which tracks the lab value).
761+
.web-vital-field {
762+
display: flex;
763+
flex-direction: column;
764+
gap: 2px;
765+
margin-top: 6px;
766+
font-size: 0.82rem;
767+
color: $color--text-secondary;
768+
}
769+
770+
// Dot + value + status sit on one line; nowrap keeps the number and its
771+
// unit (e.g. "490 ms") together instead of breaking across lines.
772+
.web-vital-field-measure {
773+
display: flex;
774+
align-items: center;
775+
gap: 6px;
776+
white-space: nowrap;
777+
}
778+
779+
.web-vital-field-dot {
780+
flex: 0 0 auto;
781+
width: 9px;
782+
height: 9px;
783+
border-radius: 50%;
784+
background: $color--text-muted;
785+
786+
&.web-vital-field-dot--ok { background: $color--ok; }
787+
&.web-vital-field-dot--warning { background: #facc15; }
788+
&.web-vital-field-dot--error { background: $color--error; }
789+
}
790+
791+
.web-vital-field-value {
792+
font-variant-numeric: tabular-nums;
793+
font-weight: $font--weight--medium;
794+
}
795+
796+
.web-vital-field-status {
797+
color: $color--text-muted;
798+
text-transform: uppercase;
799+
font-size: 0.72rem;
800+
letter-spacing: 0.04em;
801+
}
802+
803+
// Shown only when the lab classifies a Vital better than the field does
804+
// — i.e. the test conditions are rosier than what real users see.
805+
.web-vital-flag {
806+
display: inline-block;
807+
margin-top: 6px;
808+
padding: 2px 8px;
809+
border-radius: $radius--sm;
810+
background: $color--warning-bg;
811+
border: 1px solid $color--warning-border;
812+
color: $color--warning;
813+
font-size: 0.72rem;
814+
font-weight: $font--weight--medium;
815+
line-height: 1.4;
816+
}

lib/plugins/html/src/sass/light/components/metricsCards.scss

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,3 +819,74 @@ $lcp--render-delay: #7c3aed;
819819
.web-vital--warning & { color: #a16207; }
820820
.web-vital--error & { color: $color--error; }
821821
}
822+
823+
// Lab / Field eyebrow label that prefixes the lab status word and the
824+
// CrUX field line, so the two measurements can't be confused.
825+
.web-vital-source {
826+
font-size: 0.7rem;
827+
font-weight: $font--weight--medium;
828+
text-transform: uppercase;
829+
letter-spacing: 0.04em;
830+
color: $color--text-muted;
831+
margin-right: 6px;
832+
}
833+
834+
// CrUX field p75 line below the lab status. Its own dot is coloured by
835+
// the field's own Web Vitals class, independent of the tile background
836+
// (which tracks the lab value).
837+
.web-vital-field {
838+
display: flex;
839+
flex-direction: column;
840+
gap: 2px;
841+
margin-top: 6px;
842+
font-size: 0.82rem;
843+
color: $color--text-secondary;
844+
}
845+
846+
// Dot + value + status sit on one line; nowrap keeps the number and its
847+
// unit (e.g. "490 ms") together instead of breaking across lines.
848+
.web-vital-field-measure {
849+
display: flex;
850+
align-items: center;
851+
gap: 6px;
852+
white-space: nowrap;
853+
}
854+
855+
.web-vital-field-dot {
856+
flex: 0 0 auto;
857+
width: 9px;
858+
height: 9px;
859+
border-radius: 50%;
860+
background: $color--text-muted;
861+
862+
&.web-vital-field-dot--ok { background: $color--ok; }
863+
&.web-vital-field-dot--warning { background: #facc15; }
864+
&.web-vital-field-dot--error { background: $color--error; }
865+
}
866+
867+
.web-vital-field-value {
868+
font-variant-numeric: tabular-nums;
869+
font-weight: $font--weight--medium;
870+
}
871+
872+
.web-vital-field-status {
873+
color: $color--text-muted;
874+
text-transform: uppercase;
875+
font-size: 0.72rem;
876+
letter-spacing: 0.04em;
877+
}
878+
879+
// Shown only when the lab classifies a Vital better than the field does
880+
// — i.e. the test conditions are rosier than what real users see.
881+
.web-vital-flag {
882+
display: inline-block;
883+
margin-top: 6px;
884+
padding: 2px 8px;
885+
border-radius: $radius--sm;
886+
background: $color--warning-bg;
887+
border: 1px solid $color--warning-border;
888+
color: $color--warning;
889+
font-size: 0.72rem;
890+
font-weight: $font--weight--medium;
891+
line-height: 1.4;
892+
}

lib/plugins/html/templates/url/metrics/index.pug

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,19 +162,34 @@ if browsertime
162162
if googleWebVitals
163163
a#google-web-vitals
164164
- const wvTiles = []
165-
- if (timings.ttfb) wvTiles.push({short: 'TTFB', label: 'Time to first byte', formatted: h.time.ms(timings.ttfb.toFixed ? timings.ttfb.toFixed(0) : timings.ttfb), cls: gwvTtfbClass(timings.ttfb), href: baseHelpURL + 'ttfb'})
166-
- if (timings.paintTiming && timings.paintTiming['first-contentful-paint']) { const v = timings.paintTiming['first-contentful-paint']; wvTiles.push({short: 'FCP', label: 'First Contentful Paint', formatted: h.time.ms(v), cls: gwvFcpClass(v), href: baseHelpURL + 'firstContentfulPaint'}) }
167-
- if (timings.largestContentfulPaint) { const v = Math.max(timings.largestContentfulPaint.loadTime, timings.largestContentfulPaint.renderTime); wvTiles.push({short: 'LCP', label: 'Largest Contentful Paint', formatted: h.time.ms(v), cls: gwvLcpClass(v), href: '#largestContentfulPaint'}) }
168-
- if (browsertime.pageinfo && browsertime.pageinfo.cumulativeLayoutShift !== undefined) { const v = browsertime.pageinfo.cumulativeLayoutShift; wvTiles.push({short: 'CLS', label: 'Cumulative Layout Shift', formatted: v.toFixed(2), cls: gwvClsClass(v), href: '#cumulativeLayoutShift'}) }
169-
- if (timings.interactionToNextPaint) { const v = timings.interactionToNextPaint; wvTiles.push({short: 'INP', label: 'Interaction to next paint', formatted: h.time.ms(v), cls: gwvInpClass(v), href: '#interactionToNextPaint'}) }
165+
//- CrUX field p75 for the same URL (only when the crux plugin ran).
166+
//- The field line reuses the same lab thresholds so Good / Needs
167+
//- improvement / Poor mean the same thing on the lab and field rows.
168+
//- Match the CrUX form factor to the lab device so a mobile-emulated
169+
//- run isn't compared against desktop-heavy field data; fall back to
170+
//- ALL when the matched form factor wasn't collected (default is ALL).
171+
- const cruxFF = h.get(pageInfo.data, 'crux.pageSummary.loadingExperience')
172+
- const labIsMobile = !!(options.mobile || options.android || h.get(options, 'chrome.android.package') || h.get(options, 'firefox.android.package') || h.get(options, 'safari.ios') || h.get(options, 'safari.useSimulator'))
173+
- const wantFF = labIsMobile ? 'PHONE' : 'DESKTOP'
174+
- const cruxFFKey = cruxFF ? (cruxFF[wantFF] ? wantFF : (cruxFF.ALL ? 'ALL' : null)) : null
175+
- const cruxField = cruxFFKey ? cruxFF[cruxFFKey] : null
176+
- const cruxFFLabel = cruxFFKey === 'PHONE' ? 'phone' : (cruxFFKey === 'DESKTOP' ? 'desktop' : (cruxFFKey === 'TABLET' ? 'tablet' : 'all devices'))
177+
- const fieldVital = (path, classifier, fmt) => { if (!cruxField) return null; const raw = h.get(cruxField, path); return raw == null ? null : {formatted: fmt(raw), cls: classifier(raw)} }
178+
- if (timings.ttfb) wvTiles.push({short: 'TTFB', label: 'Time to first byte', formatted: h.time.ms(timings.ttfb.toFixed ? timings.ttfb.toFixed(0) : timings.ttfb), cls: gwvTtfbClass(timings.ttfb), href: baseHelpURL + 'ttfb', field: fieldVital('TIME_TO_FIRST_BYTE_MS.p75', gwvTtfbClass, (v) => h.time.ms(v))})
179+
- if (timings.paintTiming && timings.paintTiming['first-contentful-paint']) { const v = timings.paintTiming['first-contentful-paint']; wvTiles.push({short: 'FCP', label: 'First Contentful Paint', formatted: h.time.ms(v), cls: gwvFcpClass(v), href: baseHelpURL + 'firstContentfulPaint', field: fieldVital('FIRST_CONTENTFUL_PAINT_MS.p75', gwvFcpClass, (v) => h.time.ms(v))}) }
180+
- if (timings.largestContentfulPaint) { const v = Math.max(timings.largestContentfulPaint.loadTime, timings.largestContentfulPaint.renderTime); wvTiles.push({short: 'LCP', label: 'Largest Contentful Paint', formatted: h.time.ms(v), cls: gwvLcpClass(v), href: '#largestContentfulPaint', field: fieldVital('LARGEST_CONTENTFUL_PAINT_MS.p75', gwvLcpClass, (v) => h.time.ms(v))}) }
181+
- if (browsertime.pageinfo && browsertime.pageinfo.cumulativeLayoutShift !== undefined) { const v = browsertime.pageinfo.cumulativeLayoutShift; wvTiles.push({short: 'CLS', label: 'Cumulative Layout Shift', formatted: v.toFixed(2), cls: gwvClsClass(v), href: '#cumulativeLayoutShift', field: fieldVital('CUMULATIVE_LAYOUT_SHIFT_SCORE.p75', (v) => gwvClsClass(Number(v)), (v) => Number(v).toFixed(2))}) }
182+
- if (timings.interactionToNextPaint) { const v = timings.interactionToNextPaint; wvTiles.push({short: 'INP', label: 'Interaction to next paint', formatted: h.time.ms(v), cls: gwvInpClass(v), href: '#interactionToNextPaint', field: fieldVital('INTERACTION_TO_NEXT_PAINT_MS.p75', gwvInpClass, (v) => h.time.ms(v))}) }
170183
- else if (cpu && cpu.longTasks) { const v = cpu.longTasks.totalBlockingTime; wvTiles.push({short: 'TBT', label: 'Total Blocking Time', formatted: h.time.ms(v), cls: gwvTbtClass(v), href: baseHelpURL + 'totalBlockingTime'}) }
171184
.listing-card.web-vitals-hero
172185
.listing-card-header
173186
h3.listing-card-title Google Web Vitals
174187
if medianRun
175188
span.listing-card-meta from run #{medianRun.runIndex}
176189
.web-vitals-hero-grid
190+
- const wvRank = (c) => c === 'error' ? 2 : (c === 'warning' ? 1 : (c === 'ok' ? 0 : -1))
177191
each tile in wvTiles
192+
- const labRosier = tile.field && wvRank(tile.cls) >= 0 && wvRank(tile.field.cls) > wvRank(tile.cls)
178193
.web-vital(class=tile.cls ? 'web-vital--' + tile.cls : '')
179194
span.web-vital-dot
180195
.web-vital-body
@@ -184,7 +199,19 @@ if browsertime
184199
.web-vital-name
185200
a(href=tile.href) #{tile.label}
186201
if tile.cls
187-
.web-vital-status #{wvStatusLabel(tile.cls)}
202+
.web-vital-status
203+
if tile.field
204+
span.web-vital-source Lab
205+
span #{wvStatusLabel(tile.cls)}
206+
if tile.field
207+
.web-vital-field
208+
span.web-vital-source Field p75 · #{cruxFFLabel}
209+
.web-vital-field-measure
210+
span.web-vital-field-dot(class='web-vital-field-dot--' + tile.field.cls)
211+
span.web-vital-field-value #{tile.field.formatted}
212+
span.web-vital-field-status #{wvStatusLabel(tile.field.cls)}
213+
if labRosier
214+
span.web-vital-flag ⚠ Lab is more optimistic than real users
188215

189216
//- 3-5. The three "interactive" Web Vitals each get their own deep-dive
190217
//- card from a sub-template (LCP phase breakdown, CLS shift list, INP

0 commit comments

Comments
 (0)