Skip to content

Commit d138028

Browse files
committed
feat(browser): Attribute web vitals to the navigation they were measured 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
1 parent 9003818 commit d138028

12 files changed

Lines changed: 323 additions & 30 deletions

File tree

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ sentryTest(
7777
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
7878
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
7979
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
80+
'browser.navigation.type': { value: 'navigate', type: 'string' },
8081
'sentry.transaction': { value: 'test-url', type: 'string' },
8182
'sentry.segment.name': { value: 'test-url', type: 'string' },
8283
'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' },

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ sentryTest(
7777
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
7878
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
7979
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
80+
'browser.navigation.type': { value: 'navigate', type: 'string' },
8081
'sentry.transaction': { value: 'test-url', type: 'string' },
8182
'sentry.segment.name': { value: 'test-url', type: 'string' },
8283
'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' },
@@ -162,6 +163,7 @@ sentryTest(
162163
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
163164
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
164165
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
166+
'browser.navigation.type': { value: 'navigate', type: 'string' },
165167
'sentry.transaction': { value: 'test-url', type: 'string' },
166168
'sentry.segment.name': { value: 'test-url', type: 'string' },
167169
'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' },

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ sentryTest(
7878
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
7979
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
8080
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
81+
'browser.navigation.type': { value: 'navigate', type: 'string' },
8182
// the parametrized route name flows onto the INP span
8283
'sentry.transaction': { value: 'test-route', type: 'string' },
8384
'sentry.segment.name': { value: 'test-route', type: 'string' },

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ sentryTest(
7878
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
7979
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
8080
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
81+
'browser.navigation.type': { value: 'navigate', type: 'string' },
8182
// the parametrized route name flows onto the INP span
8283
'sentry.transaction': { value: 'test-route', type: 'string' },
8384
'sentry.segment.name': { value: 'test-route', type: 'string' },

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ sentryTest(
8181
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
8282
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
8383
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
84+
'browser.navigation.type': { value: 'navigate', type: 'string' },
8485
'sentry.transaction': { value: 'test-url', type: 'string' },
8586
'sentry.segment.name': { value: 'test-url', type: 'string' },
8687
'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' },
@@ -147,6 +148,7 @@ sentryTest(
147148
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
148149
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
149150
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
151+
'browser.navigation.type': { value: 'navigate', type: 'string' },
150152
'sentry.transaction': { value: 'test-url', type: 'string' },
151153
'sentry.segment.name': { value: 'test-url', type: 'string' },
152154
'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' },

packages/browser-utils/src/instrumentation/performanceObserver.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ export interface PerformanceLongAnimationFrameTiming extends PerformanceEntry {
5858
// entrypoint carries a `declare global` block that references DOM globals not present in every
5959
// TypeScript lib version (e.g. `NavigationType`), which leaks into and breaks consumers on older
6060
// TS. Keeping this local keeps web-vitals' global augmentations out of our published types.
61+
/**
62+
* The navigation types web-vitals reports a metric for. Wider than the set the
63+
* `browser.navigation.type` attribute uses - see `toBrowserNavigationType`.
64+
*/
65+
export type MetricNavigationType =
66+
| 'navigate'
67+
| 'reload'
68+
| 'back-forward'
69+
| 'back-forward-cache'
70+
| 'prerender'
71+
| 'restore'
72+
| 'soft-navigation';
73+
6174
interface Metric {
6275
/**
6376
* The name of the metric (in acronym form).
@@ -106,14 +119,7 @@ interface Metric {
106119
* support that API). For pages that are restored from the bfcache, this
107120
* value will be 'back-forward-cache'.
108121
*/
109-
navigationType:
110-
| 'navigate'
111-
| 'reload'
112-
| 'back-forward'
113-
| 'back-forward-cache'
114-
| 'prerender'
115-
| 'restore'
116-
| 'soft-navigation';
122+
navigationType: MetricNavigationType;
117123

118124
/**
119125
* The id of the navigation the metric belongs to. For soft navigations this is the
@@ -157,6 +163,8 @@ let _previousTtfb: Metric | undefined;
157163
let _previousInp: Metric | undefined;
158164
let _previousFcp: Metric | undefined;
159165

166+
const stopListeners: Partial<Record<InstrumentHandlerType, StopListening>> = {};
167+
160168
let _reportSoftNavs = false;
161169

162170
/**
@@ -373,18 +381,24 @@ function addMetricObserver(
373381
): CleanupHandlerCallback {
374382
addHandler(type, callback);
375383

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

383397
if (previousValue) {
384398
callback({ metric: previousValue });
385399
}
386400

387-
return getCleanupCallback(type, callback, stopOnCallback ? stopListening : undefined);
401+
return getCleanupCallback(type, callback, stopOnCallback);
388402
}
389403

390404
function instrumentPerformanceObserver(type: InstrumentHandlerTypePerformanceObserver): void {
@@ -421,11 +435,13 @@ function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallb
421435
function getCleanupCallback(
422436
type: InstrumentHandlerType,
423437
callback: InstrumentHandlerCallback,
424-
stopListening: StopListening,
438+
stopOnCleanup = false,
425439
): CleanupHandlerCallback {
426440
return () => {
427-
if (stopListening) {
428-
stopListening();
441+
// Looked up rather than captured: the observer is started in a microtask, so its stop function
442+
// does not exist yet when this callback is built.
443+
if (stopOnCleanup) {
444+
stopListeners[type]?.();
429445
}
430446

431447
const typeHandlers = handlers[type];

packages/browser-utils/src/web-vitals/emitSpan.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,35 @@ import {
99
spanToJSON,
1010
} from '@sentry/core';
1111
import { startInactiveSpan } from '@sentry/core/browser';
12-
import { SENTRY_SEGMENT_NAME, SENTRY_TRANSACTION } from '@sentry/conventions/attributes';
12+
import {
13+
SENTRY_REPLAY_ID,
14+
SENTRY_SEGMENT_NAME,
15+
SENTRY_TRANSACTION,
16+
USER_AGENT_ORIGINAL,
17+
} from '@sentry/conventions/attributes';
1318
import { WINDOW } from '../types';
19+
import type { MetricNavigationType } from '../instrumentation/performanceObserver';
1420
import type { WebVitalReportEvent } from './reportEvents';
1521
import { SOFT_NAVIGATION_ID_ATTRIBUTE } from './softNavs';
1622

23+
// TODO(conventions): replace with `BROWSER_NAVIGATION_TYPE` from `@sentry/conventions/attributes`
24+
// once https://github.com/getsentry/sentry-conventions/pull/600 is released.
25+
const BROWSER_NAVIGATION_TYPE_ATTRIBUTE = 'browser.navigation.type';
26+
27+
// web-vitals reports a wider set of navigation types than the attribute defines. Only the states
28+
// Navigation Timing cannot express keep their own value; every ordinary document navigation folds
29+
// into `navigate`, including a back/forward that missed the bfcache and a discarded-tab restore.
30+
const BROWSER_NAVIGATION_TYPES: Partial<Record<MetricNavigationType, string>> = {
31+
reload: 'reload',
32+
prerender: 'prerender',
33+
'back-forward-cache': 'bfcache',
34+
'soft-navigation': 'soft-navigation',
35+
};
36+
37+
function toBrowserNavigationType(navigationType: MetricNavigationType): string {
38+
return BROWSER_NAVIGATION_TYPES[navigationType] ?? 'navigate';
39+
}
40+
1741
// Locally-defined interfaces to avoid leaking bare global type references into the
1842
// generated .d.ts. The `declare global` augmentations in web-vitals/types.ts make these
1943
// available during this package's compilation but are NOT carried to consumers.
@@ -46,6 +70,8 @@ interface WebVitalSpanOptions {
4670
endTime?: number;
4771
/** Set when the vital was reported for a soft navigation rather than the initial page load. */
4872
softNavigationId?: number;
73+
/** The navigation the vital was measured on, as reported by web-vitals. */
74+
navigationType?: MetricNavigationType;
4975
/**
5076
* When `true`, the span is sent on its own as a v2 streamed span instead of being folded into a
5177
* transaction. Used for INP when span streaming is disabled (it reports late, so it can't ride
@@ -74,6 +100,7 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void {
74100
endTime,
75101
standalone,
76102
softNavigationId,
103+
navigationType,
77104
} = options;
78105

79106
// Taken off the segment span itself, so it can't diverge from it: a routing instrumentation may
@@ -92,7 +119,7 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void {
92119
[SENTRY_TRANSACTION]: segmentName,
93120
[SENTRY_SEGMENT_NAME]: segmentName,
94121
// Web vital score calculation relies on the user agent
95-
'user_agent.original': WINDOW.navigator?.userAgent,
122+
[USER_AGENT_ORIGINAL]: WINDOW.navigator?.userAgent,
96123
...passedAttributes,
97124
};
98125

@@ -109,6 +136,10 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void {
109136
attributes[SOFT_NAVIGATION_ID_ATTRIBUTE] = softNavigationId;
110137
}
111138

139+
if (navigationType) {
140+
attributes[BROWSER_NAVIGATION_TYPE_ATTRIBUTE] = toBrowserNavigationType(navigationType);
141+
}
142+
112143
// A standalone span is sent as a plain v2 span without running the `processSpan` hooks (see
113144
// `captureStandaloneSpanWithStaticCallback`), so Replay can't attach the replay id itself. Set it
114145
// here, mirroring Replay's `processSpan`, so INP keeps its replay association like it did on v1.
@@ -147,7 +178,9 @@ function getReplayAttributes(): SpanAttributes {
147178
}
148179

149180
return {
150-
'sentry.replay_id': replayId,
181+
[SENTRY_REPLAY_ID]: replayId,
182+
// Not the `SENTRY_REPLAY_IS_BUFFERING` convention: that one has no `sentry._internal.` prefix, and
183+
// the rest of the SDK sets the prefixed key (see `logs/internal.ts`, `metrics/internal.ts`).
151184
'sentry._internal.replay_is_buffering': replay!.getRecordingMode() === 'buffer' ? true : undefined,
152185
};
153186
}

packages/browser-utils/src/web-vitals/spans.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { DEBUG_BUILD } from '../debug-build';
1212
import { htmlTreeAsString } from '../htmlTreeAsString';
1313
import type { InteractionType } from './inp';
1414
import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp';
15-
import type { InstrumentationHandlerCallback } from '../instrumentation/performanceObserver';
15+
import type { InstrumentationHandlerCallback, MetricNavigationType } from '../instrumentation/performanceObserver';
1616
import {
1717
addClsInstrumentationHandler,
1818
addInpInstrumentationHandler,
@@ -92,15 +92,28 @@ export function trackLcpAsSpan(client: Client, reportSoftNavs = false): void {
9292
if (reportSoftNavs) {
9393
trackWebVitalPerNavigation(client, addLcpInstrumentationHandler, (metric, parentSpan, softNavigationId) => {
9494
const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined;
95-
_sendLcpSpan(metric.value, entry, parentSpan, undefined, softNavigationId);
95+
_sendLcpSpan(
96+
metric.value,
97+
entry,
98+
parentSpan,
99+
undefined,
100+
softNavigationId,
101+
metric.navigationType,
102+
metric.navigationStartTime,
103+
);
96104
});
97105
return;
98106
}
99107

100108
let lcpValue = 0;
101109
let lcpEntry: LargestContentfulPaint | undefined;
110+
let lcpNavigationType: MetricNavigationType | undefined;
102111

103112
const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => {
113+
// The navigation type describes the page, not the entry, so it is worth keeping even for a
114+
// report we otherwise discard.
115+
lcpNavigationType = metric.navigationType;
116+
104117
const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined;
105118
if (!entry || !isValidLcpMetric(metric.value)) {
106119
return;
@@ -110,7 +123,7 @@ export function trackLcpAsSpan(client: Client, reportSoftNavs = false): void {
110123
}, true);
111124

112125
listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => {
113-
_sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, reportEvent);
126+
_sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, reportEvent, undefined, lcpNavigationType);
114127
cleanupLcpHandler();
115128
});
116129
}
@@ -124,6 +137,8 @@ export function _sendLcpSpan(
124137
pageloadSpan?: Span,
125138
reportEvent?: WebVitalReportEvent,
126139
softNavigationId?: number,
140+
navigationType?: MetricNavigationType,
141+
navigationStartTime?: number,
127142
): void {
128143
if (!isValidLcpMetric(lcpValue)) {
129144
return;
@@ -132,8 +147,13 @@ export function _sendLcpSpan(
132147
DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`);
133148

134149
const performanceTimeOrigin = browserPerformanceTimeOrigin() || 0;
135-
const timeOrigin = msToSec(performanceTimeOrigin);
136-
const endTime = msToSec(performanceTimeOrigin + (entry?.startTime || 0));
150+
// A soft navigation's LCP is measured from the triggering interaction, not the document time
151+
// origin. Starting the span there too keeps it inside the navigation span it is parented to and
152+
// keeps its duration equal to the value it reports.
153+
const startTime = msToSec(performanceTimeOrigin + (navigationStartTime || 0));
154+
// Without an entry there is no render time to end at, so the span lasts the value it reports,
155+
// like an entry-less INP does. Ending at the time origin instead would invert the span.
156+
const endTime = entry ? msToSec(performanceTimeOrigin + entry.startTime) : startTime + msToSec(lcpValue);
137157
const name = entry ? htmlTreeAsString(entry.element) : 'Largest contentful paint';
138158

139159
const attributes: SpanAttributes = {};
@@ -154,9 +174,10 @@ export function _sendLcpSpan(
154174
attributes,
155175
parentSpan: pageloadSpan,
156176
reportEvent,
157-
startTime: timeOrigin,
177+
startTime,
158178
endTime,
159179
softNavigationId,
180+
navigationType,
160181
});
161182
}
162183

@@ -171,15 +192,28 @@ export function trackClsAsSpan(client: Client, reportSoftNavs = false): void {
171192
if (reportSoftNavs) {
172193
trackWebVitalPerNavigation(client, addClsInstrumentationHandler, (metric, parentSpan, softNavigationId) => {
173194
const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
174-
_sendClsSpan(metric.value, entry, parentSpan, undefined, softNavigationId);
195+
_sendClsSpan(
196+
metric.value,
197+
entry,
198+
parentSpan,
199+
undefined,
200+
softNavigationId,
201+
metric.navigationType,
202+
metric.navigationStartTime,
203+
);
175204
});
176205
return;
177206
}
178207

179208
let clsValue = 0;
180209
let clsEntry: LayoutShift | undefined;
210+
let clsNavigationType: MetricNavigationType | undefined;
181211

182212
const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => {
213+
// A CLS of 0 is reported with no entries and still emits a span, so the navigation type has to
214+
// be captured before the entry check rather than alongside the value.
215+
clsNavigationType = metric.navigationType;
216+
183217
const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
184218
if (!entry) {
185219
return;
@@ -189,7 +223,7 @@ export function trackClsAsSpan(client: Client, reportSoftNavs = false): void {
189223
}, true);
190224

191225
listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => {
192-
_sendClsSpan(clsValue, clsEntry, pageloadSpan, reportEvent);
226+
_sendClsSpan(clsValue, clsEntry, pageloadSpan, reportEvent, undefined, clsNavigationType);
193227
cleanupClsHandler();
194228
});
195229
}
@@ -203,10 +237,17 @@ export function _sendClsSpan(
203237
pageloadSpan?: Span,
204238
reportEvent?: WebVitalReportEvent,
205239
softNavigationId?: number,
240+
navigationType?: MetricNavigationType,
241+
navigationStartTime?: number,
206242
): void {
207243
DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`);
208244

209-
const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds();
245+
const performanceTimeOrigin = browserPerformanceTimeOrigin();
246+
// A CLS of 0 has no shift to place the span at. It is reported when the navigation it was
247+
// measured on is already over - the next soft navigation, or pagehide - so the current time would
248+
// land it outside that navigation, on the route that follows it.
249+
const offset = entry?.startTime ?? navigationStartTime ?? 0;
250+
const startTime = performanceTimeOrigin ? msToSec(performanceTimeOrigin + offset) : timestampInSeconds();
210251
const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift';
211252

212253
const attributes: SpanAttributes = {};
@@ -228,6 +269,7 @@ export function _sendClsSpan(
228269
reportEvent,
229270
startTime,
230271
softNavigationId,
272+
navigationType,
231273
});
232274
}
233275

@@ -332,6 +374,7 @@ export function _sendInpSpan(
332374
},
333375
startTime,
334376
endTime: startTime + duration,
377+
navigationType: metric?.navigationType,
335378
parentSpan: spanToUse,
336379
standalone,
337380
softNavigationId,

0 commit comments

Comments
 (0)