Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {
addTtfbInstrumentationHandler,
addLcpInstrumentationHandler,
addInpInstrumentationHandler,
addFcpInstrumentationHandler,
} from './instrumentation/performanceObserver';

export {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<Metric, 'entries'> & {
entries: PerformanceEventTiming[];
Expand Down Expand Up @@ -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 => {
Expand Down
34 changes: 23 additions & 11 deletions packages/browser-utils/src/web-vitals/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DEBUG_BUILD } from '../debug-build';
import { htmlTreeAsString } from '../htmlTreeAsString';
import {
addClsInstrumentationHandler,
addFcpInstrumentationHandler,
addLcpInstrumentationHandler,
addPerformanceInstrumentationHandler,
addTtfbInstrumentationHandler,
Expand Down Expand Up @@ -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?.();
};
Expand Down Expand Up @@ -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' };
});
}

Copy link
Copy Markdown

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 fcp from web-vitals onFCP waits for page activation before reporting. On a Speculation Rules prerender the pageload span often finishes during the hidden prerender (load plus idle timeout), so finalizeWebVitals removes the handler before onFCP runs and First Contentful Paint never reaches the span.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b2a1abc. Configure here.


/**
* 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' };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FP corrected before activation starts

Medium Severity

_trackFp rebases First Paint with getActivationStart() when the paint entry arrives. During prerender that value is still 0, and unlike onFCP this path does not wait for activation, so pages that paint in the prerender buffer keep a navigation-start timestamp instead of a user-perceived 0.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b2a1abc. Configure here.

}
}
});
Expand Down
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);
});
});
90 changes: 90 additions & 0 deletions packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts
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);
});
});
Loading