From e9e0eb2b793a81fa9075934bf73e0a3d77894d5a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 9 Jul 2025 17:43:11 +0200 Subject: [PATCH 1/9] ref(browser): Add CLS/LCP report event attribute to standalone spans --- packages/browser-utils/src/metrics/cls.ts | 34 ++++++++++++--------- packages/browser-utils/src/metrics/lcp.ts | 26 ++++++++-------- packages/browser-utils/src/metrics/utils.ts | 5 +++ 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index b564a0187818..41a2c0216475 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,9 +72,9 @@ export function trackClsAsStandaloneSpan(): void { } const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => { - // we only want to collect LCP if we actually navigate. Redirects should be ignored. + // we only want to collect CLS if we actually navigate. Redirects should be ignored. if (!options?.isRedirect) { - _collectClsOnce(); + _collectClsOnce('navigation'); unsubscribeStartNavigation?.(); } }); @@ -91,7 +90,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)); @@ -105,6 +109,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 e34391a4a1d7..0dacc269e729 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, @@ -75,7 +74,7 @@ export function trackLcpAsStandaloneSpan(): void { const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => { // we only want to collect LCP if we actually navigate. Redirects should be ignored. if (!options?.isRedirect) { - _collectLcpOnce(); + _collectLcpOnce('navigation'); unsubscribeStartNavigation?.(); } }); @@ -98,6 +97,7 @@ export function _sendStandaloneLcpSpan( lcpValue: number, entry: LargestContentfulPaint | undefined, pageloadSpanId: string, + reportEvent: WebVitalReportEvent, ) { DEBUG_BUILD && logger.log(`Sending LCP span (${lcpValue})`); @@ -112,6 +112,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(); From 557b55f12a7a141d2fcd93c6d9411be0503151af Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 9 Jul 2025 17:49:04 +0200 Subject: [PATCH 2/9] cleanup --- packages/browser-utils/src/metrics/utils.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index 566d04ea7f11..bca612c74252 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -170,6 +170,3 @@ export function extractNetworkProtocol(nextHopProtocol: string): { name: string; } return { name, version }; } - -type ReportEvent = 'pagehide' | 'navigation'; -function createReportOnceHandler(); From e6e300b7d6ab433dff767253cf07e4eb25afdfe3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 10 Jul 2025 13:22:57 +0200 Subject: [PATCH 3/9] a bit more deduplication --- packages/browser-utils/src/metrics/cls.ts | 12 ++---------- packages/browser-utils/src/metrics/lcp.ts | 12 ++---------- packages/browser-utils/src/metrics/utils.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index 41a2c0216475..0d9ce569a1a8 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -17,7 +17,7 @@ import { import { DEBUG_BUILD } from '../debug-build'; import { addClsInstrumentationHandler } from './instrument'; import type { WebVitalReportEvent } from './utils'; -import { msToSec, startStandaloneWebVitalSpan } from './utils'; +import { msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; import { onHidden } from './web-vitals/lib/onHidden'; import { runOnce } from './web-vitals/lib/runOnce'; @@ -35,7 +35,7 @@ export function trackClsAsStandaloneSpan(): void { let standaloneClsEntry: LayoutShift | undefined; let pageloadSpanId: string | undefined; - if (!supportsLayoutShift()) { + if (!supportsWebVital('layout-shift')) { return; } @@ -139,11 +139,3 @@ function sendStandaloneClsSpan( span.end(startTime); } } - -function supportsLayoutShift(): boolean { - try { - return PerformanceObserver.supportedEntryTypes.includes('layout-shift'); - } catch { - return false; - } -} diff --git a/packages/browser-utils/src/metrics/lcp.ts b/packages/browser-utils/src/metrics/lcp.ts index 0dacc269e729..82c9b543b673 100644 --- a/packages/browser-utils/src/metrics/lcp.ts +++ b/packages/browser-utils/src/metrics/lcp.ts @@ -17,7 +17,7 @@ import { import { DEBUG_BUILD } from '../debug-build'; import { addLcpInstrumentationHandler } from './instrument'; import type { WebVitalReportEvent } from './utils'; -import { msToSec, startStandaloneWebVitalSpan } from './utils'; +import { msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; import { onHidden } from './web-vitals/lib/onHidden'; import { runOnce } from './web-vitals/lib/runOnce'; @@ -35,7 +35,7 @@ export function trackLcpAsStandaloneSpan(): void { let standaloneLcpEntry: LargestContentfulPaint | undefined; let pageloadSpanId: string | undefined; - if (!supportsLargestContentfulPaint()) { + if (!supportsWebVital('largest-contentful-paint')) { return; } @@ -151,11 +151,3 @@ export function _sendStandaloneLcpSpan( span.end(startTime); } } - -function supportsLargestContentfulPaint(): boolean { - try { - return PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint'); - } catch { - return false; - } -} diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index bca612c74252..b2270a10786c 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -170,3 +170,14 @@ export function extractNetworkProtocol(nextHopProtocol: string): { name: string; } return { name, version }; } + +/** + * Generic support check for web vitals + */ +export function supportsWebVital(entryType: 'layout-shift' | 'largest-contentful-paint'): boolean { + try { + return PerformanceObserver.supportedEntryTypes.includes(entryType); + } catch { + return false; + } +} From 452dd23ca0d25452d3017b17b07c9ffa8c1ceccf Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 10 Jul 2025 14:08:26 +0200 Subject: [PATCH 4/9] more extraction --- packages/browser-utils/src/metrics/cls.ts | 51 ++----------- packages/browser-utils/src/metrics/lcp.ts | 51 ++----------- packages/browser-utils/src/metrics/utils.ts | 79 ++++++++++++++++++++- 3 files changed, 86 insertions(+), 95 deletions(-) diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index 0d9ce569a1a8..8839f038fb49 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -1,10 +1,7 @@ import type { SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, - getActiveSpan, - getClient, getCurrentScope, - getRootSpan, htmlTreeAsString, logger, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, @@ -12,14 +9,11 @@ import { 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 type { WebVitalReportEvent } from './utils'; -import { msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; -import { onHidden } from './web-vitals/lib/onHidden'; -import { runOnce } from './web-vitals/lib/runOnce'; +import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; /** * Starts tracking the Cumulative Layout Shift on the current page and collects the value once @@ -33,21 +27,11 @@ import { runOnce } from './web-vitals/lib/runOnce'; export function trackClsAsStandaloneSpan(): void { let standaloneCLsValue = 0; let standaloneClsEntry: LayoutShift | undefined; - let pageloadSpanId: string | undefined; if (!supportsWebVital('layout-shift')) { return; } - function _collectClsOnce(reportEvent: WebVitalReportEvent) { - runOnce(() => { - if (pageloadSpanId) { - sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent); - } - cleanupClsHandler(); - }); - } - const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; if (!entry) { @@ -57,37 +41,10 @@ export function trackClsAsStandaloneSpan(): void { standaloneClsEntry = entry; }, true); - onHidden(() => { - _collectClsOnce('pagehide'); + 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 CLS if we actually navigate. Redirects should be ignored. - if (!options?.isRedirect) { - _collectClsOnce('navigation'); - 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( diff --git a/packages/browser-utils/src/metrics/lcp.ts b/packages/browser-utils/src/metrics/lcp.ts index 82c9b543b673..dc98b2f8f2b1 100644 --- a/packages/browser-utils/src/metrics/lcp.ts +++ b/packages/browser-utils/src/metrics/lcp.ts @@ -1,10 +1,7 @@ import type { SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, - getActiveSpan, - getClient, getCurrentScope, - getRootSpan, htmlTreeAsString, logger, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, @@ -12,14 +9,11 @@ import { 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 type { WebVitalReportEvent } from './utils'; -import { msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; -import { onHidden } from './web-vitals/lib/onHidden'; -import { runOnce } from './web-vitals/lib/runOnce'; +import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; /** * Starts tracking the Largest Contentful Paint on the current page and collects the value once @@ -33,21 +27,11 @@ import { runOnce } from './web-vitals/lib/runOnce'; export function trackLcpAsStandaloneSpan(): void { let standaloneLcpValue = 0; let standaloneLcpEntry: LargestContentfulPaint | undefined; - let pageloadSpanId: string | undefined; if (!supportsWebVital('largest-contentful-paint')) { return; } - function _collectLcpOnce(reportEvent: WebVitalReportEvent) { - runOnce(() => { - if (pageloadSpanId) { - _sendStandaloneLcpSpan(standaloneLcpValue, standaloneLcpEntry, pageloadSpanId, reportEvent); - } - cleanupLcpHandler(); - }); - } - const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; if (!entry) { @@ -57,37 +41,10 @@ export function trackLcpAsStandaloneSpan(): void { standaloneLcpEntry = entry; }, true); - onHidden(() => { - _collectLcpOnce('pagehide'); + 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('navigation'); - unsubscribeStartNavigation?.(); - } - }); - - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - const spanJSON = spanToJSON(rootSpan); - if (spanJSON.op === 'pageload') { - pageloadSpanId = rootSpan.spanContext().spanId; - } - } - }, 0); } /** diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index b2270a10786c..837359231b62 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -1,6 +1,16 @@ import type { Integration, SentrySpan, Span, SpanAttributes, SpanTimeInput, StartSpanOptions } from '@sentry/core'; -import { getClient, getCurrentScope, spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core'; +import { + getActiveSpan, + getClient, + getCurrentScope, + getRootSpan, + spanToJSON, + startInactiveSpan, + withActiveSpan, +} from '@sentry/core'; import { WINDOW } from '../types'; +import { onHidden } from './web-vitals/lib/onHidden'; +import { runOnce } from './web-vitals/lib/runOnce'; export type WebVitalReportEvent = 'pagehide' | 'navigation'; @@ -181,3 +191,70 @@ export function supportsWebVital(entryType: 'layout-shift' | 'largest-contentful return false; } } + +/** + * Listens for events on which we want to collect a previously accumulated web vital value. + * Currently, this includes: + * + * - pagehide (i.e. user minimizes browser window, hides tab, etc) + * - soft navigation (we only care about the vital of the initially loaded route) + * + * As a "side-effect", this function will also collect the span id of the pageload span. + * + * @param collectorCallback the callback to be called when the first of these events is triggered. Parameters: + * - event: the event that triggered the reporting of the web vital value. + * - pageloadSpanId: the span id of the pageload span. This is used to link the web vital span to the pageload span. + */ +export function listenForWebVitalReportEvents( + collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string) => void, +) { + let pageloadSpanId: string | undefined; + let triggeredReportEvent: WebVitalReportEvent | undefined; + let collected = false; + + function _runCollectorCallbackOnce() { + if (!collected && triggeredReportEvent) { + collectorCallback(triggeredReportEvent, pageloadSpanId ?? 'unknown'); + collected = true; + } + } + + onHidden(() => { + if (!collected) { + triggeredReportEvent = 'pagehide'; + _runCollectorCallbackOnce(); + } + }); + + 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) { + triggeredReportEvent = 'navigation'; + _runCollectorCallbackOnce(); + unsubscribeStartNavigation?.(); + } + }); + + client.on('spanEnd', span => { + const spanJSON = spanToJSON(span); + if (spanJSON.op === 'pageload') { + pageloadSpanId = span.spanContext().spanId; + } + }); + + const activeSpan = getActiveSpan(); + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + const spanJSON = spanToJSON(rootSpan); + if (spanJSON.op === 'pageload') { + pageloadSpanId = rootSpan.spanContext().spanId; + } + } + }, 0); +} From 4b7a12360aa63f0c4acc238360d73d86d99dee93 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 10 Jul 2025 14:25:31 +0200 Subject: [PATCH 5/9] bit more cleanup --- packages/browser-utils/src/metrics/utils.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index 837359231b62..5e51a9251a18 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -209,20 +209,18 @@ export function listenForWebVitalReportEvents( collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string) => void, ) { let pageloadSpanId: string | undefined; - let triggeredReportEvent: WebVitalReportEvent | undefined; - let collected = false; - function _runCollectorCallbackOnce() { - if (!collected && triggeredReportEvent) { - collectorCallback(triggeredReportEvent, pageloadSpanId ?? 'unknown'); + let collected = false; + function _runCollectorCallbackOnce(event: WebVitalReportEvent) { + if (!collected) { + collectorCallback(event, pageloadSpanId ?? 'unknown'); collected = true; } } onHidden(() => { if (!collected) { - triggeredReportEvent = 'pagehide'; - _runCollectorCallbackOnce(); + _runCollectorCallbackOnce('pagehide'); } }); @@ -235,8 +233,7 @@ export function listenForWebVitalReportEvents( const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => { // we only want to collect LCP if we actually navigate. Redirects should be ignored. if (!options?.isRedirect) { - triggeredReportEvent = 'navigation'; - _runCollectorCallbackOnce(); + _runCollectorCallbackOnce('navigation'); unsubscribeStartNavigation?.(); } }); From 3fb49d1dfe034f3654826f9cb505b5b8e32896e3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 10 Jul 2025 14:28:12 +0200 Subject: [PATCH 6/9] remove accidental onSpanEnd hook --- packages/browser-utils/src/metrics/utils.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index 5e51a9251a18..89bc3ff84f22 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -238,13 +238,6 @@ export function listenForWebVitalReportEvents( } }); - client.on('spanEnd', span => { - const spanJSON = spanToJSON(span); - if (spanJSON.op === 'pageload') { - pageloadSpanId = span.spanContext().spanId; - } - }); - const activeSpan = getActiveSpan(); if (activeSpan) { const rootSpan = getRootSpan(activeSpan); From 3dca74e6e19e42893fa44044739f657aa3f47264 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 10 Jul 2025 14:56:23 +0200 Subject: [PATCH 7/9] adjust tests --- .../web-vitals-cls-standalone-spans/test.ts | 10 ++++++++++ .../web-vitals-lcp-standalone-spans/test.ts | 18 ++++++++++++------ packages/browser-utils/src/metrics/utils.ts | 1 - 3 files changed, 22 insertions(+), 7 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 b5c47fdd3ab7..24c949c63afa 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 @@ -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}/), @@ -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}/), @@ -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}/), @@ -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}/), @@ -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'); }, ); @@ -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 }) => { @@ -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(page, 1, { envelopeType: 'span' }, () => { throw new Error('Unexpected span - This should not happen!'); @@ -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(page, 1, { envelopeType: 'span' }, () => { throw new Error('Unexpected span - This should not happen!'); 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 3310c7b95004..ad21a6240793 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 @@ -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}/), @@ -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); }); @@ -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(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, @@ -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(page, 1, { envelopeType: 'span' }, () => { throw new Error('Unexpected span - This should not happen!'); @@ -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(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, @@ -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(page, 1, { envelopeType: 'span' }, () => { throw new Error('Unexpected span - This should not happen!'); diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index 89bc3ff84f22..9a499385fb56 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -10,7 +10,6 @@ import { } from '@sentry/core'; import { WINDOW } from '../types'; import { onHidden } from './web-vitals/lib/onHidden'; -import { runOnce } from './web-vitals/lib/runOnce'; export type WebVitalReportEvent = 'pagehide' | 'navigation'; From 20380575183f4bdbed9df6a352ec85cefea5234e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 10 Jul 2025 15:56:11 +0200 Subject: [PATCH 8/9] yeah you're technically right cursor... --- packages/browser-utils/src/metrics/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index 9a499385fb56..c2326a1d2adb 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -211,8 +211,8 @@ export function listenForWebVitalReportEvents( let collected = false; function _runCollectorCallbackOnce(event: WebVitalReportEvent) { - if (!collected) { - collectorCallback(event, pageloadSpanId ?? 'unknown'); + if (!collected && pageloadSpanId) { + collectorCallback(event, pageloadSpanId); collected = true; } } From 006b7a6bd83176dd0296a4e2173a39fbdbf05af8 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 10 Jul 2025 15:57:13 +0200 Subject: [PATCH 9/9] but we still don't want to collect more than once --- packages/browser-utils/src/metrics/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index c2326a1d2adb..ce3da0d4f16d 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -213,8 +213,8 @@ export function listenForWebVitalReportEvents( function _runCollectorCallbackOnce(event: WebVitalReportEvent) { if (!collected && pageloadSpanId) { collectorCallback(event, pageloadSpanId); - collected = true; } + collected = true; } onHidden(() => {