-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(browser): Source FCP from web-vitals onFCP and rebase FP against activationStart
#23032
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FP corrected before activation startsMedium Severity
Reviewed by Cursor Bugbot for commit b2a1abc. Configure here. |
||
| } | ||
| } | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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); | ||
| }); | ||
| }); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FCP dropped before prerender activation
High Severity
Sourcing
fcpfrom web-vitalsonFCPwaits for page activation before reporting. On a Speculation Rules prerender the pageload span often finishes during the hidden prerender (load plus idle timeout), sofinalizeWebVitalsremoves the handler beforeonFCPruns and First Contentful Paint never reaches the span.Additional Locations (1)
packages/browser-utils/src/instrumentation/performanceObserver.ts#L286-L296Reviewed by Cursor Bugbot for commit b2a1abc. Configure here.