Skip to content
Merged
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
51 changes: 41 additions & 10 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,19 +92,28 @@ 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, and mirrors the one thing
* `onFCP` does inline: skip the vital if the page was hidden before it. The raw `startTime` is
* stored as-is; rebasing it against `activationStart` happens in `_rebaseFpAgainstActivationStart`
* when the span ends, because this observer can run before the page is activated.
*/
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) {
if (entry.name === 'first-paint' && entry.startTime < firstHidden.firstHiddenTime) {
_measurements['fp'] = { value: entry.startTime, unit: 'millisecond' };
}
if (entry.name === 'first-contentful-paint' && shouldRecord) {
_measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' };
}
}
});
}
Expand Down Expand Up @@ -153,6 +165,7 @@ export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOption
// Measurements are only available for pageload transactions
if (spanToJSON(span).attributes[SENTRY_OP] === 'pageload') {
_addTtfbRequestTimeToMeasurements(_measurements);
_rebaseFpAgainstActivationStart(_measurements);

if (spanStreamingEnabled) {
const setAttr = (shortWebVitalName: string, value: number, customAttrName?: string) => {
Expand Down Expand Up @@ -254,6 +267,24 @@ function _setWebVitalAttributes(span: Span, options: AddWebVitalsToSpanOptions):
}
}

/**
* Rebases First Paint against `activationStart`, so a prerendered page reports time-to-paint from
* the moment the user activated it rather than from the (much earlier) prerender navigation start.
*
* The vitals that come from web-vitals do this themselves, when they compute their value: they only
* start observing once the page is activated, so `activationStart` is known by then. FP has no
* web-vitals equivalent and is read off a `buffered` paint observer registered at SDK init, which
* on a prerendered page runs while `document.prerendering` is still true and `activationStart` is
* still 0. Correcting there would be a no-op, so it happens here, at span end, by which point the
* activation time is known.
*/
function _rebaseFpAgainstActivationStart(measurements: Measurements): void {
const fp = measurements['fp'];
if (fp) {
fp.value = Math.max(fp.value - getActivationStart(), 0);
}
}

/**
* Add ttfb request time information to measurements.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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.

interface ObserverRegistration {
callback: (list: PerformanceObserverEntryList) => void;
buffered: boolean;
}

const observers: ObserverRegistration[] = [];
const emittedEntries: PerformanceEntry[] = [];

function deliver(callback: (list: PerformanceObserverEntryList) => void, entries: PerformanceEntry[]): void {
callback({ getEntries: () => entries } as PerformanceObserverEntryList);
}

/**
* Models the two things the real `PerformanceObserver` does that matter here: entries go to every
* observer already listening, and an observer that registers later with `buffered: true` is replayed
* the ones it missed. That difference is the point of this test: our paint observer is listening
* during the prerender, web-vitals' only registers after activation.
*/
class MockPerformanceObserver {
public static supportedEntryTypes = ['paint'];

private _registration: ObserverRegistration;

public constructor(callback: (list: PerformanceObserverEntryList) => void) {
this._registration = { callback, buffered: false };
observers.push(this._registration);
}

public observe(options?: { buffered?: boolean }): void {
this._registration.buffered = !!options?.buffered;

if (this._registration.buffered && emittedEntries.length) {
deliver(this._registration.callback, emittedEntries);
}
}

public disconnect(): void {
// noop
}
}

function emitPaintEntries(entries: PerformanceEntry[]): void {
emittedEntries.push(...entries);

for (const { callback } of observers) {
deliver(callback, entries);
}
}

function flush(): Promise<void> {
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 () => {
// The document is still sitting in the prerender buffer, so `activationStart` reads 0. It only
// becomes the real activation time once the user navigates to the page.
const navigationEntry = { type: 'navigate', responseStart: 1, activationStart: 0 } as PerformanceNavigationTiming;
const documentStub = { prerendering: true, readyState: 'complete', visibilityState: 'visible' };
const pageListeners: Record<string, Array<() => void>> = {};

vi.stubGlobal('PerformanceObserver', MockPerformanceObserver);
vi.stubGlobal('addEventListener', (type: string, listener: () => void) => {
(pageListeners[type] ??= []).push(listener);
});
vi.stubGlobal('removeEventListener', vi.fn());
vi.stubGlobal('document', documentStub);
vi.stubGlobal('performance', {
timeOrigin: realPerformance.timeOrigin,
now: () => realPerformance.now(),
getEntries: () => [],
getEntriesByType: (type: string) => (type === 'navigation' ? [navigationEntry] : []),
});

const cleanupWebVitals = startTrackingWebVitals({ trackCls: false, trackLcp: false, client: getClient()! });

// The page paints while it is still prerendering, 5s into the prerender navigation. Our paint
// observer sees this right away; web-vitals' `onFCP` is still waiting on `prerenderingchange`.
emitPaintEntries([
{ entryType: 'paint', name: 'first-paint', duration: 0, startTime: 5012, toJSON: () => ({}) },
{ entryType: 'paint', name: 'first-contentful-paint', duration: 0, startTime: 5018, toJSON: () => ({}) },
] as PerformanceEntry[]);
await flush();

// The user clicks the link and the page activates 5s into the prerender navigation, so the
// paints they actually perceived happened 12ms and 18ms after the click.
documentStub.prerendering = false;
navigationEntry.activationStart = 5000;
pageListeners.prerenderingchange?.forEach(listener => listener());
await flush();

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