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', () => {