Skip to content
Draft
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
8 changes: 6 additions & 2 deletions packages/browser-utils/src/metrics/browserMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
}
}
});
Expand Down
96 changes: 79 additions & 17 deletions packages/browser-utils/test/browser/browserMetrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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());
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading