From b2a1abca578ad88ac568a767ce82d89b43510c66 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 13 Aug 2026 12:14:01 -0400 Subject: [PATCH 1/2] fix(browser): Source FCP from web-vitals `onFCP` and rebase FP against `activationStart` FP and FCP were the only vitals still read straight off the `paint` observer, storing the raw `entry.startTime`. On a page prerendered via the Speculation Rules API, paint timestamps are relative to the prerender navigation start, so both values carried the entire time the document sat dormant in the prerender buffer. FCP now goes through web-vitals' `onFCP` like CLS/LCP/TTFB/INP already do, which applies the `activationStart` correction itself and defers registration until activation. web-vitals has no `onFP`, so FP stays on the paint observer and applies the same correction inline. `onFCP` costs nothing in bundle size: `onCLS` already imports it, and that import is static. --- packages/browser-utils/src/index.ts | 1 + .../instrumentation/performanceObserver.ts | 23 ++++- .../browser-utils/src/web-vitals/tracking.ts | 34 ++++--- .../tracking-fp-fcp-prerender.test.ts | 92 +++++++++++++++++++ .../test/web-vitals/tracking-fp-fcp.test.ts | 90 ++++++++++++++++++ 5 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 packages/browser-utils/test/web-vitals/tracking-fp-fcp-prerender.test.ts create mode 100644 packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 3105a48181b9..a32dba04ea59 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -4,6 +4,7 @@ export { addTtfbInstrumentationHandler, addLcpInstrumentationHandler, addInpInstrumentationHandler, + addFcpInstrumentationHandler, } from './instrumentation/performanceObserver'; export { diff --git a/packages/browser-utils/src/instrumentation/performanceObserver.ts b/packages/browser-utils/src/instrumentation/performanceObserver.ts index 463908bca008..9b74e59d3156 100644 --- a/packages/browser-utils/src/instrumentation/performanceObserver.ts +++ b/packages/browser-utils/src/instrumentation/performanceObserver.ts @@ -1,5 +1,5 @@ import { debug, getFunctionName } from '@sentry/core'; -import { onCLS, onINP, onLCP, onTTFB } from 'web-vitals'; +import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals'; import { DEBUG_BUILD } from '../debug-build'; type InstrumentHandlerTypePerformanceObserver = @@ -12,7 +12,7 @@ type InstrumentHandlerTypePerformanceObserver = // fist-input is still needed for INP | 'first-input'; -type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'ttfb' | 'inp'; +type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'ttfb' | 'inp' | 'fcp'; // We provide this here manually instead of relying on a global, as this is not available in non-browser environements // And we do not want to expose such types @@ -121,6 +121,7 @@ let _previousCls: Metric | undefined; let _previousLcp: Metric | undefined; let _previousTtfb: Metric | undefined; let _previousInp: Metric | undefined; +let _previousFcp: Metric | undefined; /** * Add a callback that will be triggered when a CLS metric is available. @@ -157,6 +158,13 @@ export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb); } +/** + * Add a callback that will be triggered when a FCP metric is available. + */ +export function addFcpInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback { + return addMetricObserver('fcp', callback, instrumentFcp, _previousFcp); +} + export type InstrumentationHandlerCallback = (data: { metric: Omit & { entries: PerformanceEventTiming[]; @@ -276,6 +284,17 @@ function instrumentTtfb(): StopListening { ); } +function instrumentFcp(): StopListening { + return onFCP( + withoutBfcache(metric => { + triggerHandlers('fcp', { + metric, + }); + _previousFcp = metric; + }), + ); +} + function instrumentInp(): StopListening { return onINP( withoutBfcache(metric => { diff --git a/packages/browser-utils/src/web-vitals/tracking.ts b/packages/browser-utils/src/web-vitals/tracking.ts index 607eb3830809..223c1b2db43f 100644 --- a/packages/browser-utils/src/web-vitals/tracking.ts +++ b/packages/browser-utils/src/web-vitals/tracking.ts @@ -5,6 +5,7 @@ import { DEBUG_BUILD } from '../debug-build'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { addClsInstrumentationHandler, + addFcpInstrumentationHandler, addLcpInstrumentationHandler, addPerformanceInstrumentationHandler, addTtfbInstrumentationHandler, @@ -35,11 +36,13 @@ export function startTrackingWebVitals({ trackCls, trackLcp }: StartTrackingWebV const lcpCleanupCallback = trackLcp ? _trackLCP() : undefined; const clsCleanupCallback = trackCls ? _trackCLS() : undefined; const ttfbCleanupCallback = _trackTtfb(); - const fpFcpCleanupCallback = _trackFpFcp(); + const fcpCleanupCallback = _trackFcp(); + const fpCleanupCallback = _trackFp(); return (): void => { ttfbCleanupCallback(); - fpFcpCleanupCallback(); + fcpCleanupCallback(); + fpCleanupCallback(); lcpCleanupCallback?.(); clsCleanupCallback?.(); }; @@ -89,18 +92,27 @@ function _trackTtfb(): () => void { }); } -/** Starts tracking First Paint and First Contentful Paint on the current page. */ -function _trackFpFcp(): () => void { +/** Starts tracking the First Contentful Paint on the current page. */ +function _trackFcp(): () => void { + return addFcpInstrumentationHandler(({ metric }) => { + _measurements['fcp'] = { value: metric.value, unit: 'millisecond' }; + }); +} + +/** + * Starts tracking First Paint on the current page. + * + * web-vitals has no `onFP`, so this stays on the raw paint observer. It mirrors what `onFCP` does + * for its own entry: skip the vital if the page was hidden before it, and rebase against + * `activationStart` so prerendered pages report time-to-paint from activation rather than from the + * (much earlier) prerender navigation start. + */ +function _trackFp(): () => void { return addPerformanceInstrumentationHandler('paint', ({ entries }) => { const firstHidden = getVisibilityWatcher(); for (const entry of entries) { - // Only report if the page wasn't hidden prior to the web vital. - const shouldRecord = entry.startTime < firstHidden.firstHiddenTime; - if (entry.name === 'first-paint' && shouldRecord) { - _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; - } - if (entry.name === 'first-contentful-paint' && shouldRecord) { - _measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' }; + if (entry.name === 'first-paint' && entry.startTime < firstHidden.firstHiddenTime) { + _measurements['fp'] = { value: Math.max(entry.startTime - getActivationStart(), 0), unit: 'millisecond' }; } } }); diff --git a/packages/browser-utils/test/web-vitals/tracking-fp-fcp-prerender.test.ts b/packages/browser-utils/test/web-vitals/tracking-fp-fcp-prerender.test.ts new file mode 100644 index 000000000000..aa1428059b1d --- /dev/null +++ b/packages/browser-utils/test/web-vitals/tracking-fp-fcp-prerender.test.ts @@ -0,0 +1,92 @@ +import { getClient, getMainCarrier, SentrySpan, setCurrentClient, spanToJSON } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { addWebVitalsToSpan, startTrackingWebVitals } from '../../src/web-vitals/tracking'; +import { getDefaultClientOptions, TestClient } from '../utils/TestClient'; + +// Lives in its own file rather than alongside the regular-page-load case: the paint observers, the +// `instrumented` registry and the visibility watcher are all module-level singletons that can only +// be armed once, so a second scenario in the same file would reuse the first one's state. +const paintObserverCallbacks: Array<(list: PerformanceObserverEntryList) => void> = []; + +class MockPerformanceObserver { + public static supportedEntryTypes = ['paint']; + + public constructor(callback: (list: PerformanceObserverEntryList) => void) { + paintObserverCallbacks.push(callback); + } + + public observe(): void { + // noop + } + + public disconnect(): void { + // noop + } +} + +function emitPaintEntries(entries: PerformanceEntry[]): Promise { + for (const callback of paintObserverCallbacks) { + callback({ getEntries: () => entries } as PerformanceObserverEntryList); + } + + return new Promise(resolve => setTimeout(resolve, 0)); +} + +describe('startTrackingWebVitals', () => { + const realPerformance = globalThis.performance; + + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + + const client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('rebases fp and fcp against activationStart for prerendered pages', async () => { + vi.stubGlobal('PerformanceObserver', MockPerformanceObserver); + vi.stubGlobal('addEventListener', vi.fn()); + vi.stubGlobal('removeEventListener', vi.fn()); + vi.stubGlobal('document', { + prerendering: false, + readyState: 'complete', + visibilityState: 'visible', + }); + + // The document sat in the prerender buffer for 5s before the user navigated to it, so paint + // timestamps are 5s into the prerender navigation while the user only perceived ~12/18ms. + vi.stubGlobal('performance', { + timeOrigin: realPerformance.timeOrigin, + now: () => realPerformance.now(), + getEntries: () => [], + getEntriesByType: (type: string) => + type === 'navigation' + ? [{ type: 'navigate', responseStart: 1, activationStart: 5000 } as PerformanceNavigationTiming] + : [], + }); + + const cleanupWebVitals = startTrackingWebVitals({ trackCls: false, trackLcp: false, client: getClient()! }); + + await emitPaintEntries([ + { entryType: 'paint', name: 'first-paint', duration: 0, startTime: 5012, toJSON: () => ({}) }, + { entryType: 'paint', name: 'first-contentful-paint', duration: 0, startTime: 5018, toJSON: () => ({}) }, + ] as PerformanceEntry[]); + + cleanupWebVitals(); + + const pageloadSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); + addWebVitalsToSpan(pageloadSpan, { + recordClsOnPageloadSpan: true, + recordLcpOnPageloadSpan: true, + spanStreamingEnabled: true, + }); + + expect(spanToJSON(pageloadSpan).attributes['browser.web_vital.fp.value']).toBe(12); + expect(spanToJSON(pageloadSpan).attributes['browser.web_vital.fcp.value']).toBe(18); + }); +}); diff --git a/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts b/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts new file mode 100644 index 000000000000..aa0114b87fec --- /dev/null +++ b/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts @@ -0,0 +1,90 @@ +import { getClient, getMainCarrier, SentrySpan, setCurrentClient, spanToJSON } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { addWebVitalsToSpan, startTrackingWebVitals } from '../../src/web-vitals/tracking'; +import { getDefaultClientOptions, TestClient } from '../utils/TestClient'; + +// FCP comes from web-vitals' `onFCP` and FP from our own paint observer, so both register their own +// `PerformanceObserver`. Every constructed observer is collected here and paint entries are handed +// to all of them, the way the browser would. +const paintObserverCallbacks: Array<(list: PerformanceObserverEntryList) => void> = []; + +class MockPerformanceObserver { + public static supportedEntryTypes = ['paint']; + + public constructor(callback: (list: PerformanceObserverEntryList) => void) { + paintObserverCallbacks.push(callback); + } + + public observe(): void { + // noop + } + + public disconnect(): void { + // noop + } +} + +function emitPaintEntries(entries: PerformanceEntry[]): Promise { + for (const callback of paintObserverCallbacks) { + callback({ getEntries: () => entries } as PerformanceObserverEntryList); + } + + // Both observers hand off to their handlers in a microtask, so let the queue drain. + return new Promise(resolve => setTimeout(resolve, 0)); +} + +describe('startTrackingWebVitals', () => { + const realPerformance = globalThis.performance; + + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + + const client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('records fp and fcp on a regular (non-prerendered) page load', async () => { + vi.stubGlobal('PerformanceObserver', MockPerformanceObserver); + vi.stubGlobal('addEventListener', vi.fn()); + vi.stubGlobal('removeEventListener', vi.fn()); + vi.stubGlobal('document', { + prerendering: false, + readyState: 'complete', + visibilityState: 'visible', + }); + vi.stubGlobal('performance', { + timeOrigin: realPerformance.timeOrigin, + now: () => realPerformance.now(), + getEntries: () => [], + getEntriesByType: (type: string) => + type === 'navigation' + ? [{ type: 'navigate', responseStart: 1, activationStart: 0 } as PerformanceNavigationTiming] + : [], + }); + + const cleanupWebVitals = startTrackingWebVitals({ trackCls: false, trackLcp: false, client: getClient()! }); + + await emitPaintEntries([ + { entryType: 'paint', name: 'first-paint', duration: 0, startTime: 12, toJSON: () => ({}) }, + { entryType: 'paint', name: 'first-contentful-paint', duration: 0, startTime: 18, toJSON: () => ({}) }, + ] as PerformanceEntry[]); + + cleanupWebVitals(); + + const pageloadSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); + addWebVitalsToSpan(pageloadSpan, { + recordClsOnPageloadSpan: true, + recordLcpOnPageloadSpan: true, + spanStreamingEnabled: true, + }); + + expect(spanToJSON(pageloadSpan).attributes['browser.web_vital.fp.value']).toBe(12); + expect(spanToJSON(pageloadSpan).attributes['browser.web_vital.fcp.value']).toBe(18); + }); +}); From af93edd21af2d149dbd5f905214271cc0b53fd02 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 28 Aug 2026 12:07:00 -0400 Subject: [PATCH 2/2] fix(browser): Rebase `fp` against `activationStart` when the span ends The correction was applied when the paint entry arrived, but on a prerendered page that happens while the document is still in the prerender buffer, before the user has activated it. `activationStart` is 0 at that point, so subtracting it did nothing and FP kept the prerender-relative timestamp. The web-vitals-sourced metrics avoid this because they only start observing after activation. FP comes off a buffered paint observer we register at init, so it can't. Store the raw `startTime` instead and do the subtraction in `addWebVitalsToSpan`, by which point activation has happened. Also rewrites the prerender test, which stubbed a non-zero `activationStart` before emitting any entries and so never covered the prerender window. --- .../browser-utils/src/web-vitals/tracking.ts | 29 +++++-- .../tracking-fp-fcp-prerender.test.ts | 78 ++++++++++++++----- 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/packages/browser-utils/src/web-vitals/tracking.ts b/packages/browser-utils/src/web-vitals/tracking.ts index 223c1b2db43f..4e1af199884c 100644 --- a/packages/browser-utils/src/web-vitals/tracking.ts +++ b/packages/browser-utils/src/web-vitals/tracking.ts @@ -102,17 +102,17 @@ function _trackFcp(): () => void { /** * Starts tracking First Paint on the current page. * - * web-vitals has no `onFP`, so this stays on the raw paint observer. It mirrors what `onFCP` does - * for its own entry: skip the vital if the page was hidden before it, and rebase against - * `activationStart` so prerendered pages report time-to-paint from activation rather than from the - * (much earlier) prerender navigation start. + * web-vitals has no `onFP`, so this stays on the raw paint observer, and mirrors the one thing + * `onFCP` does inline: skip the vital if the page was hidden before it. The raw `startTime` is + * stored as-is; rebasing it against `activationStart` happens in `_rebaseFpAgainstActivationStart` + * when the span ends, because this observer can run before the page is activated. */ function _trackFp(): () => void { return addPerformanceInstrumentationHandler('paint', ({ entries }) => { const firstHidden = getVisibilityWatcher(); for (const entry of entries) { if (entry.name === 'first-paint' && entry.startTime < firstHidden.firstHiddenTime) { - _measurements['fp'] = { value: Math.max(entry.startTime - getActivationStart(), 0), unit: 'millisecond' }; + _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; } } }); @@ -165,6 +165,7 @@ export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOption // Measurements are only available for pageload transactions if (spanToJSON(span).attributes[SENTRY_OP] === 'pageload') { _addTtfbRequestTimeToMeasurements(_measurements); + _rebaseFpAgainstActivationStart(_measurements); if (spanStreamingEnabled) { const setAttr = (shortWebVitalName: string, value: number, customAttrName?: string) => { @@ -266,6 +267,24 @@ function _setWebVitalAttributes(span: Span, options: AddWebVitalsToSpanOptions): } } +/** + * Rebases First Paint against `activationStart`, so a prerendered page reports time-to-paint from + * the moment the user activated it rather than from the (much earlier) prerender navigation start. + * + * The vitals that come from web-vitals do this themselves, when they compute their value: they only + * start observing once the page is activated, so `activationStart` is known by then. FP has no + * web-vitals equivalent and is read off a `buffered` paint observer registered at SDK init, which + * on a prerendered page runs while `document.prerendering` is still true and `activationStart` is + * still 0. Correcting there would be a no-op, so it happens here, at span end, by which point the + * activation time is known. + */ +function _rebaseFpAgainstActivationStart(measurements: Measurements): void { + const fp = measurements['fp']; + if (fp) { + fp.value = Math.max(fp.value - getActivationStart(), 0); + } +} + /** * Add ttfb request time information to measurements. * diff --git a/packages/browser-utils/test/web-vitals/tracking-fp-fcp-prerender.test.ts b/packages/browser-utils/test/web-vitals/tracking-fp-fcp-prerender.test.ts index aa1428059b1d..f3dcddc02997 100644 --- a/packages/browser-utils/test/web-vitals/tracking-fp-fcp-prerender.test.ts +++ b/packages/browser-utils/test/web-vitals/tracking-fp-fcp-prerender.test.ts @@ -6,17 +6,41 @@ import { getDefaultClientOptions, TestClient } from '../utils/TestClient'; // Lives in its own file rather than alongside the regular-page-load case: the paint observers, the // `instrumented` registry and the visibility watcher are all module-level singletons that can only // be armed once, so a second scenario in the same file would reuse the first one's state. -const paintObserverCallbacks: Array<(list: PerformanceObserverEntryList) => void> = []; +interface ObserverRegistration { + callback: (list: PerformanceObserverEntryList) => void; + buffered: boolean; +} + +const observers: ObserverRegistration[] = []; +const emittedEntries: PerformanceEntry[] = []; + +function deliver(callback: (list: PerformanceObserverEntryList) => void, entries: PerformanceEntry[]): void { + callback({ getEntries: () => entries } as PerformanceObserverEntryList); +} + +/** + * Models the two things the real `PerformanceObserver` does that matter here: entries go to every + * observer already listening, and an observer that registers later with `buffered: true` is replayed + * the ones it missed. That difference is the point of this test: our paint observer is listening + * during the prerender, web-vitals' only registers after activation. + */ class MockPerformanceObserver { public static supportedEntryTypes = ['paint']; + private _registration: ObserverRegistration; + public constructor(callback: (list: PerformanceObserverEntryList) => void) { - paintObserverCallbacks.push(callback); + this._registration = { callback, buffered: false }; + observers.push(this._registration); } - public observe(): void { - // noop + public observe(options?: { buffered?: boolean }): void { + this._registration.buffered = !!options?.buffered; + + if (this._registration.buffered && emittedEntries.length) { + deliver(this._registration.callback, emittedEntries); + } } public disconnect(): void { @@ -24,11 +48,15 @@ class MockPerformanceObserver { } } -function emitPaintEntries(entries: PerformanceEntry[]): Promise { - for (const callback of paintObserverCallbacks) { - callback({ getEntries: () => entries } as PerformanceObserverEntryList); +function emitPaintEntries(entries: PerformanceEntry[]): void { + emittedEntries.push(...entries); + + for (const { callback } of observers) { + deliver(callback, entries); } +} +function flush(): Promise { return new Promise(resolve => setTimeout(resolve, 0)); } @@ -49,33 +77,41 @@ describe('startTrackingWebVitals', () => { }); it('rebases fp and fcp against activationStart for prerendered pages', async () => { + // The document is still sitting in the prerender buffer, so `activationStart` reads 0. It only + // becomes the real activation time once the user navigates to the page. + const navigationEntry = { type: 'navigate', responseStart: 1, activationStart: 0 } as PerformanceNavigationTiming; + const documentStub = { prerendering: true, readyState: 'complete', visibilityState: 'visible' }; + const pageListeners: Record void>> = {}; + vi.stubGlobal('PerformanceObserver', MockPerformanceObserver); - vi.stubGlobal('addEventListener', vi.fn()); - vi.stubGlobal('removeEventListener', vi.fn()); - vi.stubGlobal('document', { - prerendering: false, - readyState: 'complete', - visibilityState: 'visible', + vi.stubGlobal('addEventListener', (type: string, listener: () => void) => { + (pageListeners[type] ??= []).push(listener); }); - - // The document sat in the prerender buffer for 5s before the user navigated to it, so paint - // timestamps are 5s into the prerender navigation while the user only perceived ~12/18ms. + vi.stubGlobal('removeEventListener', vi.fn()); + vi.stubGlobal('document', documentStub); vi.stubGlobal('performance', { timeOrigin: realPerformance.timeOrigin, now: () => realPerformance.now(), getEntries: () => [], - getEntriesByType: (type: string) => - type === 'navigation' - ? [{ type: 'navigate', responseStart: 1, activationStart: 5000 } as PerformanceNavigationTiming] - : [], + getEntriesByType: (type: string) => (type === 'navigation' ? [navigationEntry] : []), }); const cleanupWebVitals = startTrackingWebVitals({ trackCls: false, trackLcp: false, client: getClient()! }); - await emitPaintEntries([ + // The page paints while it is still prerendering, 5s into the prerender navigation. Our paint + // observer sees this right away; web-vitals' `onFCP` is still waiting on `prerenderingchange`. + emitPaintEntries([ { entryType: 'paint', name: 'first-paint', duration: 0, startTime: 5012, toJSON: () => ({}) }, { entryType: 'paint', name: 'first-contentful-paint', duration: 0, startTime: 5018, toJSON: () => ({}) }, ] as PerformanceEntry[]); + await flush(); + + // The user clicks the link and the page activates 5s into the prerender navigation, so the + // paints they actually perceived happened 12ms and 18ms after the click. + documentStub.prerendering = false; + navigationEntry.activationStart = 5000; + pageListeners.prerenderingchange?.forEach(listener => listener()); + await flush(); cleanupWebVitals();