Skip to content

ref(browser): Unify CLS/LCP recording and add recording event attribute #16866

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

Merged
merged 9 commits into from
Jul 11, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ sentryTest('captures a "GOOD" CLS vital with its source as a standalone span', a
'sentry.exclusive_time': 0,
'sentry.op': 'ui.webvital.cls',
'sentry.origin': 'auto.http.browser.cls',
'sentry.report_event': 'pagehide',
transaction: expect.stringContaining('index.html'),
'user_agent.original': expect.stringContaining('Chrome'),
'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/),
Expand Down Expand Up @@ -134,6 +135,7 @@ sentryTest('captures a "MEH" CLS vital with its source as a standalone span', as
'sentry.exclusive_time': 0,
'sentry.op': 'ui.webvital.cls',
'sentry.origin': 'auto.http.browser.cls',
'sentry.report_event': 'pagehide',
transaction: expect.stringContaining('index.html'),
'user_agent.original': expect.stringContaining('Chrome'),
'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/),
Expand Down Expand Up @@ -201,6 +203,7 @@ sentryTest('captures a "POOR" CLS vital with its source as a standalone span.',
'sentry.exclusive_time': 0,
'sentry.op': 'ui.webvital.cls',
'sentry.origin': 'auto.http.browser.cls',
'sentry.report_event': 'pagehide',
transaction: expect.stringContaining('index.html'),
'user_agent.original': expect.stringContaining('Chrome'),
'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/),
Expand Down Expand Up @@ -269,6 +272,7 @@ sentryTest(
'sentry.exclusive_time': 0,
'sentry.op': 'ui.webvital.cls',
'sentry.origin': 'auto.http.browser.cls',
'sentry.report_event': 'pagehide',
transaction: expect.stringContaining('index.html'),
'user_agent.original': expect.stringContaining('Chrome'),
'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/),
Expand Down Expand Up @@ -342,6 +346,8 @@ sentryTest(
// Ensure the CLS span is connected to the pageload span and trace
expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadSpanId);
expect(spanEnvelopeItem.trace_id).toEqual(pageloadTraceId);

expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('pagehide');
},
);

Expand Down Expand Up @@ -374,6 +380,8 @@ sentryTest('sends CLS of the initial page when soft-navigating to a new page', a
expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.15);
expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadEventData.contexts?.trace?.span_id);
expect(spanEnvelopeItem.trace_id).toEqual(pageloadTraceId);

expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation');
});

