Skip to content

Commit abfcedf

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 08e3c70 commit abfcedf

4 files changed

Lines changed: 106 additions & 13 deletions

File tree

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

Lines changed: 14 additions & 8 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

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,28 @@ import {
1111
import { startInactiveSpan } from '@sentry/core/browser';
1212
import { SENTRY_SEGMENT_NAME, SENTRY_TRANSACTION } from '@sentry/conventions/attributes';
1313
import { WINDOW } from '../types';
14+
import type { MetricNavigationType } from '../instrumentation/performanceObserver';
1415
import type { WebVitalReportEvent } from './reportEvents';
1516
import { SOFT_NAVIGATION_ID_ATTRIBUTE } from './softNavs';
1617

18+
// TODO(conventions): replace with `BROWSER_NAVIGATION_TYPE` from `@sentry/conventions/attributes`
19+
// once https://github.com/getsentry/sentry-conventions/pull/600 is released.
20+
const BROWSER_NAVIGATION_TYPE_ATTRIBUTE = 'browser.navigation.type';
21+
22+
// web-vitals reports a wider set of navigation types than the attribute defines. Only the states
23+
// Navigation Timing cannot express keep their own value; every ordinary document navigation folds
24+
// into `navigate`, including a back/forward that missed the bfcache and a discarded-tab restore.
25+
const BROWSER_NAVIGATION_TYPES: Partial<Record<MetricNavigationType, string>> = {
26+
reload: 'reload',
27+
prerender: 'prerender',
28+
'back-forward-cache': 'bfcache',
29+
'soft-navigation': 'soft-navigation',
30+
};
31+
32+
function toBrowserNavigationType(navigationType: MetricNavigationType): string {
33+
return BROWSER_NAVIGATION_TYPES[navigationType] ?? 'navigate';
34+
}
35+
1736
// Locally-defined interfaces to avoid leaking bare global type references into the
1837
// generated .d.ts. The `declare global` augmentations in web-vitals/types.ts make these
1938
// available during this package's compilation but are NOT carried to consumers.
@@ -46,6 +65,8 @@ interface WebVitalSpanOptions {
4665
endTime?: number;
4766
/** Set when the vital was reported for a soft navigation rather than the initial page load. */
4867
softNavigationId?: number;
68+
/** The navigation the vital was measured on, as reported by web-vitals. */
69+
navigationType?: MetricNavigationType;
4970
/**
5071
* When `true`, the span is sent on its own as a v2 streamed span instead of being folded into a
5172
* transaction. Used for INP when span streaming is disabled (it reports late, so it can't ride
@@ -74,6 +95,7 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void {
7495
endTime,
7596
standalone,
7697
softNavigationId,
98+
navigationType,
7799
} = options;
78100

79101
// Taken off the segment span itself, so it can't diverge from it: a routing instrumentation may
@@ -109,6 +131,10 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void {
109131
attributes[SOFT_NAVIGATION_ID_ATTRIBUTE] = softNavigationId;
110132
}
111133

134+
if (navigationType) {
135+
attributes[BROWSER_NAVIGATION_TYPE_ATTRIBUTE] = toBrowserNavigationType(navigationType);
136+
}
137+
112138
// A standalone span is sent as a plain v2 span without running the `processSpan` hooks (see
113139
// `captureStandaloneSpanWithStaticCallback`), so Replay can't attach the replay id itself. Set it
114140
// here, mirroring Replay's `processSpan`, so INP keeps its replay association like it did on v1.

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

Lines changed: 20 additions & 5 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,20 @@ 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(metric.value, entry, parentSpan, undefined, softNavigationId, metric.navigationType);
9696
});
9797
return;
9898
}
9999

100100
let lcpValue = 0;
101101
let lcpEntry: LargestContentfulPaint | undefined;
102+
let lcpNavigationType: MetricNavigationType | undefined;
102103

103104
const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => {
105+
// The navigation type describes the page, not the entry, so it is worth keeping even for a
106+
// report we otherwise discard.
107+
lcpNavigationType = metric.navigationType;
108+
104109
const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined;
105110
if (!entry || !isValidLcpMetric(metric.value)) {
106111
return;
@@ -110,7 +115,7 @@ export function trackLcpAsSpan(client: Client, reportSoftNavs = false): void {
110115
}, true);
111116

112117
listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => {
113-
_sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, reportEvent);
118+
_sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, reportEvent, undefined, lcpNavigationType);
114119
cleanupLcpHandler();
115120
});
116121
}
@@ -124,6 +129,7 @@ export function _sendLcpSpan(
124129
pageloadSpan?: Span,
125130
reportEvent?: WebVitalReportEvent,
126131
softNavigationId?: number,
132+
navigationType?: MetricNavigationType,
127133
): void {
128134
if (!isValidLcpMetric(lcpValue)) {
129135
return;
@@ -157,6 +163,7 @@ export function _sendLcpSpan(
157163
startTime: timeOrigin,
158164
endTime,
159165
softNavigationId,
166+
navigationType,
160167
});
161168
}
162169

@@ -171,15 +178,20 @@ export function trackClsAsSpan(client: Client, reportSoftNavs = false): void {
171178
if (reportSoftNavs) {
172179
trackWebVitalPerNavigation(client, addClsInstrumentationHandler, (metric, parentSpan, softNavigationId) => {
173180
const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
174-
_sendClsSpan(metric.value, entry, parentSpan, undefined, softNavigationId);
181+
_sendClsSpan(metric.value, entry, parentSpan, undefined, softNavigationId, metric.navigationType);
175182
});
176183
return;
177184
}
178185

179186
let clsValue = 0;
180187
let clsEntry: LayoutShift | undefined;
188+
let clsNavigationType: MetricNavigationType | undefined;
181189

182190
const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => {
191+
// A CLS of 0 is reported with no entries and still emits a span, so the navigation type has to
192+
// be captured before the entry check rather than alongside the value.
193+
clsNavigationType = metric.navigationType;
194+
183195
const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
184196
if (!entry) {
185197
return;
@@ -189,7 +201,7 @@ export function trackClsAsSpan(client: Client, reportSoftNavs = false): void {
189201
}, true);
190202

191203
listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => {
192-
_sendClsSpan(clsValue, clsEntry, pageloadSpan, reportEvent);
204+
_sendClsSpan(clsValue, clsEntry, pageloadSpan, reportEvent, undefined, clsNavigationType);
193205
cleanupClsHandler();
194206
});
195207
}
@@ -203,6 +215,7 @@ export function _sendClsSpan(
203215
pageloadSpan?: Span,
204216
reportEvent?: WebVitalReportEvent,
205217
softNavigationId?: number,
218+
navigationType?: MetricNavigationType,
206219
): void {
207220
DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`);
208221

@@ -228,6 +241,7 @@ export function _sendClsSpan(
228241
reportEvent,
229242
startTime,
230243
softNavigationId,
244+
navigationType,
231245
});
232246
}
233247

@@ -332,6 +346,7 @@ export function _sendInpSpan(
332346
},
333347
startTime,
334348
endTime: startTime + duration,
349+
navigationType: metric?.navigationType,
335350
parentSpan: spanToUse,
336351
standalone,
337352
softNavigationId,

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,50 @@ describe('_emitWebVitalSpan', () => {
331331
});
332332
}).not.toThrow();
333333
});
334+
335+
it.each([
336+
['navigate', 'navigate'],
337+
['reload', 'reload'],
338+
['prerender', 'prerender'],
339+
['soft-navigation', 'soft-navigation'],
340+
['back-forward-cache', 'bfcache'],
341+
// Ordinary document navigations the attribute has no separate value for.
342+
['back-forward', 'navigate'],
343+
['restore', 'navigate'],
344+
] as const)('reports navigationType %s as browser.navigation.type %s', (navigationType, expected) => {
345+
_emitWebVitalSpan({
346+
name: 'Test',
347+
op: 'ui.webvital.lcp',
348+
origin: 'auto.http.browser.lcp',
349+
metricName: 'lcp',
350+
value: 50,
351+
startTime: 1.0,
352+
navigationType,
353+
});
354+
355+
expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(
356+
expect.objectContaining({
357+
attributes: expect.objectContaining({ 'browser.navigation.type': expected }),
358+
}),
359+
);
360+
});
361+
362+
it('omits browser.navigation.type when the navigation type is unknown', () => {
363+
_emitWebVitalSpan({
364+
name: 'Test',
365+
op: 'ui.webvital.lcp',
366+
origin: 'auto.http.browser.lcp',
367+
metricName: 'lcp',
368+
value: 50,
369+
startTime: 1.0,
370+
});
371+
372+
expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalledWith(
373+
expect.objectContaining({
374+
attributes: expect.objectContaining({ 'browser.navigation.type': expect.anything() }),
375+
}),
376+
);
377+
});
334378
});
335379

336380
describe('_sendLcpSpan', () => {
@@ -765,9 +809,11 @@ describe('soft navigation web vitals', () => {
765809
expect(calls).toHaveLength(2);
766810
expect(calls[0]![0].attributes?.['browser.web_vital.lcp.value']).toBe(800);
767811
expect(calls[0]![0].attributes?.['browser.soft_navigation.id']).toBeUndefined();
812+
expect(calls[0]![0].attributes?.['browser.navigation.type']).toBe('navigate');
768813
expect(calls[0]![0].parentSpan).toBe(pageloadSpan);
769814
expect(calls[1]![0].attributes?.['browser.web_vital.lcp.value']).toBe(300);
770815
expect(calls[1]![0].attributes?.['browser.soft_navigation.id']).toBe(2);
816+
expect(calls[1]![0].attributes?.['browser.navigation.type']).toBe('soft-navigation');
771817
expect(calls[1]![0].parentSpan).toBe(navigationSpan);
772818
});
773819

0 commit comments

Comments
 (0)