Skip to content

feat(browser): Report web vitals for soft navigations - #23425

Draft
logaretm wants to merge 7 commits into
developfrom
awad/js-1019-soft-nav-web-vitals-spans
Draft

feat(browser): Report web vitals for soft navigations#23425
logaretm wants to merge 7 commits into
developfrom
awad/js-1019-soft-nav-web-vitals-spans

Conversation

@logaretm

@logaretm logaretm commented Aug 13, 2026

Copy link
Copy Markdown
Member

Reports LCP, CLS and INP for soft navigations, opt-in via webVitalsIntegration({ reportSoftNavs: true }). Correlates them to navigation spans via the triggering interactionId, since navigationId isn'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 deliberate inaccuracy worth review. 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

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-in via `webVitalsIntegration({ reportSoftNavs: true })` or
`browserTracingIntegration({ _experiments: { enableSoftNavWebVitals: true } })`.
@linear-code

linear-code Bot commented Aug 13, 2026

Copy link
Copy Markdown

JS-1019

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 30.3 kB - -
@sentry/browser - with treeshaking flags 28.47 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 26.81 kB - -
@sentry/browser (incl. Tracing) 49.32 kB +1.52% +738 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 49.33 kB +1.54% +744 B 🔺
@sentry/browser (incl. Tracing, Profiling) 52.26 kB +1.56% +800 B 🔺
@sentry/browser (incl. Tracing, Replay) 88.68 kB +0.79% +694 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 78 kB +0.82% +633 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 93.41 kB +0.76% +704 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 106.11 kB +0.68% +715 B 🔺
@sentry/browser (incl. Feedback) 47.65 kB - -
@sentry/browser (incl. sendFeedback) 35.13 kB - -
@sentry/browser (incl. FeedbackAsync) 40.28 kB - -
@sentry/browser (incl. Metrics) 31.24 kB - -
@sentry/browser (incl. Logs) 31.52 kB - -
@sentry/browser (incl. Metrics & Logs) 32.15 kB - -
@sentry/react 32.09 kB - -
@sentry/react (incl. Tracing) 51.47 kB +1.39% +703 B 🔺
@sentry/vue 35.34 kB - -
@sentry/vue (incl. Tracing) 51.26 kB +1.43% +721 B 🔺
@sentry/svelte 30.33 kB - -
CDN Bundle 31.61 kB - -
CDN Bundle (incl. Tracing) 49.66 kB +1.58% +769 B 🔺
CDN Bundle (incl. Logs, Metrics) 33.8 kB +0.01% +1 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 51.55 kB +1.45% +735 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 74.33 kB +0.04% +24 B 🔺
CDN Bundle (incl. Tracing, Replay) 87.16 kB +0.79% +679 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 89 kB +0.79% +693 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 92.95 kB +0.83% +764 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 94.86 kB +0.79% +739 B 🔺
CDN Bundle - uncompressed 93.84 kB -0.01% -6 B 🔽
CDN Bundle (incl. Tracing) - uncompressed 149.19 kB +1.67% +2.44 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 100.14 kB -0.01% -6 B 🔽
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 154.88 kB +1.61% +2.44 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 229.13 kB +0.03% +56 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 268.43 kB +0.92% +2.42 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 274.11 kB +0.9% +2.42 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 282.13 kB +0.87% +2.42 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 287.79 kB +0.85% +2.42 kB 🔺
@sentry/nextjs (client) 53.83 kB +1.01% +537 B 🔺
@sentry/sveltekit (client) 49.68 kB +1.39% +680 B 🔺
@sentry/core/server 65.38 kB - -
@sentry/core/browser 51.72 kB - -
@sentry/node 117.03 kB +0.01% +1 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 0 B added added
@sentry/node - without tracing 81.44 kB +0.01% +1 B 🔺
@sentry/aws-serverless 90.92 kB +0.01% +1 B 🔺
@sentry/cloudflare (withSentry) - minified 213.98 kB - -
@sentry/cloudflare (withSentry) 528.93 kB - -

View base workflow run

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.
The Soft Navigations API is stable in Chromium 151+, web-vitals ships
`reportSoftNavs` as an ordinary option, and span streaming is already
the default, so there is nothing experimental left to gate on.

`enableSoftNavWebVitals` becomes a plain `browserTracingIntegration`
option, mirroring how `enableInp` forwards to `webVitalsIntegration`.
@logaretm logaretm changed the title feat(browser): Experimental soft navigation web vitals feat(browser): Report web vitals for soft navigations Aug 13, 2026
Web vital spans read the route name from the current scope, which has
already moved on by the time a late vital is reported: a soft
navigation's CLS and INP only finalize at the next navigation, so
`sentry.segment.name` was systematically the following route.

Prefer the name of the span the vital is parented to, which is what
`_sendInpSpan` already computed for itself. Also fixes the page load
LCP and CLS, which were labelled with the first navigated-to route.
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`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investigate and Experiment with soft navigation web vitals

1 participant