From 1cbd2671191215e4e47467b43410f3d40d156606 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 9 Jul 2025 16:55:26 +0200 Subject: [PATCH 1/4] feat(core): Add `beforeStartNavigationSpan` lifecycle hook --- .../src/tracing/browserTracingIntegration.ts | 1 + .../tracing/browserTracingIntegration.test.ts | 18 ++++++++++++++++++ packages/core/src/client.ts | 11 +++++++++++ 3 files changed, 30 insertions(+) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index e1815cc7bf39..522840686d66 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -609,6 +609,7 @@ export function startBrowserTracingPageLoadSpan( * This will only do something if a browser tracing integration has been setup. */ export function startBrowserTracingNavigationSpan(client: Client, spanOptions: StartSpanOptions): Span | undefined { + client.emit('beforeStartNavigationSpan', spanOptions); client.emit('startNavigationSpan', spanOptions); getCurrentScope().setTransactionName(spanOptions.name); diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 0b71fcc01383..aa6177990d0e 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -788,6 +788,24 @@ describe('browserTracingIntegration', () => { }, }); }); + + it('triggers beforeStartNavigationSpan hook listeners', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration()], + }), + ); + setCurrentClient(client); + + const mockBeforeStartNavigationSpanCallback = vi.fn((options: StartSpanOptions) => options); + + client.on('beforeStartNavigationSpan', mockBeforeStartNavigationSpanCallback); + + startBrowserTracingNavigationSpan(client, { name: 'test span', op: 'navigation' }); + + expect(mockBeforeStartNavigationSpanCallback).toHaveBeenCalledWith({ name: 'test span', op: 'navigation' }); + }); }); describe('using the tag data', () => { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 7997bd3345a0..584cf024c67e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -603,6 +603,12 @@ export abstract class Client { ) => void, ): () => void; + /** + * A hook for triggering right before a navigation span is started. + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'beforeStartNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; + /** * A hook for browser tracing integrations to trigger a span for a navigation. * @returns {() => void} A function that, when executed, removes the registered callback. @@ -779,6 +785,11 @@ export abstract class Client { traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, ): void; + /** + * Emit a hook event for triggering right before a navigation span is started. + */ + public emit(hook: 'beforeStartNavigationSpan', options: StartSpanOptions): void; + /** * Emit a hook event for browser tracing integrations to trigger a span for a navigation. */ From 768d7ce9a27bee223a19020c155e7f1ddcdf4fac Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 9 Jul 2025 17:15:32 +0200 Subject: [PATCH 2/4] fix nextjs unit test --- .../pagesRouterInstrumentation.test.ts | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts b/packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts index d189dc0a20c3..147cb131e4c9 100644 --- a/packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts +++ b/packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts @@ -321,18 +321,17 @@ describe('pagesRouterInstrumentNavigation', () => { Router.events.emit('routeChangeStart', targetLocation); - expect(emit).toHaveBeenCalledTimes(1); - expect(emit).toHaveBeenCalledWith( - 'startNavigationSpan', - expect.objectContaining({ - name: expectedTransactionName, - attributes: { - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.nextjs.pages_router_instrumentation', - 'sentry.source': expectedTransactionSource, - }, - }), - ); + expect(emit).toHaveBeenCalledTimes(2); + const expectedSpanOptions = { + name: expectedTransactionName, + attributes: { + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.nextjs.pages_router_instrumentation', + 'sentry.source': expectedTransactionSource, + }, + }; + expect(emit).toHaveBeenCalledWith('beforeStartNavigationSpan', expect.objectContaining(expectedSpanOptions)); + expect(emit).toHaveBeenCalledWith('startNavigationSpan', expect.objectContaining(expectedSpanOptions)); }, ); }); From 7b61e1beac3da0ceefb6ee1b359cfbd50d99e102 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 9 Jul 2025 17:08:00 +0200 Subject: [PATCH 3/4] fix(browser): Ensure standalone CLS and LCP spans have traceId of pageload span --- .../web-vitals-cls-standalone-spans/test.ts | 12 +++++++---- .../web-vitals-lcp-standalone-spans/test.ts | 20 +++++++++++++------ packages/browser-utils/src/metrics/cls.ts | 2 +- packages/browser-utils/src/metrics/lcp.ts | 2 +- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts index 3db30586c909..b5c47fdd3ab7 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts @@ -348,10 +348,10 @@ sentryTest( sentryTest('sends CLS of the initial page when soft-navigating to a new page', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); - const eventData = await getFirstSentryEnvelopeRequest(page, url); + const pageloadEventData = await getFirstSentryEnvelopeRequest(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( page, @@ -364,12 +364,16 @@ sentryTest('sends CLS of the initial page when soft-navigating to a new page', a await page.goto(`${url}#soft-navigation`); + const pageloadTraceId = pageloadEventData.contexts?.trace?.trace_id; + expect(pageloadTraceId).toMatch(/[a-f0-9]{32}/); + const spanEnvelope = (await spanEnvelopePromise)[0]; const spanEnvelopeItem = spanEnvelope[1][0][1]; // Flakey value dependent on timings -> we check for a range expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.05); expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.15); - expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toMatch(/[a-f0-9]{16}/); + expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadEventData.contexts?.trace?.span_id); + expect(spanEnvelopeItem.trace_id).toEqual(pageloadTraceId); }); sentryTest("doesn't send further CLS after the first navigation", async ({ getLocalTestUrl, page }) => { diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts index 9ced3b2dee07..3310c7b95004 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts @@ -3,10 +3,12 @@ import { expect } from '@playwright/test'; import type { Event as SentryEvent, EventEnvelope, SpanEnvelope } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { + envelopeRequestParser, getFirstSentryEnvelopeRequest, getMultipleSentryEnvelopeRequests, properFullEnvelopeRequestParser, shouldSkipTracingTest, + waitForTransactionRequest, } from '../../../../utils/helpers'; sentryTest.beforeEach(async ({ browserName, page }) => { @@ -31,6 +33,8 @@ sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl, properFullEnvelopeRequestParser, ); + const pageloadEnvelopePromise = waitForTransactionRequest(page, e => e.contexts?.trace?.op === 'pageload'); + page.route('**', route => route.continue()); page.route('**/my/image.png', async (route: Route) => { return route.fulfill({ @@ -47,10 +51,14 @@ sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl, await hidePage(page); const spanEnvelope = (await spanEnvelopePromise)[0]; + const pageloadTransactionEvent = envelopeRequestParser(await pageloadEnvelopePromise); const spanEnvelopeHeaders = spanEnvelope[0]; const spanEnvelopeItem = spanEnvelope[1][0][1]; + const pageloadTraceId = pageloadTransactionEvent.contexts?.trace?.trace_id; + expect(pageloadTraceId).toMatch(/[a-f0-9]{32}/); + expect(spanEnvelopeItem).toEqual({ data: { 'sentry.exclusive_time': 0, @@ -80,7 +88,7 @@ sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl, segment_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), timestamp: spanEnvelopeItem.start_timestamp, // LCP is a point-in-time metric - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: pageloadTraceId, }); // LCP value should be greater than 0 @@ -95,7 +103,6 @@ sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl, sampled: 'true', trace_id: spanEnvelopeItem.trace_id, sample_rand: expect.any(String), - // no transaction, because span source is URL }, }); }); @@ -152,10 +159,10 @@ sentryTest('sends LCP of the initial page when soft-navigating to a new page', a const url = await getLocalTestUrl({ testDir: __dirname }); - const eventData = await getFirstSentryEnvelopeRequest(page, url); + const pageloadEventData = await getFirstSentryEnvelopeRequest(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( page, @@ -173,7 +180,8 @@ sentryTest('sends LCP of the initial page when soft-navigating to a new page', a const spanEnvelopeItem = spanEnvelope[1][0][1]; expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0); - expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toMatch(/[a-f0-9]{16}/); + expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadEventData.contexts?.trace?.span_id); + expect(spanEnvelopeItem.trace_id).toBe(pageloadEventData.contexts?.trace?.trace_id); }); sentryTest("doesn't send further LCP after the first navigation", async ({ getLocalTestUrl, page }) => { diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index 5e9b646fde89..8b7683008d2e 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -72,7 +72,7 @@ export function trackClsAsStandaloneSpan(): void { return; } - const unsubscribeStartNavigation = client.on('startNavigationSpan', () => { + const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', () => { _collectClsOnce(); unsubscribeStartNavigation?.(); }); diff --git a/packages/browser-utils/src/metrics/lcp.ts b/packages/browser-utils/src/metrics/lcp.ts index abbe4348fa6f..d6f57242f156 100644 --- a/packages/browser-utils/src/metrics/lcp.ts +++ b/packages/browser-utils/src/metrics/lcp.ts @@ -72,7 +72,7 @@ export function trackLcpAsStandaloneSpan(): void { return; } - const unsubscribeStartNavigation = client.on('startNavigationSpan', () => { + const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', () => { _collectLcpOnce(); unsubscribeStartNavigation?.(); }); From bfbe6a67879118cdb8ab449dbf0179ff8c240c7f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 9 Jul 2025 17:43:11 +0200 Subject: [PATCH 4/4] ref(browser): Add CLS/LCP report event attribute to standalone spans --- packages/browser-utils/src/metrics/cls.ts | 32 ++++++++++++--------- packages/browser-utils/src/metrics/lcp.ts | 26 +++++++++-------- packages/browser-utils/src/metrics/utils.ts | 5 ++++ 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index 8b7683008d2e..622f16d5d31a 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -16,8 +16,10 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { addClsInstrumentationHandler } from './instrument'; +import type { WebVitalReportEvent } from './utils'; import { msToSec, startStandaloneWebVitalSpan } from './utils'; import { onHidden } from './web-vitals/lib/onHidden'; +import { runOnce } from './web-vitals/lib/runOnce'; /** * Starts tracking the Cumulative Layout Shift on the current page and collects the value once @@ -37,16 +39,13 @@ export function trackClsAsStandaloneSpan(): void { return; } - let sentSpan = false; - function _collectClsOnce() { - if (sentSpan) { - return; - } - sentSpan = true; - if (pageloadSpanId) { - sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId); - } - cleanupClsHandler(); + function _collectClsOnce(reportEvent: WebVitalReportEvent) { + runOnce(() => { + if (pageloadSpanId) { + sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent); + } + cleanupClsHandler(); + }); } const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => { @@ -59,7 +58,7 @@ export function trackClsAsStandaloneSpan(): void { }, true); onHidden(() => { - _collectClsOnce(); + _collectClsOnce('pagehide'); }); // Since the call chain of this function is synchronous and evaluates before the SDK client is created, @@ -73,7 +72,7 @@ export function trackClsAsStandaloneSpan(): void { } const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', () => { - _collectClsOnce(); + _collectClsOnce('navigation'); unsubscribeStartNavigation?.(); }); @@ -88,7 +87,12 @@ export function trackClsAsStandaloneSpan(): void { }, 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)); @@ -102,6 +106,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 diff --git a/packages/browser-utils/src/metrics/lcp.ts b/packages/browser-utils/src/metrics/lcp.ts index d6f57242f156..c076edca5c20 100644 --- a/packages/browser-utils/src/metrics/lcp.ts +++ b/packages/browser-utils/src/metrics/lcp.ts @@ -16,8 +16,10 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { addLcpInstrumentationHandler } from './instrument'; +import type { WebVitalReportEvent } from './utils'; import { msToSec, startStandaloneWebVitalSpan } from './utils'; import { onHidden } from './web-vitals/lib/onHidden'; +import { runOnce } from './web-vitals/lib/runOnce'; /** * Starts tracking the Largest Contentful Paint on the current page and collects the value once @@ -37,16 +39,13 @@ export function trackLcpAsStandaloneSpan(): void { return; } - let sentSpan = false; - function _collectLcpOnce() { - if (sentSpan) { - return; - } - sentSpan = true; - if (pageloadSpanId) { - _sendStandaloneLcpSpan(standaloneLcpValue, standaloneLcpEntry, pageloadSpanId); - } - cleanupLcpHandler(); + function _collectLcpOnce(reportEvent: WebVitalReportEvent) { + runOnce(() => { + if (pageloadSpanId) { + _sendStandaloneLcpSpan(standaloneLcpValue, standaloneLcpEntry, pageloadSpanId, reportEvent); + } + cleanupLcpHandler(); + }); } const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => { @@ -59,7 +58,7 @@ export function trackLcpAsStandaloneSpan(): void { }, true); onHidden(() => { - _collectLcpOnce(); + _collectLcpOnce('pagehide'); }); // Since the call chain of this function is synchronous and evaluates before the SDK client is created, @@ -73,7 +72,7 @@ export function trackLcpAsStandaloneSpan(): void { } const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', () => { - _collectLcpOnce(); + _collectLcpOnce('navigation'); unsubscribeStartNavigation?.(); }); @@ -95,6 +94,7 @@ export function _sendStandaloneLcpSpan( lcpValue: number, entry: LargestContentfulPaint | undefined, pageloadSpanId: string, + reportEvent: WebVitalReportEvent, ) { DEBUG_BUILD && logger.log(`Sending LCP span (${lcpValue})`); @@ -109,6 +109,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) { diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index aef40d4cf613..566d04ea7f11 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -2,6 +2,8 @@ import type { Integration, SentrySpan, Span, SpanAttributes, SpanTimeInput, Star import { getClient, getCurrentScope, spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core'; import { WINDOW } from '../types'; +export type WebVitalReportEvent = 'pagehide' | 'navigation'; + /** * Checks if a given value is a valid measurement value. */ @@ -168,3 +170,6 @@ export function extractNetworkProtocol(nextHopProtocol: string): { name: string; } return { name, version }; } + +type ReportEvent = 'pagehide' | 'navigation'; +function createReportOnceHandler();