Skip to content

Commit 9350361

Browse files
authored
fix(browser): Source FCP from web-vitals onFCP and rebase FP against activationStart (#23032)
Corrects First Paint and First Contentful Paint on the pageload span for pages prerendered via the Speculation Rules API. Both were read straight off the `paint` observer as a raw `entry.startTime`, so on a prerendered page they carried the whole time the document sat in the prerender buffer. Rather than patch the arithmetic in two places I moved FCP onto web-vitals' `onFCP`, which is where CLS/LCP/TTFB/INP already get their `activationStart` correction. web-vitals has no `onFP`, so FP stays on the paint observer with the correction applied inline.
1 parent 6031e0f commit 9350361

5 files changed

Lines changed: 281 additions & 12 deletions

File tree

‎packages/browser-utils/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {
44
addTtfbInstrumentationHandler,
55
addLcpInstrumentationHandler,
66
addInpInstrumentationHandler,
7+
addFcpInstrumentationHandler,
78
} from './instrumentation/performanceObserver';
89

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

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { debug, getFunctionName } from '@sentry/core';
2-
import { onCLS, onINP, onLCP, onTTFB } from 'web-vitals';
2+
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
33
import { DEBUG_BUILD } from '../debug-build';
44

55
type InstrumentHandlerTypePerformanceObserver =
@@ -12,7 +12,7 @@ type InstrumentHandlerTypePerformanceObserver =
1212
// fist-input is still needed for INP
1313
| 'first-input';
1414

15-
type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'ttfb' | 'inp';
15+
type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'ttfb' | 'inp' | 'fcp';
1616

1717
// We provide this here manually instead of relying on a global, as this is not available in non-browser environements
1818
// And we do not want to expose such types
@@ -121,6 +121,7 @@ let _previousCls: Metric | undefined;
121121
let _previousLcp: Metric | undefined;
122122
let _previousTtfb: Metric | undefined;
123123
let _previousInp: Metric | undefined;
124+
let _previousFcp: Metric | undefined;
124125

125126
/**
126127
* Add a callback that will be triggered when a CLS metric is available.
@@ -157,6 +158,13 @@ export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric
157158
return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb);
158159
}
159160

161+
/**
162+
* Add a callback that will be triggered when a FCP metric is available.
163+
*/
164+
export function addFcpInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback {
165+
return addMetricObserver('fcp', callback, instrumentFcp, _previousFcp);
166+
}
167+
160168
export type InstrumentationHandlerCallback = (data: {
161169
metric: Omit<Metric, 'entries'> & {
162170
entries: PerformanceEventTiming[];
@@ -276,6 +284,17 @@ function instrumentTtfb(): StopListening {
276284
);
277285
}
278286

287+
function instrumentFcp(): StopListening {
288+
return onFCP(
289+
withoutBfcache(metric => {
290+
triggerHandlers('fcp', {
291+
metric,
292+
});
293+
_previousFcp = metric;
294+
}),
295+
);
296+
}
297+
279298
function instrumentInp(): StopListening {
280299
return onINP(
281300
withoutBfcache(metric => {

‎packages/browser-utils/src/web-vitals/tracking.ts‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { DEBUG_BUILD } from '../debug-build';
55
import { htmlTreeAsString } from '../htmlTreeAsString';
66
import {
77
addClsInstrumentationHandler,
8+
addFcpInstrumentationHandler,
89
addLcpInstrumentationHandler,
910
addPerformanceInstrumentationHandler,
1011
addTtfbInstrumentationHandler,
@@ -35,11 +36,13 @@ export function startTrackingWebVitals({ trackCls, trackLcp }: StartTrackingWebV
3536
const lcpCleanupCallback = trackLcp ? _trackLCP() : undefined;
3637
const clsCleanupCallback = trackCls ? _trackCLS() : undefined;
3738
const ttfbCleanupCallback = _trackTtfb();
38-
const fpFcpCleanupCallback = _trackFpFcp();
39+
const fcpCleanupCallback = _trackFcp();
40+
const fpCleanupCallback = _trackFp();
3941

4042
return (): void => {
4143
ttfbCleanupCallback();
42-
fpFcpCleanupCallback();
44+
fcpCleanupCallback();
45+
fpCleanupCallback();
4346
lcpCleanupCallback?.();
4447
clsCleanupCallback?.();
4548
};
@@ -89,19 +92,28 @@ function _trackTtfb(): () => void {
8992
});
9093
}
9194

92-
/** Starts tracking First Paint and First Contentful Paint on the current page. */
93-
function _trackFpFcp(): () => void {
95+
/** Starts tracking the First Contentful Paint on the current page. */
96+
function _trackFcp(): () => void {
97+
return addFcpInstrumentationHandler(({ metric }) => {
98+
_measurements['fcp'] = { value: metric.value, unit: 'millisecond' };
99+
});
100+
}
101+
102+
/**
103+
* Starts tracking First Paint on the current page.
104+
*
105+
* web-vitals has no `onFP`, so this stays on the raw paint observer, and mirrors the one thing
106+
* `onFCP` does inline: skip the vital if the page was hidden before it. The raw `startTime` is
107+
* stored as-is; rebasing it against `activationStart` happens in `_rebaseFpAgainstActivationStart`
108+
* when the span ends, because this observer can run before the page is activated.
109+
*/
110+
function _trackFp(): () => void {
94111
return addPerformanceInstrumentationHandler('paint', ({ entries }) => {
95112
const firstHidden = getVisibilityWatcher();
96113
for (const entry of entries) {
97-
// Only report if the page wasn't hidden prior to the web vital.
98-
const shouldRecord = entry.startTime < firstHidden.firstHiddenTime;
99-
if (entry.name === 'first-paint' && shouldRecord) {
114+
if (entry.name === 'first-paint' && entry.startTime < firstHidden.firstHiddenTime) {
100115
_measurements['fp'] = { value: entry.startTime, unit: 'millisecond' };
101116
}
102-
if (entry.name === 'first-contentful-paint' && shouldRecord) {
103-
_measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' };
104-
}
105117
}
106118
});
107119
}
@@ -153,6 +165,7 @@ export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOption
153165
// Measurements are only available for pageload transactions
154166
if (spanToJSON(span).attributes[SENTRY_OP] === 'pageload') {
155167
_addTtfbRequestTimeToMeasurements(_measurements);
168+
_rebaseFpAgainstActivationStart(_measurements);
156169

157170
if (spanStreamingEnabled) {
158171
const setAttr = (shortWebVitalName: string, value: number, customAttrName?: string) => {
@@ -254,6 +267,24 @@ function _setWebVitalAttributes(span: Span, options: AddWebVitalsToSpanOptions):
254267
}
255268
}
256269

270+
/**
271+
* Rebases First Paint against `activationStart`, so a prerendered page reports time-to-paint from
272+
* the moment the user activated it rather than from the (much earlier) prerender navigation start.
273+
*
274+
* The vitals that come from web-vitals do this themselves, when they compute their value: they only
275+
* start observing once the page is activated, so `activationStart` is known by then. FP has no
276+
* web-vitals equivalent and is read off a `buffered` paint observer registered at SDK init, which
277+
* on a prerendered page runs while `document.prerendering` is still true and `activationStart` is
278+
* still 0. Correcting there would be a no-op, so it happens here, at span end, by which point the
279+
* activation time is known.
280+
*/
281+
function _rebaseFpAgainstActivationStart(measurements: Measurements): void {
282+
const fp = measurements['fp'];
283+
if (fp) {
284+
fp.value = Math.max(fp.value - getActivationStart(), 0);
285+
}
286+
}
287+
257288
/**
258289
* Add ttfb request time information to measurements.
259290
*
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { getClient, getMainCarrier, SentrySpan, setCurrentClient, spanToJSON } from '@sentry/core';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { addWebVitalsToSpan, startTrackingWebVitals } from '../../src/web-vitals/tracking';
4+
import { getDefaultClientOptions, TestClient } from '../utils/TestClient';
5+
6+
// Lives in its own file rather than alongside the regular-page-load case: the paint observers, the
7+
// `instrumented` registry and the visibility watcher are all module-level singletons that can only
8+
// be armed once, so a second scenario in the same file would reuse the first one's state.
9+
10+
interface ObserverRegistration {
11+
callback: (list: PerformanceObserverEntryList) => void;
12+
buffered: boolean;
13+
}
14+
15+
const observers: ObserverRegistration[] = [];
16+
const emittedEntries: PerformanceEntry[] = [];
17+
18+
function deliver(callback: (list: PerformanceObserverEntryList) => void, entries: PerformanceEntry[]): void {
19+
callback({ getEntries: () => entries } as PerformanceObserverEntryList);
20+
}
21+
22+
/**
23+
* Models the two things the real `PerformanceObserver` does that matter here: entries go to every
24+
* observer already listening, and an observer that registers later with `buffered: true` is replayed
25+
* the ones it missed. That difference is the point of this test: our paint observer is listening
26+
* during the prerender, web-vitals' only registers after activation.
27+
*/
28+
class MockPerformanceObserver {
29+
public static supportedEntryTypes = ['paint'];
30+
31+
private _registration: ObserverRegistration;
32+
33+
public constructor(callback: (list: PerformanceObserverEntryList) => void) {
34+
this._registration = { callback, buffered: false };
35+
observers.push(this._registration);
36+
}
37+
38+
public observe(options?: { buffered?: boolean }): void {
39+
this._registration.buffered = !!options?.buffered;
40+
41+
if (this._registration.buffered && emittedEntries.length) {
42+
deliver(this._registration.callback, emittedEntries);
43+
}
44+
}
45+
46+
public disconnect(): void {
47+
// noop
48+
}
49+
}
50+
51+
function emitPaintEntries(entries: PerformanceEntry[]): void {
52+
emittedEntries.push(...entries);
53+
54+
for (const { callback } of observers) {
55+
deliver(callback, entries);
56+
}
57+
}
58+
59+
function flush(): Promise<void> {
60+
return new Promise(resolve => setTimeout(resolve, 0));
61+
}
62+
63+
describe('startTrackingWebVitals', () => {
64+
const realPerformance = globalThis.performance;
65+
66+
beforeEach(() => {
67+
getMainCarrier().__SENTRY__ = undefined;
68+
69+
const client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1 }));
70+
setCurrentClient(client);
71+
client.init();
72+
});
73+
74+
afterEach(() => {
75+
vi.unstubAllGlobals();
76+
vi.restoreAllMocks();
77+
});
78+
79+
it('rebases fp and fcp against activationStart for prerendered pages', async () => {
80+
// The document is still sitting in the prerender buffer, so `activationStart` reads 0. It only
81+
// becomes the real activation time once the user navigates to the page.
82+
const navigationEntry = { type: 'navigate', responseStart: 1, activationStart: 0 } as PerformanceNavigationTiming;
83+
const documentStub = { prerendering: true, readyState: 'complete', visibilityState: 'visible' };
84+
const pageListeners: Record<string, Array<() => void>> = {};
85+
86+
vi.stubGlobal('PerformanceObserver', MockPerformanceObserver);
87+
vi.stubGlobal('addEventListener', (type: string, listener: () => void) => {
88+
(pageListeners[type] ??= []).push(listener);
89+
});
90+
vi.stubGlobal('removeEventListener', vi.fn());
91+
vi.stubGlobal('document', documentStub);
92+
vi.stubGlobal('performance', {
93+
timeOrigin: realPerformance.timeOrigin,
94+
now: () => realPerformance.now(),
95+
getEntries: () => [],
96+
getEntriesByType: (type: string) => (type === 'navigation' ? [navigationEntry] : []),
97+
});
98+
99+
const cleanupWebVitals = startTrackingWebVitals({ trackCls: false, trackLcp: false, client: getClient()! });
100+
101+
// The page paints while it is still prerendering, 5s into the prerender navigation. Our paint
102+
// observer sees this right away; web-vitals' `onFCP` is still waiting on `prerenderingchange`.
103+
emitPaintEntries([
104+
{ entryType: 'paint', name: 'first-paint', duration: 0, startTime: 5012, toJSON: () => ({}) },
105+
{ entryType: 'paint', name: 'first-contentful-paint', duration: 0, startTime: 5018, toJSON: () => ({}) },
106+
] as PerformanceEntry[]);
107+
await flush();
108+
109+
// The user clicks the link and the page activates 5s into the prerender navigation, so the
110+
// paints they actually perceived happened 12ms and 18ms after the click.
111+
documentStub.prerendering = false;
112+
navigationEntry.activationStart = 5000;
113+
pageListeners.prerenderingchange?.forEach(listener => listener());
114+
await flush();
115+
116+
cleanupWebVitals();
117+
118+
const pageloadSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true });
119+
addWebVitalsToSpan(pageloadSpan, {
120+
recordClsOnPageloadSpan: true,
121+
recordLcpOnPageloadSpan: true,
122+
spanStreamingEnabled: true,
123+
});
124+
125+
expect(spanToJSON(pageloadSpan).attributes['browser.web_vital.fp.value']).toBe(12);
126+
expect(spanToJSON(pageloadSpan).attributes['browser.web_vital.fcp.value']).toBe(18);
127+
});
128+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { getClient, getMainCarrier, SentrySpan, setCurrentClient, spanToJSON } from '@sentry/core';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { addWebVitalsToSpan, startTrackingWebVitals } from '../../src/web-vitals/tracking';
4+
import { getDefaultClientOptions, TestClient } from '../utils/TestClient';
5+
6+
// FCP comes from web-vitals' `onFCP` and FP from our own paint observer, so both register their own
7+
// `PerformanceObserver`. Every constructed observer is collected here and paint entries are handed
8+
// to all of them, the way the browser would.
9+
const paintObserverCallbacks: Array<(list: PerformanceObserverEntryList) => void> = [];
10+
11+
class MockPerformanceObserver {
12+
public static supportedEntryTypes = ['paint'];
13+
14+
public constructor(callback: (list: PerformanceObserverEntryList) => void) {
15+
paintObserverCallbacks.push(callback);
16+
}
17+
18+
public observe(): void {
19+
// noop
20+
}
21+
22+
public disconnect(): void {
23+
// noop
24+
}
25+
}
26+
27+
function emitPaintEntries(entries: PerformanceEntry[]): Promise<void> {
28+
for (const callback of paintObserverCallbacks) {
29+
callback({ getEntries: () => entries } as PerformanceObserverEntryList);
30+
}
31+
32+
// Both observers hand off to their handlers in a microtask, so let the queue drain.
33+
return new Promise(resolve => setTimeout(resolve, 0));
34+
}
35+
36+
describe('startTrackingWebVitals', () => {
37+
const realPerformance = globalThis.performance;
38+
39+
beforeEach(() => {
40+
getMainCarrier().__SENTRY__ = undefined;
41+
42+
const client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1 }));
43+
setCurrentClient(client);
44+
client.init();
45+
});
46+
47+
afterEach(() => {
48+
vi.unstubAllGlobals();
49+
vi.restoreAllMocks();
50+
});
51+
52+
it('records fp and fcp on a regular (non-prerendered) page load', async () => {
53+
vi.stubGlobal('PerformanceObserver', MockPerformanceObserver);
54+
vi.stubGlobal('addEventListener', vi.fn());
55+
vi.stubGlobal('removeEventListener', vi.fn());
56+
vi.stubGlobal('document', {
57+
prerendering: false,
58+
readyState: 'complete',
59+
visibilityState: 'visible',
60+
});
61+
vi.stubGlobal('performance', {
62+
timeOrigin: realPerformance.timeOrigin,
63+
now: () => realPerformance.now(),
64+
getEntries: () => [],
65+
getEntriesByType: (type: string) =>
66+
type === 'navigation'
67+
? [{ type: 'navigate', responseStart: 1, activationStart: 0 } as PerformanceNavigationTiming]
68+
: [],
69+
});
70+
71+
const cleanupWebVitals = startTrackingWebVitals({ trackCls: false, trackLcp: false, client: getClient()! });
72+
73+
await emitPaintEntries([
74+
{ entryType: 'paint', name: 'first-paint', duration: 0, startTime: 12, toJSON: () => ({}) },
75+
{ entryType: 'paint', name: 'first-contentful-paint', duration: 0, startTime: 18, toJSON: () => ({}) },
76+
] as PerformanceEntry[]);
77+
78+
cleanupWebVitals();
79+
80+
const pageloadSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true });
81+
addWebVitalsToSpan(pageloadSpan, {
82+
recordClsOnPageloadSpan: true,
83+
recordLcpOnPageloadSpan: true,
84+
spanStreamingEnabled: true,
85+
});
86+
87+
expect(spanToJSON(pageloadSpan).attributes['browser.web_vital.fp.value']).toBe(12);
88+
expect(spanToJSON(pageloadSpan).attributes['browser.web_vital.fcp.value']).toBe(18);
89+
});
90+
});

0 commit comments

Comments
 (0)