From 21daec9571f7c25293cbbfdd2398d139d87b1f55 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 4 Aug 2026 16:41:42 -0400 Subject: [PATCH] fix(browser): Rebase FP/FCP against activationStart for prerendered pages `_trackFpFcp` stored the raw paint `entry.startTime` as the fp/fcp measurement. For a page prerendered via the Speculation Rules API and later activated, those timestamps are relative to the prerender navigation start, so the reported value is inflated by the full time the document sat dormant in the prerender buffer. Unlike LCP and TTFB (which go through the vendored web-vitals library and are already corrected), FP/FCP had no correction anywhere. Rebase against `activationStart` and clamp at 0, matching the vendored library's `Math.max(entry.startTime - getActivationStart(), 0)`. `activationStart` is 0 for regular loads, so non-prerendered pages are unchanged. --- .../src/metrics/browserMetrics.ts | 8 +- .../test/browser/browserMetrics.test.ts | 96 +++++++++++++++---- 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index f1e28a7d50b5..7b5ab4a65186 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -280,11 +280,15 @@ function _trackFpFcp(): () => void { for (const entry of entries) { // Only report if the page wasn't hidden prior to the web vital. const shouldRecord = entry.startTime < firstHidden.firstHiddenTime; + // For prerendered pages, paint timestamps are relative to navigation start, but the vital + // should be relative to activation. Rebase against `activationStart` (0 for regular loads), + // matching how the vendored web-vitals library reports LCP/FCP/TTFB. + const value = Math.max(entry.startTime - getActivationStart(), 0); if (entry.name === 'first-paint' && shouldRecord) { - _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; + _measurements['fp'] = { value, unit: 'millisecond' }; } if (entry.name === 'first-contentful-paint' && shouldRecord) { - _measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' }; + _measurements['fcp'] = { value, unit: 'millisecond' }; } } }); diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts index fd1ae31d178a..18812751fb7d 100644 --- a/packages/browser-utils/test/browser/browserMetrics.test.ts +++ b/packages/browser-utils/test/browser/browserMetrics.test.ts @@ -49,6 +49,26 @@ function mockPerformanceResourceTiming( } describe('addWebVitalsToSpan', () => { + // `addPerformanceInstrumentationHandler` only constructs the underlying PerformanceObserver once + // (module-level `instrumented` guard), so the paint observer callback is captured here at describe + // scope and reused across tests rather than per-test. + let performanceObserverCallback: ((list: PerformanceObserverEntryList) => void) | undefined; + class MockPerformanceObserver { + public static supportedEntryTypes = ['paint']; + + public constructor(callback: (list: PerformanceObserverEntryList) => void) { + performanceObserverCallback = callback; + } + + public observe(): void { + // noop + } + + public disconnect(): void { + // noop + } + } + beforeEach(() => { vi.restoreAllMocks(); getCurrentScope().clear(); @@ -69,23 +89,6 @@ describe('addWebVitalsToSpan', () => { }); it('clears pending measurements when the performance API is unavailable', async () => { - let performanceObserverCallback: ((list: PerformanceObserverEntryList) => void) | undefined; - class MockPerformanceObserver { - public static supportedEntryTypes = ['paint']; - - public constructor(callback: (list: PerformanceObserverEntryList) => void) { - performanceObserverCallback = callback; - } - - public observe(): void { - // noop - } - - public disconnect(): void { - // noop - } - } - vi.stubGlobal('PerformanceObserver', MockPerformanceObserver); vi.stubGlobal('addEventListener', vi.fn()); vi.stubGlobal('removeEventListener', vi.fn()); @@ -142,6 +145,65 @@ describe('addWebVitalsToSpan', () => { expect(spanToJSON(nextPageloadSpan).data['browser.web_vital.fp.value']).toBeUndefined(); expect(spanToJSON(nextPageloadSpan).data['browser.web_vital.fcp.value']).toBeUndefined(); }); + + it('rebases fp/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 page was prerendered and activated 5ms after navigation start. + const realPerformance = WINDOW.performance; + vi.stubGlobal('performance', { + timeOrigin: realPerformance.timeOrigin, + now: () => realPerformance.now(), + getEntries: () => [], + getEntriesByType: (type: string) => + type === 'navigation' ? [{ responseStart: 100, activationStart: 5 } as PerformanceNavigationTiming] : [], + }); + + const cleanupWebVitals = startTrackingWebVitals({ + trackCls: true, + trackLcp: true, + client: getClient()!, + }); + + performanceObserverCallback?.({ + getEntries: () => [ + { + entryType: 'paint', + name: 'first-paint', + duration: 0, + startTime: 12, + toJSON: () => ({}), + }, + { + entryType: 'paint', + name: 'first-contentful-paint', + duration: 0, + startTime: 18, + toJSON: () => ({}), + }, + ], + } as PerformanceObserverEntryList); + await Promise.resolve(); + cleanupWebVitals(); + + const pageloadSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); + addWebVitalsToSpan(pageloadSpan, { + recordClsOnPageloadSpan: true, + recordLcpOnPageloadSpan: true, + spanStreamingEnabled: true, + }); + + // Raw startTimes are 12 and 18, activationStart is 5, so values are clamped to 7 and 13. + expect(spanToJSON(pageloadSpan).data['browser.web_vital.fp.value']).toBe(7); + expect(spanToJSON(pageloadSpan).data['browser.web_vital.fcp.value']).toBe(13); + }); }); describe('_addResourceSpans', () => {