feat(browser): Report web vitals for soft navigations - #23425
Conversation
size-limit report 📦
|
7105e45 to
0d9c2e6
Compare
b9d4e64 to
1e0115b
Compare
d159dc7 to
1e06724
Compare
1e06724 to
abfcedf
Compare
Report LCP, CLS and INP for soft navigations, using upstream web-vitals'
`reportSoftNavs` option and the browser's Soft Navigations API.
Soft navigation vitals are correlated back to the SDK's navigation spans
through the `interactionId` of the interaction that triggered the
navigation, which both the `soft-navigation` entry and the interaction's
own Event Timing entry carry.
Opt out via `webVitalsIntegration({ reportSoftNavs: false })` or
`browserTracingIntegration({ enableSoftNavWebVitals: false })`.
Drop `reportAllChanges` when soft navigations are on, so every value a handler receives is already final for its navigation and can be sent as a span directly. Replaces the accumulate/flush state machine, and leaves the default path untouched.
Soft navigations the browser doesn't detect (programmatic navigations, navigations that never paint) report no vitals at all, so coverage is lower than for page loads. That is worth stating next to the option rather than leaving it to be discovered from missing data. Also covers that `browserTracingIntegration` forwards the option to the `webVitalsIntegration` it auto-registers.
INP took its parent from the interaction cache, which records whichever root span was active when the interaction's entry was observed. For a click that drives a navigation that is the destination navigation span, so the page load's INP was reported against the first navigation. With soft navigations the metric carries the navigation it belongs to, so INP now flows through the same path as LCP and CLS. The cache is still used for the element name, and still parents INP when soft navigations are off.
web-vitals decides what an INP is; we map its emissions to telemetry rather than filtering them. It reports a soft navigation's INP with no entries when every interaction stayed below the Event Timing threshold, and we were dropping those, so a fast navigation reported no INP at all. Without an entry there is no element or interaction type to describe, so the span falls back to a bare `ui.interaction` op and a generic name, the same way CLS falls back to a "Layout shift" span. It is placed at the start of the navigation it belongs to.
web-vitals synthesizes an INP value with no entries when a soft navigation's interactions all stayed below the Event Timing threshold, so that fast navigations are not excluded from INP reporting (GoogleChrome/web-vitals#724). Emitting those under a bare `ui.interaction` op would hide them from any aggregation matching `ui.interaction.*`, reintroducing exactly the bias upstream added the synthetic value to remove. Without an entry there is no interaction type to derive, so the op falls back to `click`.
A page's vitals are only comparable across routes if every route reports
its own. Behind an opt-in, the default stayed a single page-lifetime LCP,
CLS and INP that implicitly belong to whichever route happened to load
first, which is the less useful of the two behaviours in an app that soft
navigates at all.
Soft navigation vitals are now on by default and configured through a
nested `webVitals` bag on `browserTracingIntegration`, typed as the
`webVitalsIntegration` options it forwards to, so the two ways of
configuring the same integration cannot drift:
browserTracingIntegration({ webVitals: { softNavigations: false } })
webVitalsIntegration({ softNavigations: false })
The keys are positive with per-key defaults rather than `disable*` flags,
because the defaults in this bag point in opposite directions: soft
navigations are reported by default, while bfcache re-reports are dropped
(see `withoutBfcache`). A negated scheme would have to spell an
off-by-default feature as `disableBfcache: true` in the defaults.
`enableInp` is deprecated in favour of `webVitals: { ignore: ['inp'] }`,
which is the same switch spelled twice. It keeps working and is merged
into any user-provided ignore list.
Soft navigation vitals are still ignored when span streaming is off or
the browser has no Soft Navigations API, so nothing changes for a browser
that can't detect soft navigations in the first place.
abfcedf to
2aa6b25
Compare
2aa6b25 to
0371626
Compare
0371626 to
5116ecb
Compare
5116ecb to
e56b122
Compare
Lms24
left a comment
There was a problem hiding this comment.
One question, one concern, otherwise just nits (feel free to ignore em)
| 'sentry.replay_id': replayId, | ||
| 'sentry._internal.replay_is_buffering': replay!.getRecordingMode() === 'buffer' ? true : undefined, |
There was a problem hiding this comment.
super-l: can both be replaced with consts from conventions
There was a problem hiding this comment.
I think _internal isn't covered by the conventions? unless both are the same?
There was a problem hiding this comment.
ah right, sorry, I thought this was covered. I don't know what the difference is, but let's just leave it as-is
There was a problem hiding this comment.
I remember somehwere that some of these get mapped to the _internal prefix by relay or somehting but maybe something for us to check later.
e56b122 to
e1db9ed
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e1db9ed. Configure here.
e1db9ed to
ad4d1c5
Compare
ad4d1c5 to
a391d98
Compare
…red on Adds `browser.navigation.type` to every emitted LCP, CLS and INP span, so a vital can be read against the kind of navigation that produced it. A soft navigation and a cold page load are not comparable measurements, and without this there is no way to separate them after the fact. web-vitals reports a wider set of navigation types than the attribute defines, so only the states Navigation Timing cannot express keep their own value. Every ordinary document navigation folds into `navigate`, including a back/forward that missed the bfcache (per the spec) and a `document.wasDiscarded` restore, which the spec does not name at all. The `back-forward-cache` -> `bfcache` mapping is included but unreachable today: `withoutBfcache` drops those metrics before they reach a span. It's here so that enabling bfcache vitals later doesn't silently report them as `navigate`. Spec: getsentry/sentry-conventions#600
a391d98 to
d138028
Compare
Lms24
left a comment
There was a problem hiding this comment.
Exciting change!
Probably makes sense to add a note to the migration guide. Just to prepare users that their values will most likely shift. wdyt?
| /** | ||
| * Start correlating the browser's soft navigations with the SDK's navigation spans. | ||
| * | ||
| * A navigation span is created synchronously on the history change, but the browser only mints the | ||
| * `soft-navigation` entry (and with it the `navigationId` that web vitals are reported against) | ||
| * once the navigation has been confirmed by a paint. So the `navigationId` cannot be known at span | ||
| * creation time and the two have to be joined after the fact. | ||
| * | ||
| * The join key is the `interactionId` of the interaction that drove the navigation: per the Soft | ||
| * Navigations spec the `soft-navigation` entry carries the `interactionId` of the interaction that | ||
| * triggered it, which is the same id the interaction's own `PerformanceEventTiming` entry carries. | ||
| * So we bind a navigation span to the interaction it happened during, and the soft navigation | ||
| * joins back to that span through the shared id. | ||
| * | ||
| * This is inherently partial. Navigations that don't meet the browser's soft navigation heuristic | ||
| * (programmatic navigations, navigations that never paint, back/forward from the browser chrome) | ||
| * produce no entry at all, so those navigation spans simply have no web vitals. | ||
| */ | ||
| export function startSoftNavigationCorrelation(client: Client): void { |
There was a problem hiding this comment.
reading this: have we tested this with framework router instrumentations where we no longer necessarily start a navigation on history change? Might be worth adding a react or Vue e2e test to one of our existing apps. wdyt?
There was a problem hiding this comment.
I mostly tested all of this in Vue initially, and then vanilla JS, i never tried react or others but worth it yes, thanks for the catch!
I will add a follow up PR tho for these tests because this is getting harder to read here, also allows us to test the bfcache variant as well.
There was a problem hiding this comment.
On
where we no longer necessarily start a navigation on history change...
I haven't observed a case where this happened which suggests we have a good match with the pre-existing browser huerstics, but if it happens we would lose these spans since the corrolation will fail.
Switching over to metrics would mitigate this, I will try to verify it via tests as well.
There was a problem hiding this comment.
mmm, we would need a newer playwright, I will create a new PR so we can grab a newer chromuim with soft navs.
Web vitals values shift on upgrade for apps that do client-side routing: page load vitals are now finalized at the first soft navigation instead of accumulating over the page lifetime, and CLS/LCP no longer report every intermediate update, which changes what Session Replay records.

Reports LCP, CLS and INP for soft navigations, opt-in via
webVitalsIntegration({ reportSoftNavs: true }). Correlates them to navigation spans via the triggeringinteractionId, sincenavigationIdisn't available at history-change time. Requires span streaming.Verified in Chrome 151, which surfaced two pre-existing attribution bugs, fixed here. One changes the default path: vital spans read the route name from the current scope, so a page load's LCP and CLS were labelled with the next route.
One interesting case is web-vitals synthesizes an INP with no entries so fast soft navs still report (GoogleChrome/web-vitals#724). That leaves no interaction type, and a bare op would hide those spans from aggregation. So it falls back to
click.closes #17857