Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,34 @@ Sentry.init({
});
```

### Web vitals are reported per soft navigation

Affected SDKs: All SDKs running in the browser.

`webVitalsIntegration` (auto-registered by `browserTracingIntegration`) now reports its own set of LCP, CLS and INP for every soft navigation the browser detects through the [Soft Navigations API](https://developer.chrome.com/docs/web-platform/soft-navigations-experiment), attributed to the navigation span it belongs to.

This also changes how the initial page load is measured. Previously a page reported a single set of vitals that accumulated over the whole page lifetime. Now the page load's vitals are finalized at the first soft navigation, so **expect the values reported for page loads to drop** on apps that do client-side routing, most noticeably for CLS and INP. Aggregates such as p75s will shift after upgrading.

Reporting per soft navigation requires span streaming (`traceLifecycle: 'stream'`, the default) and is ignored in browsers without support for the Soft Navigations API (Chromium 151+). Navigations the browser does not detect as soft navigations (programmatic navigations, navigations that never paint) report no vitals at all, so coverage is lower than for page loads.

To keep the previous behaviour of one set of vitals for the whole page lifetime:

```js
Sentry.init({
integrations: [Sentry.browserTracingIntegration({ webVitals: { softNavigations: false } })],
});
```

### CLS and LCP no longer report intermediate values

Affected SDKs: All SDKs running in the browser.

With soft navigation reporting enabled (the default, see above), the SDK no longer subscribes to every intermediate CLS and LCP update. `web-vitals` reports once per navigation, with the final value.

This is required for per-navigation values to be correct: `web-vitals` skips any report with a zero delta, including the forced report at a navigation boundary, so subscribing to all changes means the page load never receives its final value.

The visible effect is in Session Replay, which records `web-vital` breadcrumbs from the same instrumentation. Replays now contain one LCP and one CLS entry per navigation instead of one per intermediate update. Where soft navigation reporting is disabled or unsupported, the previous behaviour is unchanged.

### `DOMException.code` is no longer set as a tag

Affected SDKs: All SDKs running in the browser.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ sentryTest(
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.navigation.type': { value: 'navigate', type: 'string' },
'sentry.transaction': { value: 'test-url', type: 'string' },
'sentry.segment.name': { value: 'test-url', type: 'string' },
'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ sentryTest(
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.navigation.type': { value: 'navigate', type: 'string' },
'sentry.transaction': { value: 'test-url', type: 'string' },
'sentry.segment.name': { value: 'test-url', type: 'string' },
'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' },
Expand Down Expand Up @@ -162,6 +163,7 @@ sentryTest(
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.navigation.type': { value: 'navigate', type: 'string' },
'sentry.transaction': { value: 'test-url', type: 'string' },
'sentry.segment.name': { value: 'test-url', type: 'string' },
'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ sentryTest(
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.navigation.type': { value: 'navigate', type: 'string' },
// the parametrized route name flows onto the INP span
'sentry.transaction': { value: 'test-route', type: 'string' },
'sentry.segment.name': { value: 'test-route', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ sentryTest(
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.navigation.type': { value: 'navigate', type: 'string' },
// the parametrized route name flows onto the INP span
'sentry.transaction': { value: 'test-route', type: 'string' },
'sentry.segment.name': { value: 'test-route', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ sentryTest(
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.navigation.type': { value: 'navigate', type: 'string' },
'sentry.transaction': { value: 'test-url', type: 'string' },
'sentry.segment.name': { value: 'test-url', type: 'string' },
'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' },
Expand Down Expand Up @@ -147,6 +148,7 @@ sentryTest(
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
'browser.navigation.type': { value: 'navigate', type: 'string' },
'sentry.transaction': { value: 'test-url', type: 'string' },
'sentry.segment.name': { value: 'test-url', type: 'string' },
'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' },
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"dependencies": {
"@sentry/core": "10.67.0",
"@sentry/conventions": "^0.20.0",
"web-vitals": "^6.0.1"
"web-vitals": "^6.1.1"
},
"scripts": {
"build": "run-p build:transpile build:types",
Expand Down
3 changes: 3 additions & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ export {
addLcpInstrumentationHandler,
addInpInstrumentationHandler,
addFcpInstrumentationHandler,
enableSoftNavigationReporting,
} from './instrumentation/performanceObserver';

export { startSoftNavigationCorrelation, supportsSoftNavigations } from './web-vitals/softNavs';

export { addPerformanceEntries, startTrackingLongTasks, startTrackingLongAnimationFrames } from './performance/entries';

export {
Expand Down
108 changes: 91 additions & 17 deletions packages/browser-utils/src/instrumentation/performanceObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type InstrumentHandlerTypePerformanceObserver =
| 'paint'
| 'resource'
| 'element'
| 'soft-navigation'
// fist-input is still needed for INP
| 'first-input';

Expand All @@ -32,6 +33,16 @@ export interface PerformanceEventTiming extends PerformanceEntry {
interactionId?: number;
}

/**
* A `soft-navigation` entry, minted by the browser once a history change is followed by a
* confirming paint. `interactionId` is the id of the `PerformanceEventTiming` entry for the
* interaction that drove the navigation, which is how we join it back to a Sentry navigation span.
*/
export interface PerformanceSoftNavigation extends PerformanceEntry {
readonly interactionId: number;
readonly navigationId: number;
}

interface PerformanceScriptTiming extends PerformanceEntry {
sourceURL: string;
sourceFunctionName: string;
Expand All @@ -47,6 +58,19 @@ export interface PerformanceLongAnimationFrameTiming extends PerformanceEntry {
// entrypoint carries a `declare global` block that references DOM globals not present in every
// TypeScript lib version (e.g. `NavigationType`), which leaks into and breaks consumers on older
// TS. Keeping this local keeps web-vitals' global augmentations out of our published types.
/**
* The navigation types web-vitals reports a metric for. Wider than the set the
* `browser.navigation.type` attribute uses - see `toBrowserNavigationType`.
*/
export type MetricNavigationType =
| 'navigate'
| 'reload'
| 'back-forward'
| 'back-forward-cache'
| 'prerender'
| 'restore'
| 'soft-navigation';

interface Metric {
/**
* The name of the metric (in acronym form).
Expand Down Expand Up @@ -95,14 +119,30 @@ interface Metric {
* support that API). For pages that are restored from the bfcache, this
* value will be 'back-forward-cache'.
*/
navigationType:
| 'navigate'
| 'reload'
| 'back-forward'
| 'back-forward-cache'
| 'prerender'
| 'restore'
| 'soft-navigation';
navigationType: MetricNavigationType;

/**
* The id of the navigation the metric belongs to. For soft navigations this is the
* `navigationId` of the `soft-navigation` entry, otherwise it's the id of the hard navigation.
*/
navigationId: number;

/**
* For soft navigations, the `interactionId` of the interaction that triggered the navigation.
*/
navigationInteractionId?: number;

/**
* The start time the metric value is relative to. Non-zero for soft navigations, where the
* time origin is the triggering interaction rather than the start of the document.
*/
navigationStartTime?: number;

/**
* The URL the metric was recorded for. Relevant for soft navigations, where a metric can be
* reported long after the URL has moved on.
*/
navigationURL?: string;
}

type InstrumentHandlerType = InstrumentHandlerTypeMetric | InstrumentHandlerTypePerformanceObserver;
Expand All @@ -123,6 +163,31 @@ let _previousTtfb: Metric | undefined;
let _previousInp: Metric | undefined;
let _previousFcp: Metric | undefined;

const stopListeners: Partial<Record<InstrumentHandlerType, StopListening>> = {};

let _reportSoftNavs = false;

/**
Comment thread
logaretm marked this conversation as resolved.
Comment thread
logaretm marked this conversation as resolved.
* Opt the CLS, LCP and INP observers into reporting metrics for soft navigations.
*
* This also turns `reportAllChanges` off for CLS and LCP. web-vitals force-reports a metric when
* the navigation it belongs to is over, so without the intermediate updates every value a handler
* receives is already the final one for its navigation. That only holds because soft navigations
* are limited to span streaming, where CLS and LCP are sent as their own spans - the static
* lifecycle instead writes them onto the pageload span as it ends, which is what `reportAllChanges`
* was originally added for (#11934, #12360).
*
* Each observer is instrumented lazily, on its first handler, and web-vitals takes its options at
* that point only. So this has to be called before any of the `add*InstrumentationHandler`
* functions, otherwise it won't take effect for observers that are already running.
*
* On browsers without the Soft Navigation API this is a no-op: web-vitals feature-detects the API
* and keeps reporting hard-navigation metrics as usual.
*/
export function enableSoftNavigationReporting(): void {
_reportSoftNavs = true;
}

/**
* Add a callback that will be triggered when a CLS metric is available.
* Returns a cleanup callback which can be called to remove the instrumentation handler.
Expand Down Expand Up @@ -255,7 +320,7 @@ function instrumentCls(): StopListening {
}),
// We want the callback to be called whenever the CLS value updates.
// By default, the callback is only called when the tab goes to the background.
{ reportAllChanges: true },
{ reportAllChanges: !_reportSoftNavs, reportSoftNavs: _reportSoftNavs },
Comment thread
logaretm marked this conversation as resolved.
);
}

Expand All @@ -269,7 +334,7 @@ function instrumentLcp(): StopListening {
}),
// We want the callback to be called whenever the LCP value updates.
// By default, the callback is only called when the tab goes to the background.
{ reportAllChanges: true },
{ reportAllChanges: !_reportSoftNavs, reportSoftNavs: _reportSoftNavs },
);
}

Expand Down Expand Up @@ -303,6 +368,7 @@ function instrumentInp(): StopListening {
});
_previousInp = metric;
}),
{ reportSoftNavs: _reportSoftNavs },
);
}

Expand All @@ -315,18 +381,24 @@ function addMetricObserver(
): CleanupHandlerCallback {
addHandler(type, callback);

let stopListening: StopListening | undefined;

if (!instrumented[type]) {
stopListening = instrumentFn();
instrumented[type] = true;
// Deferred by a microtask rather than started here, because web-vitals reads its options once,
// when the observer is created. Registering a handler would otherwise pin those options for
// every other consumer of this observer, so whichever integration happened to run first would
// decide whether soft navigations and bfcache restores are reported. Client setup is
// synchronous, so every `enable*Reporting()` call has landed by the time this runs, and the
// observers are buffered so no entries are missed in the meantime.
void Promise.resolve().then(() => {
stopListeners[type] = instrumentFn();
});
}

if (previousValue) {
callback({ metric: previousValue });
}

return getCleanupCallback(type, callback, stopOnCallback ? stopListening : undefined);
return getCleanupCallback(type, callback, stopOnCallback);
}

function instrumentPerformanceObserver(type: InstrumentHandlerTypePerformanceObserver): void {
Expand Down Expand Up @@ -363,11 +435,13 @@ function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallb
function getCleanupCallback(
type: InstrumentHandlerType,
callback: InstrumentHandlerCallback,
stopListening: StopListening,
stopOnCleanup = false,
): CleanupHandlerCallback {
return () => {
if (stopListening) {
stopListening();
// Looked up rather than captured: the observer is started in a microtask, so its stop function
// does not exist yet when this callback is built.
if (stopOnCleanup) {
stopListeners[type]?.();
}

const typeHandlers = handlers[type];
Expand Down
Loading
Loading