From 1ed6394f16227d1bfdba6571c341d7a31d8661c8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 18 Sep 2026 11:52:29 -0400 Subject: [PATCH 1/2] ref(browser)!: Remove the web vital report event attribute LCP and CLS spans set browser.web_vital.{lcp,cls}.report_event to record whether the SDK finalized the page load value on pagehide or at the first navigation. Per-navigation web vitals are the default in v11 and never set it, since web-vitals decides when a value is final on that path. It was only still emitted with softNavigations and bfcacheNavigations turned off. The logic that finalizes on pagehide or navigation stays, it is still what the opt-out path uses. It just no longer reports which of the two fired. --- CHANGELOG.md | 1 + MIGRATION.md | 8 ++ .../browser-utils/src/web-vitals/emitSpan.ts | 7 -- .../src/web-vitals/reportEvents.ts | 23 ++-- .../browser-utils/src/web-vitals/spans.ts | 15 +-- .../test/web-vitals/spans.test.ts | 100 +++++++++++++----- 6 files changed, 96 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68fb27ce580c..38995dbab4e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehaprasad-dev, @JealousGx, @Jxxunnn, @eddie333016, @davidmurdoch, @yashschandra, @atharv-sys32, @AG0708, @birkskyum, @mkly, @mcbbugu, @suhailopensource, @zkasuran, @mohd-akram, @RealBhupesh, @halillusion, @psang39, @hafzism, @JosephDoUrden, @Tyagiquamar, @Andarist, @msnelling, and @oesnuj. Thank you for your contributions! +- ref(browser)!: LCP and CLS spans no longer set `browser.web_vital.lcp.report_event` and `browser.web_vital.cls.report_event`. With per-navigation web vitals (the default) the attribute was already never set; it is now also gone when `softNavigations` and `bfcacheNavigations` are turned off. When the values are finalized is unchanged. - feat(browser)!: `browser.navigation.type` on web vital and bfcache navigation spans now carries the navigation type exactly as web-vitals reports it. `bfcache` is now `back-forward-cache`, and a back/forward navigation that missed the bfcache (`back-forward`) or a discarded-tab restore (`restore`) is no longer folded into `navigate`. Update any dashboards or alerts filtering on `bfcache`. - feat(core): Add `createFetchIntegration`, the shared implementation behind the global-`fetch` integrations in `@sentry/bun`, `@sentry/cloudflare`, `@sentry/deno` and `@sentry/vercel-edge`. Those four packages carried four copies of it; they now share one. Two changes come out of that: - All four gain a `tracePropagation` option (default `true`). Turn it off to stop injecting `sentry-trace` and `baggage` without also turning off spans. To scope propagation to specific URLs, keep using `tracePropagationTargets` in the client options. diff --git a/MIGRATION.md b/MIGRATION.md index bc8e4e35c9cb..d67500ae912a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -617,6 +617,14 @@ Sentry.init({ }); ``` +### Web vital spans no longer carry a report event + +Affected SDKs: All SDKs running in the browser. + +LCP and CLS spans no longer set `browser.web_vital.lcp.report_event` and `browser.web_vital.cls.report_event`. The attribute recorded whether the SDK finalized the page load's value on `pagehide` or at the first `navigation`. With per-navigation reporting (the default, see above) `web-vitals` decides when a value is final and the attribute was already never set, so it only remained for setups that turn per-navigation reporting off. + +When the values are finalized is unchanged. If you have searches or dashboards keyed on the attribute, remove the filter. + ### `DOMException.code` is no longer set as a tag Affected SDKs: All SDKs running in the browser. diff --git a/packages/browser-utils/src/web-vitals/emitSpan.ts b/packages/browser-utils/src/web-vitals/emitSpan.ts index 890de057b6a6..7294b5c8d4a5 100644 --- a/packages/browser-utils/src/web-vitals/emitSpan.ts +++ b/packages/browser-utils/src/web-vitals/emitSpan.ts @@ -19,7 +19,6 @@ import { } from '@sentry/conventions/attributes'; import { WINDOW } from '../types'; import type { MetricNavigationType } from '../instrumentation/performanceObserver'; -import type { WebVitalReportEvent } from './reportEvents'; // Locally-defined interfaces to avoid leaking bare global type references into the // generated .d.ts. The `declare global` augmentations in web-vitals/types.ts make these @@ -48,7 +47,6 @@ interface WebVitalSpanOptions { value: number; attributes?: SpanAttributes; parentSpan?: Span; - reportEvent?: WebVitalReportEvent; startTime: number; endTime?: number; /** Set when the vital was reported for a soft navigation rather than the initial page load. */ @@ -78,7 +76,6 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { value, attributes: passedAttributes, parentSpan, - reportEvent, startTime, endTime, standalone, @@ -111,10 +108,6 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { attributes['sentry.pageload.span_id'] = parentSpan.spanContext().spanId; } - if (reportEvent) { - attributes[`browser.web_vital.${metricName}.report_event`] = reportEvent; - } - if (softNavigationId != null) { attributes[BROWSER_NAVIGATION_ID] = softNavigationId; } diff --git a/packages/browser-utils/src/web-vitals/reportEvents.ts b/packages/browser-utils/src/web-vitals/reportEvents.ts index 00ce84bf9a8d..3a1711a81bc1 100644 --- a/packages/browser-utils/src/web-vitals/reportEvents.ts +++ b/packages/browser-utils/src/web-vitals/reportEvents.ts @@ -1,8 +1,6 @@ import type { Client, Span } from '@sentry/core'; import { onHidden } from './utils'; -export type WebVitalReportEvent = 'pagehide' | 'navigation'; - /** * Listens for events on which we want to collect a previously accumulated web vital value. * Currently, this includes: @@ -10,35 +8,30 @@ export type WebVitalReportEvent = 'pagehide' | 'navigation'; * - 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. + * As a "side-effect", this function will also collect 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. - * - pageloadSpan: the pageload span instance. This is used for full access to the pageload span for span streaming. + * @param collectorCallback the callback to be called when the first of these events is triggered. It is passed the + * pageload span, which the web vital span is parented to. */ -export function listenForWebVitalReportEvents( - client: Client, - collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string, pageloadSpan?: Span) => void, -) { +export function listenForWebVitalReportEvents(client: Client, collectorCallback: (pageloadSpan: Span) => void) { let pageloadSpan: Span | undefined; let collected = false; - function _runCollectorCallbackOnce(event: WebVitalReportEvent) { + function _runCollectorCallbackOnce() { if (!collected && pageloadSpan) { - collectorCallback(event, pageloadSpan.spanContext().spanId, pageloadSpan); + collectorCallback(pageloadSpan); } collected = true; } onHidden(() => { - _runCollectorCallbackOnce('pagehide'); + _runCollectorCallbackOnce(); }); 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'); + _runCollectorCallbackOnce(); unsubscribeStartNavigation(); unsubscribeAfterStartPageLoadSpan(); } diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index 1996715d51cb..490999a976ee 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -24,7 +24,6 @@ import type { LargestContentfulPaint, LayoutShift } from './emitSpan'; import { BROWSER_NAVIGATION_TYPE } from '@sentry/conventions/attributes'; import { _emitWebVitalSpan } from './emitSpan'; import { isValidLcpMetric } from './lcp'; -import type { WebVitalReportEvent } from './reportEvents'; import { listenForWebVitalReportEvents } from './reportEvents'; import { getNavigationSpanForMetric } from './softNavs'; import { getBrowserPerformanceAPI, msToSec, supportsWebVital } from '../performance/utils'; @@ -125,7 +124,6 @@ export function trackLcpAsSpan(client: Client, perNavigation = false): void { metric.value, entry, parentSpan, - undefined, softNavigationId, metric.navigationType, metric.navigationStartTime, @@ -151,8 +149,8 @@ export function trackLcpAsSpan(client: Client, perNavigation = false): void { lcpEntry = entry; }, true); - listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => { - _sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, reportEvent, undefined, lcpNavigationType); + listenForWebVitalReportEvents(client, pageloadSpan => { + _sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, undefined, lcpNavigationType); cleanupLcpHandler(); }); } @@ -164,7 +162,6 @@ export function _sendLcpSpan( lcpValue: number, entry: LargestContentfulPaint | undefined, pageloadSpan?: Span, - reportEvent?: WebVitalReportEvent, softNavigationId?: number, navigationType?: MetricNavigationType, navigationStartTime?: number, @@ -202,7 +199,6 @@ export function _sendLcpSpan( value: lcpValue, attributes, parentSpan: pageloadSpan, - reportEvent, startTime, endTime, softNavigationId, @@ -225,7 +221,6 @@ export function trackClsAsSpan(client: Client, perNavigation = false): void { metric.value, entry, parentSpan, - undefined, softNavigationId, metric.navigationType, metric.navigationStartTime, @@ -251,8 +246,8 @@ export function trackClsAsSpan(client: Client, perNavigation = false): void { clsEntry = entry; }, true); - listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => { - _sendClsSpan(clsValue, clsEntry, pageloadSpan, reportEvent, undefined, clsNavigationType); + listenForWebVitalReportEvents(client, pageloadSpan => { + _sendClsSpan(clsValue, clsEntry, pageloadSpan, undefined, clsNavigationType); cleanupClsHandler(); }); } @@ -264,7 +259,6 @@ export function _sendClsSpan( clsValue: number, entry: LayoutShift | undefined, pageloadSpan?: Span, - reportEvent?: WebVitalReportEvent, softNavigationId?: number, navigationType?: MetricNavigationType, navigationStartTime?: number, @@ -295,7 +289,6 @@ export function _sendClsSpan( value: clsValue, attributes, parentSpan: pageloadSpan, - reportEvent, startTime, softNavigationId, navigationType, diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index daad65d7b5cb..247df20828e1 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -8,6 +8,7 @@ import { MAX_PLAUSIBLE_LCP_DURATION } from '../../src/web-vitals/lcp'; import { _emitWebVitalSpan } from '../../src/web-vitals/emitSpan'; import * as reportEvents from '../../src/web-vitals/reportEvents'; import * as softNavs from '../../src/web-vitals/softNavs'; +import * as webVitalUtils from '../../src/web-vitals/utils'; import { _sendClsSpan, _sendInpSpan, @@ -282,26 +283,6 @@ describe('_emitWebVitalSpan', () => { ); }); - it('includes reportEvent when provided', () => { - _emitWebVitalSpan({ - name: 'Test', - op: 'ui.webvital.cls', - origin: 'auto.http.browser.cls', - metricName: 'cls', - value: 0.1, - reportEvent: 'pagehide', - startTime: 1.0, - }); - - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: expect.objectContaining({ - 'browser.web_vital.cls.report_event': 'pagehide', - }), - }), - ); - }); - it('merges additional attributes', () => { _emitWebVitalSpan({ name: 'Test', @@ -421,7 +402,7 @@ describe('_sendLcpSpan', () => { const mockPageloadSpan = createMockPageloadSpan('pageload-123'); - _sendLcpSpan(250, mockEntry, mockPageloadSpan as any, 'pagehide'); + _sendLcpSpan(250, mockEntry, mockPageloadSpan as any); expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ @@ -437,7 +418,6 @@ describe('_sendLcpSpan', () => { 'browser.web_vital.lcp.load_time': 100, 'browser.web_vital.lcp.render_time': 150, 'browser.web_vital.lcp.size': 50000, - 'browser.web_vital.lcp.report_event': 'pagehide', 'sentry.transaction': 'test-route', 'sentry.segment.name': 'test-route', }), @@ -464,7 +444,7 @@ describe('_sendLcpSpan', () => { it('lasts the reported value when there is no entry to end at', () => { // A soft navigation 2000ms into the page. Ending at the time origin would put the end before // the start. - _sendLcpSpan(250, undefined, undefined, undefined, 2, 'soft-navigation', 2000); + _sendLcpSpan(250, undefined, undefined, 2, 'soft-navigation', 2000); expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ startTime: 3 })); expect(mockSpan.end).toHaveBeenCalledWith(3.25); @@ -530,7 +510,7 @@ describe('_sendClsSpan', () => { const mockPageloadSpan = createMockPageloadSpan('pageload-789'); - _sendClsSpan(0.1, mockEntry, mockPageloadSpan as any, 'navigation'); + _sendClsSpan(0.1, mockEntry, mockPageloadSpan as any); expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ @@ -541,7 +521,6 @@ describe('_sendClsSpan', () => { 'sentry.pageload.span_id': 'pageload-789', 'browser.web_vital.cls.source.1': '
', 'browser.web_vital.cls.source.2': '', - 'browser.web_vital.cls.report_event': 'navigation', 'sentry.transaction': 'test-route', 'sentry.segment.name': 'test-route', }), @@ -1071,3 +1050,74 @@ describe('soft navigation web vitals', () => { expect(listenSpy).not.toHaveBeenCalled(); }); }); + +describe('page load web vitals without per-navigation reporting', () => { + const pageloadSpan = createMockPageloadSpan('pageload-1'); + + let lcpCallback: (arg: { metric: any }) => void; + let hooks: Record void>; + let hide: () => void; + let client: any; + + beforeEach(() => { + vi.stubGlobal('PerformanceObserver', { supportedEntryTypes: ['largest-contentful-paint'] }); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.getCurrentScope).mockReturnValue({ + getScopeData: vi.fn().mockReturnValue({ transactionName: 'test-route' }), + } as any); + vi.mocked(SentryCore.spanToJSON).mockReturnValue({ attributes: { 'sentry.op': 'pageload' } } as any); + vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue({ end: vi.fn() } as any); + vi.mocked(htmlTreeAsString).mockReturnValue('
'); + vi.spyOn(instrument, 'addLcpInstrumentationHandler').mockImplementation((cb: any) => { + lcpCallback = cb; + return () => undefined; + }); + vi.spyOn(webVitalUtils, 'onHidden').mockImplementation(cb => { + hide = cb as () => void; + }); + + hooks = {}; + client = { + on: vi.fn((hook: string, cb: any) => { + hooks[hook] = cb; + return () => undefined; + }), + }; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('finalizes the page load LCP at the first navigation and ignores later report events', () => { + trackLcpAsSpan(client); + hooks.afterStartPageLoadSpan!(pageloadSpan); + lcpCallback({ metric: { value: 800, navigationType: 'navigate', entries: [{ startTime: 800, element: {} }] } }); + + hooks.beforeStartNavigationSpan!({}, { isRedirect: true }); + expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalled(); + + hooks.beforeStartNavigationSpan!({}, {}); + hide(); + + const calls = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls; + expect(calls).toHaveLength(1); + expect(calls[0]![0].parentSpan).toBe(pageloadSpan); + expect(calls[0]![0].attributes?.['browser.web_vital.lcp.value']).toBe(800); + expect(calls[0]![0].attributes).not.toHaveProperty('browser.web_vital.lcp.report_event'); + }); + + it('finalizes the page load LCP when the page is hidden before any navigation', () => { + trackLcpAsSpan(client); + hooks.afterStartPageLoadSpan!(pageloadSpan); + lcpCallback({ metric: { value: 800, navigationType: 'navigate', entries: [{ startTime: 800, element: {} }] } }); + + hide(); + hooks.beforeStartNavigationSpan!({}, {}); + + const calls = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls; + expect(calls).toHaveLength(1); + expect(calls[0]![0].attributes?.['browser.web_vital.lcp.value']).toBe(800); + }); +}); From 815a4cacef66d8ae8f83887965592a73ee2d96b5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 21 Sep 2026 10:12:00 -0400 Subject: [PATCH 2/2] test(browser): Remove the report event absence test --- .../test/web-vitals/spans.test.ts | 72 ------------------- 1 file changed, 72 deletions(-) diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index 247df20828e1..bd2b063d6be0 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -8,7 +8,6 @@ import { MAX_PLAUSIBLE_LCP_DURATION } from '../../src/web-vitals/lcp'; import { _emitWebVitalSpan } from '../../src/web-vitals/emitSpan'; import * as reportEvents from '../../src/web-vitals/reportEvents'; import * as softNavs from '../../src/web-vitals/softNavs'; -import * as webVitalUtils from '../../src/web-vitals/utils'; import { _sendClsSpan, _sendInpSpan, @@ -1050,74 +1049,3 @@ describe('soft navigation web vitals', () => { expect(listenSpy).not.toHaveBeenCalled(); }); }); - -describe('page load web vitals without per-navigation reporting', () => { - const pageloadSpan = createMockPageloadSpan('pageload-1'); - - let lcpCallback: (arg: { metric: any }) => void; - let hooks: Record void>; - let hide: () => void; - let client: any; - - beforeEach(() => { - vi.stubGlobal('PerformanceObserver', { supportedEntryTypes: ['largest-contentful-paint'] }); - vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); - vi.mocked(SentryCore.getCurrentScope).mockReturnValue({ - getScopeData: vi.fn().mockReturnValue({ transactionName: 'test-route' }), - } as any); - vi.mocked(SentryCore.spanToJSON).mockReturnValue({ attributes: { 'sentry.op': 'pageload' } } as any); - vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue({ end: vi.fn() } as any); - vi.mocked(htmlTreeAsString).mockReturnValue('
'); - vi.spyOn(instrument, 'addLcpInstrumentationHandler').mockImplementation((cb: any) => { - lcpCallback = cb; - return () => undefined; - }); - vi.spyOn(webVitalUtils, 'onHidden').mockImplementation(cb => { - hide = cb as () => void; - }); - - hooks = {}; - client = { - on: vi.fn((hook: string, cb: any) => { - hooks[hook] = cb; - return () => undefined; - }), - }; - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it('finalizes the page load LCP at the first navigation and ignores later report events', () => { - trackLcpAsSpan(client); - hooks.afterStartPageLoadSpan!(pageloadSpan); - lcpCallback({ metric: { value: 800, navigationType: 'navigate', entries: [{ startTime: 800, element: {} }] } }); - - hooks.beforeStartNavigationSpan!({}, { isRedirect: true }); - expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalled(); - - hooks.beforeStartNavigationSpan!({}, {}); - hide(); - - const calls = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls; - expect(calls).toHaveLength(1); - expect(calls[0]![0].parentSpan).toBe(pageloadSpan); - expect(calls[0]![0].attributes?.['browser.web_vital.lcp.value']).toBe(800); - expect(calls[0]![0].attributes).not.toHaveProperty('browser.web_vital.lcp.report_event'); - }); - - it('finalizes the page load LCP when the page is hidden before any navigation', () => { - trackLcpAsSpan(client); - hooks.afterStartPageLoadSpan!(pageloadSpan); - lcpCallback({ metric: { value: 800, navigationType: 'navigate', entries: [{ startTime: 800, element: {} }] } }); - - hide(); - hooks.beforeStartNavigationSpan!({}, {}); - - const calls = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls; - expect(calls).toHaveLength(1); - expect(calls[0]![0].attributes?.['browser.web_vital.lcp.value']).toBe(800); - }); -});