sentryTest("doesn't send further CLS after the first navigation", async ({ getLocalTestUrl, page }) => {
Expand All @@ -398,6 +406,7 @@ sentryTest("doesn't send further CLS after the first navigation", async ({ getLo
const spanEnvelope = (await spanEnvelopePromise)[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];
expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0);
expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation');

getMultipleSentryEnvelopeRequests<SpanEnvelope>(page, 1, { envelopeType: 'span' }, () => {
throw new Error('Unexpected span - This should not happen!');
Expand Down Expand Up @@ -442,6 +451,7 @@ sentryTest("doesn't send further CLS after the first page hide", async ({ getLoc
const spanEnvelope = (await spanEnvelopePromise)[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];
expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0);
expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('pagehide');

getMultipleSentryEnvelopeRequests<SpanEnvelope>(page, 1, { envelopeType: 'span' }, () => {
throw new Error('Unexpected span - This should not happen!');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl,
'sentry.exclusive_time': 0,
'sentry.op': 'ui.webvital.lcp',
'sentry.origin': 'auto.http.browser.lcp',
'sentry.report_event': 'pagehide',
transaction: expect.stringContaining('index.html'),
'user_agent.original': expect.stringContaining('Chrome'),
'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/),
Expand Down Expand Up @@ -181,6 +182,7 @@ sentryTest('sends LCP of the initial page when soft-navigating to a new page', a

expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadEventData.contexts?.trace?.span_id);
expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation');
expect(spanEnvelopeItem.trace_id).toBe(pageloadEventData.contexts?.trace?.trace_id);
});

Expand All @@ -194,10 +196,10 @@ sentryTest("doesn't send further LCP after the first navigation", async ({ getLo

const url = await getLocalTestUrl({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);
const pageloadEventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);

expect(eventData.type).toBe('transaction');
expect(eventData.contexts?.trace?.op).toBe('pageload');
expect(pageloadEventData.type).toBe('transaction');
expect(pageloadEventData.contexts?.trace?.op).toBe('pageload');

const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
page,
Expand All @@ -214,6 +216,8 @@ sentryTest("doesn't send further LCP after the first navigation", async ({ getLo
const spanEnvelope = (await spanEnvelopePromise)[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];
expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation');
expect(spanEnvelopeItem.trace_id).toBe(pageloadEventData.contexts?.trace?.trace_id);

getMultipleSentryEnvelopeRequests<SpanEnvelope>(page, 1, { envelopeType: 'span' }, () => {
throw new Error('Unexpected span - This should not happen!');
Expand Down Expand Up @@ -246,10 +250,10 @@ sentryTest("doesn't send further LCP after the first page hide", async ({ getLoc

const url = await getLocalTestUrl({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);
const pageloadEventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);

expect(eventData.type).toBe('transaction');
expect(eventData.contexts?.trace?.op).toBe('pageload');
expect(pageloadEventData.type).toBe('transaction');
expect(pageloadEventData.contexts?.trace?.op).toBe('pageload');

const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
page,
Expand All @@ -266,6 +270,8 @@ sentryTest("doesn't send further LCP after the first page hide", async ({ getLoc
const spanEnvelope = (await spanEnvelopePromise)[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];
expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('pagehide');
expect(spanEnvelopeItem.trace_id).toBe(pageloadEventData.contexts?.trace?.trace_id);

getMultipleSentryEnvelopeRequests<SpanEnvelope>(page, 1, { envelopeType: 'span' }, () => {
throw new Error('Unexpected span - This should not happen!');
Expand Down
73 changes: 14 additions & 59 deletions packages/browser-utils/src/metrics/cls.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
import type { SpanAttributes } from '@sentry/core';
import {
browserPerformanceTimeOrigin,
getActiveSpan,
getClient,
getCurrentScope,
getRootSpan,
htmlTreeAsString,
logger,
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
spanToJSON,
} from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { addClsInstrumentationHandler } from './instrument';
import { msToSec, startStandaloneWebVitalSpan } from './utils';
import { onHidden } from './web-vitals/lib/onHidden';
import type { WebVitalReportEvent } from './utils';
import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils';

/**
* Starts tracking the Cumulative Layout Shift on the current page and collects the value once
Expand All @@ -31,24 +27,11 @@ import { onHidden } from './web-vitals/lib/onHidden';
export function trackClsAsStandaloneSpan(): void {
let standaloneCLsValue = 0;
let standaloneClsEntry: LayoutShift | undefined;
let pageloadSpanId: string | undefined;

if (!supportsLayoutShift()) {
if (!supportsWebVital('layout-shift')) {
return;
}

let sentSpan = false;
function _collectClsOnce() {
if (sentSpan) {
return;
}
sentSpan = true;
if (pageloadSpanId) {
sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId);
}
cleanupClsHandler();
}

const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => {
const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
if (!entry) {
Expand All @@ -58,40 +41,18 @@ export function trackClsAsStandaloneSpan(): void {
standaloneClsEntry = entry;
}, true);

onHidden(() => {
_collectClsOnce();
listenForWebVitalReportEvents((reportEvent, pageloadSpanId) => {
sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent);
cleanupClsHandler();
});

// Since the call chain of this function is synchronous and evaluates before the SDK client is created,
// we need to wait with subscribing to a client hook until the client is created. Therefore, we defer
// to the next tick after the SDK setup.
setTimeout(() => {
const client = getClient();

if (!client) {
return;
}

const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => {
// we only want to collect LCP if we actually navigate. Redirects should be ignored.
if (!options?.isRedirect) {
_collectClsOnce();
unsubscribeStartNavigation?.();
}
});

const activeSpan = getActiveSpan();
if (activeSpan) {
const rootSpan = getRootSpan(activeSpan);
const spanJSON = spanToJSON(rootSpan);
if (spanJSON.op === 'pageload') {
pageloadSpanId = rootSpan.spanContext().spanId;
}
}
}, 0);
}

function sendStandaloneClsSpan(clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string) {
function sendStandaloneClsSpan(
clsValue: number,
entry: LayoutShift | undefined,
pageloadSpanId: string,
reportEvent: WebVitalReportEvent,
) {
DEBUG_BUILD && logger.log(`Sending CLS span (${clsValue})`);

const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0));
Expand All @@ -105,6 +66,8 @@ function sendStandaloneClsSpan(clsValue: number, entry: LayoutShift | undefined,
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration || 0,
// attach the pageload span id to the CLS span so that we can link them in the UI
'sentry.pageload.span_id': pageloadSpanId,
// describes what triggered the web vital to be reported
'sentry.report_event': reportEvent,
};

// Add CLS sources as span attributes to help with debugging layout shifts
Expand Down Expand Up @@ -133,11 +96,3 @@ function sendStandaloneClsSpan(clsValue: number, entry: LayoutShift | undefined,
span.end(startTime);
}
}

function supportsLayoutShift(): boolean {
try {
return PerformanceObserver.supportedEntryTypes.includes('layout-shift');
} catch {
return false;
}
}
67 changes: 9 additions & 58 deletions packages/browser-utils/src/metrics/lcp.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
import type { SpanAttributes } from '@sentry/core';
import {
browserPerformanceTimeOrigin,
getActiveSpan,
getClient,
getCurrentScope,
getRootSpan,
htmlTreeAsString,
logger,
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
spanToJSON,
} from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { addLcpInstrumentationHandler } from './instrument';
import { msToSec, startStandaloneWebVitalSpan } from './utils';
import { onHidden } from './web-vitals/lib/onHidden';
import type { WebVitalReportEvent } from './utils';
import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils';

/**
* Starts tracking the Largest Contentful Paint on the current page and collects the value once
Expand All @@ -31,24 +27,11 @@ import { onHidden } from './web-vitals/lib/onHidden';
export function trackLcpAsStandaloneSpan(): void {
let standaloneLcpValue = 0;
let standaloneLcpEntry: LargestContentfulPaint | undefined;
let pageloadSpanId: string | undefined;

if (!supportsLargestContentfulPaint()) {
if (!supportsWebVital('largest-contentful-paint')) {
return;
}

let sentSpan = false;
function _collectLcpOnce() {
if (sentSpan) {
return;
}
sentSpan = true;
if (pageloadSpanId) {
_sendStandaloneLcpSpan(standaloneLcpValue, standaloneLcpEntry, pageloadSpanId);
}
cleanupLcpHandler();
}

const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => {
const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined;
if (!entry) {
Expand All @@ -58,37 +41,10 @@ export function trackLcpAsStandaloneSpan(): void {
standaloneLcpEntry = entry;
}, true);

onHidden(() => {
_collectLcpOnce();
listenForWebVitalReportEvents((reportEvent, pageloadSpanId) => {
_sendStandaloneLcpSpan(standaloneLcpValue, standaloneLcpEntry, pageloadSpanId, reportEvent);
cleanupLcpHandler();
});

// Since the call chain of this function is synchronous and evaluates before the SDK client is created,
// we need to wait with subscribing to a client hook until the client is created. Therefore, we defer
// to the next tick after the SDK setup.
setTimeout(() => {
const client = getClient();

if (!client) {
return;
}

const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => {
// we only want to collect LCP if we actually navigate. Redirects should be ignored.
if (!options?.isRedirect) {
_collectLcpOnce();
unsubscribeStartNavigation?.();
}
});

const activeSpan = getActiveSpan();
if (activeSpan) {
const rootSpan = getRootSpan(activeSpan);
const spanJSON = spanToJSON(rootSpan);
if (spanJSON.op === 'pageload') {
pageloadSpanId = rootSpan.spanContext().spanId;
}
}
}, 0);
}

/**
Expand All @@ -98,6 +54,7 @@ export function _sendStandaloneLcpSpan(
lcpValue: number,
entry: LargestContentfulPaint | undefined,
pageloadSpanId: string,
reportEvent: WebVitalReportEvent,
) {
DEBUG_BUILD && logger.log(`Sending LCP span (${lcpValue})`);

Expand All @@ -112,6 +69,8 @@ export function _sendStandaloneLcpSpan(
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, // LCP is a point-in-time metric
// attach the pageload span id to the LCP span so that we can link them in the UI
'sentry.pageload.span_id': pageloadSpanId,
// describes what triggered the web vital to be reported
'sentry.report_event': reportEvent,
};

if (entry) {
Expand Down Expand Up @@ -149,11 +108,3 @@ export function _sendStandaloneLcpSpan(
span.end(startTime);
}
}

function supportsLargestContentfulPaint(): boolean {
try {
return PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint');
} catch {
return false;
}
}
Loading
Loading