From b2a1abca578ad88ac568a767ce82d89b43510c66 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 13 Aug 2026 12:14:01 -0400 Subject: [PATCH] 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); + }); +});