Skip to content

feat(browser): Add afterStartPageloadSpan hook to improve spanId assignment on web vital spans #16893

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 3 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
8 changes: 5 additions & 3 deletions packages/browser-utils/src/metrics/browserMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable max-lines */
import type { Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core';
import type { Client, Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core';
import {
browserPerformanceTimeOrigin,
getActiveSpan,
Expand Down Expand Up @@ -83,6 +83,7 @@ let _clsEntry: LayoutShift | undefined;
interface StartTrackingWebVitalsOptions {
recordClsStandaloneSpans: boolean;
recordLcpStandaloneSpans: boolean;
client: Client;
}

/**
Expand All @@ -94,6 +95,7 @@ interface StartTrackingWebVitalsOptions {
export function startTrackingWebVitals({
recordClsStandaloneSpans,
recordLcpStandaloneSpans,
client,
}: StartTrackingWebVitalsOptions): () => void {
const performance = getBrowserPerformanceAPI();
if (performance && browserPerformanceTimeOrigin()) {
Expand All @@ -102,9 +104,9 @@ export function startTrackingWebVitals({
WINDOW.performance.mark('sentry-tracing-init');
}
const fidCleanupCallback = _trackFID();
const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan() : _trackLCP();
const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : _trackLCP();
const ttfbCleanupCallback = _trackTtfb();
const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan() : _trackCLS();
const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : _trackCLS();

return (): void => {
fidCleanupCallback();
Expand Down
6 changes: 3 additions & 3 deletions packages/browser-utils/src/metrics/cls.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SpanAttributes } from '@sentry/core';
import type { Client, SpanAttributes } from '@sentry/core';
import {
browserPerformanceTimeOrigin,
getCurrentScope,
Expand All @@ -24,7 +24,7 @@ import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, su
* Once either of these events triggers, the CLS value is sent as a standalone span and we stop
* measuring CLS.
*/
export function trackClsAsStandaloneSpan(): void {
export function trackClsAsStandaloneSpan(client: Client): void {
let standaloneCLsValue = 0;
let standaloneClsEntry: LayoutShift | undefined;

Expand All @@ -41,7 +41,7 @@ export function trackClsAsStandaloneSpan(): void {
standaloneClsEntry = entry;
}, true);

listenForWebVitalReportEvents((reportEvent, pageloadSpanId) => {
listenForWebVitalReportEvents(client, (reportEvent, pageloadSpanId) => {
sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent);
cleanupClsHandler();
});
Expand Down
6 changes: 3 additions & 3 deletions packages/browser-utils/src/metrics/lcp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SpanAttributes } from '@sentry/core';
import type { Client, SpanAttributes } from '@sentry/core';
import {
browserPerformanceTimeOrigin,
getCurrentScope,
Expand All @@ -24,7 +24,7 @@ import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, su
* Once either of these events triggers, the LCP value is sent as a standalone span and we stop
* measuring LCP for subsequent routes.
*/
export function trackLcpAsStandaloneSpan(): void {
export function trackLcpAsStandaloneSpan(client: Client): void {
let standaloneLcpValue = 0;
let standaloneLcpEntry: LargestContentfulPaint | undefined;

Expand All @@ -41,7 +41,7 @@ export function trackLcpAsStandaloneSpan(): void {
standaloneLcpEntry = entry;
}, true);

listenForWebVitalReportEvents((reportEvent, pageloadSpanId) => {
listenForWebVitalReportEvents(client, (reportEvent, pageloadSpanId) => {
_sendStandaloneLcpSpan(standaloneLcpValue, standaloneLcpEntry, pageloadSpanId, reportEvent);
cleanupLcpHandler();
});
Expand Down
55 changes: 22 additions & 33 deletions packages/browser-utils/src/metrics/utils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { Integration, SentrySpan, Span, SpanAttributes, SpanTimeInput, StartSpanOptions } from '@sentry/core';
import {
getActiveSpan,
getClient,
getCurrentScope,
getRootSpan,
spanToJSON,
startInactiveSpan,
withActiveSpan,
import type {
Client,
Integration,
SentrySpan,
Span,
SpanAttributes,
SpanTimeInput,
StartSpanOptions,
} from '@sentry/core';
import { getClient, getCurrentScope, spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core';
import { WINDOW } from '../types';
import { onHidden } from './web-vitals/lib/onHidden';

Expand Down Expand Up @@ -205,6 +205,7 @@ export function supportsWebVital(entryType: 'layout-shift' | 'largest-contentful
* - pageloadSpanId: the span id of the pageload span. This is used to link the web vital span to the pageload span.
*/
export function listenForWebVitalReportEvents(
client: Client,
collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string) => void,
) {
let pageloadSpanId: string | undefined;
Expand All @@ -218,32 +219,20 @@ export function listenForWebVitalReportEvents(
}

onHidden(() => {
if (!collected) {
_runCollectorCallbackOnce('pagehide');
}
_runCollectorCallbackOnce('pagehide');
});

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) {
_runCollectorCallbackOnce('navigation');
unsubscribeStartNavigation?.();
unsubscribeAfterStartPageLoadSpan?.();
}
});

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

const activeSpan = getActiveSpan();
if (activeSpan) {
const rootSpan = getRootSpan(activeSpan);
const spanJSON = spanToJSON(rootSpan);
if (spanJSON.op === 'pageload') {
pageloadSpanId = rootSpan.spanContext().spanId;
}
}
}, 0);
const unsubscribeAfterStartPageLoadSpan = client.on('afterStartPageLoadSpan', span => {
pageloadSpanId = span.spanContext().spanId;
unsubscribeAfterStartPageLoadSpan?.();
});
}
9 changes: 8 additions & 1 deletion packages/browser/src/tracing/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
_collectWebVitals = startTrackingWebVitals({
recordClsStandaloneSpans: enableStandaloneClsSpans || false,
recordLcpStandaloneSpans: enableStandaloneLcpSpans || false,
client,
});

if (enableInp) {
Expand Down Expand Up @@ -644,7 +645,13 @@ export function startBrowserTracingPageLoadSpan(
client.emit('startPageLoadSpan', spanOptions, traceOptions);
getCurrentScope().setTransactionName(spanOptions.name);

return getActiveIdleSpan(client);
const pageloadSpan = getActiveIdleSpan(client);

if (pageloadSpan) {
client.emit('afterStartPageLoadSpan', pageloadSpan);
}

return pageloadSpan;
}

/**
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,12 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
) => void,
): () => void;

/**
* A hook for the browser tracing integrations to trigger after the pageload span was started.
* @returns {() => void} A function that, when executed, removes the registered callback.
*/
public on(hook: 'afterStartPageLoadSpan', callback: (span: Span) => void): () => void;

/**
* A hook for triggering right before a navigation span is started.
* @returns {() => void} A function that, when executed, removes the registered callback.
Expand Down Expand Up @@ -791,6 +797,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined },
): void;

/**
* Emit a hook event for browser tracing integrations to trigger aafter the pageload span was started.
*/
public emit(hook: 'afterStartPageLoadSpan', span: Span): void;

/**
* Emit a hook event for triggering right before a navigation span is started.
*/
Expand Down
Loading