Skip to content

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

Draft
logaretm wants to merge 9 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 9 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 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

@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 📦

⚠️ Warning: Base artifact is not the latest one, because the latest workflow run is not done yet. This may lead to incorrect results. Try to re-run all tests to get up to date results.

Path Size % Change Change
@sentry/browser 28.56 kB - -
@sentry/browser - with treeshaking flags 26.92 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 26.82 kB - -
@sentry/browser (incl. Tracing) 49.52 kB +1.82% +883 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 49.53 kB +1.83% +886 B 🔺
@sentry/browser (incl. Tracing, Profiling) 52.44 kB +1.72% +885 B 🔺
@sentry/browser (incl. Tracing, Replay) 88.95 kB +0.97% +853 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 78.27 kB +0.97% +751 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 93.65 kB +0.93% +855 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 106.63 kB +0.84% +885 B 🔺
@sentry/browser (incl. Feedback) 46.05 kB - -
@sentry/browser (incl. sendFeedback) 33.62 kB - -
@sentry/browser (incl. FeedbackAsync) 38.73 kB - -
@sentry/browser (incl. Metrics) 29.51 kB - -
@sentry/browser (incl. Logs) 29.8 kB - -
@sentry/browser (incl. Metrics & Logs) 30.43 kB - -
@sentry/react 30.3 kB - -
@sentry/react (incl. Tracing) 51.7 kB +1.7% +864 B 🔺
@sentry/vue 35.69 kB - -
@sentry/vue (incl. Tracing) 51.73 kB +1.69% +859 B 🔺
@sentry/svelte 28.59 kB - -
CDN Bundle 30.35 kB - -
CDN Bundle (incl. Tracing) 50.05 kB +1.62% +797 B 🔺
CDN Bundle (incl. Logs, Metrics) 32.58 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 51.95 kB +1.58% +804 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 73.21 kB +0.05% +33 B 🔺
CDN Bundle (incl. Tracing, Replay) 87.56 kB +0.96% +826 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 89.43 kB +0.92% +813 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 93.55 kB +1% +920 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 95.48 kB +1.02% +959 B 🔺
CDN Bundle - uncompressed 89.95 kB - -
CDN Bundle (incl. Tracing) - uncompressed 149.74 kB +1.97% +2.89 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 96.24 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 155.43 kB +1.9% +2.89 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 225.52 kB +0.05% +107 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 269.21 kB +1.08% +2.87 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 274.89 kB +1.06% +2.87 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 282.92 kB +1.03% +2.87 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 288.58 kB +1.01% +2.87 kB 🔺
@sentry/nextjs (client) 54.12 kB +1.31% +699 B 🔺
@sentry/sveltekit (client) 49.93 kB +1.74% +852 B 🔺
@sentry/core/server 65.44 kB - -
@sentry/core/browser 51.84 kB - -
@sentry/node 123.16 kB +0.05% +58 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 85.23 kB - -
@sentry/node - without tracing 87.75 kB +0.04% +27 B 🔺
@sentry/node - without channel injection 102.83 kB +0.06% +57 B 🔺
@sentry/aws-serverless 95.91 kB +0.03% +25 B 🔺
@sentry/cloudflare (withSentry) - minified 200.51 kB - -
@sentry/cloudflare (withSentry) 498.77 kB - -

View base workflow run

@logaretm logaretm changed the title feat(browser): Experimental soft navigation web vitals feat(browser): Report web vitals for soft navigations Aug 13, 2026
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.
@logaretm
logaretm force-pushed the awad/js-1019-soft-nav-web-vitals-spans branch from 7105e45 to 0d9c2e6 Compare August 28, 2026 15:28
…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
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