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); + }); +});