From 433f30a0fa61343b4c5eeb3dd9cc2b91e6aee567 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 28 May 2026 13:55:42 +0200 Subject: [PATCH 1/4] feat(browser): split web vitals integration Co-Authored-By: Codex --- .../src/index.bundle.tracing.logs.metrics.ts | 1 + ...le.tracing.replay.feedback.logs.metrics.ts | 1 + .../index.bundle.tracing.replay.feedback.ts | 1 + ...ndex.bundle.tracing.replay.logs.metrics.ts | 1 + .../src/index.bundle.tracing.replay.ts | 1 + packages/browser/src/index.bundle.tracing.ts | 1 + packages/browser/src/index.ts | 1 + .../browser/src/integrations/webVitals.ts | 84 +++++++++++++ .../src/tracing/browserTracingIntegration.ts | 53 ++++---- .../index.bundle.tracing.logs.metrics.test.ts | 3 +- ...acing.replay.feedback.logs.metrics.test.ts | 2 + ...dex.bundle.tracing.replay.feedback.test.ts | 2 + ...bundle.tracing.replay.logs.metrics.test.ts | 3 +- .../test/index.bundle.tracing.replay.test.ts | 3 +- .../browser/test/index.bundle.tracing.test.ts | 3 +- .../test/integrations/webVitals.test.ts | 113 ++++++++++++++++++ .../tracing/browserTracingIntegration.test.ts | 13 ++ 17 files changed, 250 insertions(+), 36 deletions(-) create mode 100644 packages/browser/src/integrations/webVitals.ts create mode 100644 packages/browser/test/integrations/webVitals.test.ts diff --git a/packages/browser/src/index.bundle.tracing.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.logs.metrics.ts index 720d27eefded..6a0f1e48106b 100644 --- a/packages/browser/src/index.bundle.tracing.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.logs.metrics.ts @@ -32,6 +32,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { feedbackIntegrationShim as feedbackAsyncIntegration, diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts index c500c10ba87a..d99814d380a3 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts @@ -32,6 +32,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index 6dd2608a392b..e3f5dd324dc4 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -38,6 +38,7 @@ export { reportPageLoaded } from './tracing/reportPageLoaded'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; diff --git a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts index 027425ae2144..1d486e809f61 100644 --- a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts @@ -32,6 +32,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration }; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index 6d4a0635c314..ed6082d59bc4 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -37,6 +37,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration }; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index a3813f34a0c9..aac6825ecc65 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -39,6 +39,7 @@ export { reportPageLoaded } from './tracing/reportPageLoaded'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { feedbackIntegrationShim as feedbackAsyncIntegration, diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index e206a7a012fc..46097d4d8f56 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -47,6 +47,7 @@ export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export type { RequestInstrumentationOptions } from './tracing/request'; export { diff --git a/packages/browser/src/integrations/webVitals.ts b/packages/browser/src/integrations/webVitals.ts new file mode 100644 index 000000000000..b7b325f81f8f --- /dev/null +++ b/packages/browser/src/integrations/webVitals.ts @@ -0,0 +1,84 @@ +import type { Client, IntegrationFn } from '@sentry/core/browser'; +import { defineIntegration, hasSpanStreamingEnabled } from '@sentry/core/browser'; +import { + registerInpInteractionListener, + startTrackingINP, + startTrackingWebVitals, + trackClsAsSpan, + trackInpAsSpan, + trackLcpAsSpan, +} from '@sentry-internal/browser-utils'; + +export const WEB_VITALS_INTEGRATION_NAME = 'WebVitals'; + +export type WebVitalName = 'cls' | 'inp' | 'lcp'; + +export interface WebVitalsOptions { + /** + * Web vitals to skip. + */ + disable?: WebVitalName[]; + + /** + * @experimental + */ + _experiments?: Partial<{ + enableStandaloneClsSpans: boolean; + enableStandaloneLcpSpans: boolean; + }>; +} + +const collectWebVitalsCallbacks = new WeakMap void>(); + +export function collectWebVitalsForClient(client: Client): void { + collectWebVitalsCallbacks.get(client)?.(); +} + +/** + * Captures Core Web Vitals (LCP, CLS, INP) and related pageload vitals. + * + * `browserTracingIntegration` auto-registers this integration if no + * `webVitalsIntegration` is already present, so explicit registration is only + * needed to customize options or to use it without `browserTracingIntegration`. + */ +export const webVitalsIntegration = defineIntegration((options: WebVitalsOptions = {}) => { + const disabled = new Set(options.disable ?? []); + + return { + name: WEB_VITALS_INTEGRATION_NAME, + setup(client) { + const spanStreamingEnabled = hasSpanStreamingEnabled(client); + const { enableStandaloneClsSpans, enableStandaloneLcpSpans } = options._experiments ?? {}; + + collectWebVitalsCallbacks.set( + client, + startTrackingWebVitals({ + recordClsStandaloneSpans: + spanStreamingEnabled || disabled.has('cls') ? undefined : enableStandaloneClsSpans || false, + recordLcpStandaloneSpans: + spanStreamingEnabled || disabled.has('lcp') ? undefined : enableStandaloneLcpSpans || false, + client, + }), + ); + + if (spanStreamingEnabled) { + if (!disabled.has('lcp')) { + trackLcpAsSpan(client); + } + if (!disabled.has('cls')) { + trackClsAsSpan(client); + } + if (!disabled.has('inp')) { + trackInpAsSpan(); + } + } else if (!disabled.has('inp')) { + startTrackingINP(); + } + }, + afterAllSetup() { + if (!disabled.has('inp')) { + registerInpInteractionListener(); + } + }, + }; +}) satisfies IntegrationFn; diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 8984e9e98726..1f6094a37fdc 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -40,19 +40,18 @@ import { import { addHistoryInstrumentationHandler, addPerformanceEntries, - registerInpInteractionListener, - startTrackingINP, startTrackingInteractions, startTrackingLongAnimationFrames, startTrackingLongTasks, - startTrackingWebVitals, - trackClsAsSpan, - trackInpAsSpan, - trackLcpAsSpan, } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; import { getHttpRequestData, WINDOW } from '../helpers'; import { fetchStreamPerformanceIntegration } from '../integrations/fetchStreamPerformance'; +import { + collectWebVitalsForClient, + WEB_VITALS_INTEGRATION_NAME, + webVitalsIntegration, +} from '../integrations/webVitals'; import { registerBackgroundTabDetection } from './backgroundtab'; import { linkTraces } from './linkedTraces'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request'; @@ -415,7 +414,6 @@ export const browserTracingIntegration = ((options: Partial void); let lastInteractionTimestamp: number | undefined; let _pageloadSpan: Span | undefined; @@ -458,9 +456,7 @@ export const browserTracingIntegration = ((options: Partial { - // This will generally always be defined here, because it is set in `setup()` of the integration - // but technically, it is optional, so we guard here to be extra safe - _collectWebVitals?.(); + collectWebVitalsForClient(client); const spanStreamingEnabled = hasSpanStreamingEnabled(client); addPerformanceEntries(span, { recordClsOnPageloadSpan: !spanStreamingEnabled && !enableStandaloneClsSpans, @@ -524,24 +520,6 @@ export const browserTracingIntegration = ((options: Partial { @@ -11,6 +11,7 @@ describe('index.bundle.tracing.logs.metrics', () => { expect(TracingLogsMetricsBundle.feedbackIntegration).toBe(feedbackIntegrationShim); expect(TracingLogsMetricsBundle.replayIntegration).toBe(replayIntegrationShim); expect(TracingLogsMetricsBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingLogsMetricsBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingLogsMetricsBundle.logger).toBe(coreLogger); expect(TracingLogsMetricsBundle.metrics).toBe(coreMetrics); diff --git a/packages/browser/test/index.bundle.tracing.replay.feedback.logs.metrics.test.ts b/packages/browser/test/index.bundle.tracing.replay.feedback.logs.metrics.test.ts index e4b88fab24d7..dcb7b869aa4d 100644 --- a/packages/browser/test/index.bundle.tracing.replay.feedback.logs.metrics.test.ts +++ b/packages/browser/test/index.bundle.tracing.replay.feedback.logs.metrics.test.ts @@ -5,6 +5,7 @@ import { feedbackAsyncIntegration, replayIntegration, spanStreamingIntegration, + webVitalsIntegration, } from '../src'; import * as TracingReplayFeedbackLogsMetricsBundle from '../src/index.bundle.tracing.replay.feedback.logs.metrics'; @@ -15,6 +16,7 @@ describe('index.bundle.tracing.replay.feedback.logs.metrics', () => { expect(TracingReplayFeedbackLogsMetricsBundle.feedbackIntegration).toBe(feedbackAsyncIntegration); expect(TracingReplayFeedbackLogsMetricsBundle.replayIntegration).toBe(replayIntegration); expect(TracingReplayFeedbackLogsMetricsBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingReplayFeedbackLogsMetricsBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingReplayFeedbackLogsMetricsBundle.logger).toBe(coreLogger); expect(TracingReplayFeedbackLogsMetricsBundle.metrics).toBe(coreMetrics); diff --git a/packages/browser/test/index.bundle.tracing.replay.feedback.test.ts b/packages/browser/test/index.bundle.tracing.replay.feedback.test.ts index fe60d079dc41..1bb8fffbeef9 100644 --- a/packages/browser/test/index.bundle.tracing.replay.feedback.test.ts +++ b/packages/browser/test/index.bundle.tracing.replay.feedback.test.ts @@ -5,6 +5,7 @@ import { feedbackAsyncIntegration, replayIntegration, spanStreamingIntegration, + webVitalsIntegration, } from '../src'; import * as TracingReplayFeedbackBundle from '../src/index.bundle.tracing.replay.feedback'; @@ -15,6 +16,7 @@ describe('index.bundle.tracing.replay.feedback', () => { expect(TracingReplayFeedbackBundle.feedbackIntegration).toBe(feedbackAsyncIntegration); expect(TracingReplayFeedbackBundle.replayIntegration).toBe(replayIntegration); expect(TracingReplayFeedbackBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingReplayFeedbackBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingReplayFeedbackBundle.logger).toBe(loggerShim); expect(TracingReplayFeedbackBundle.consoleLoggingIntegration).toBe(consoleLoggingIntegrationShim); diff --git a/packages/browser/test/index.bundle.tracing.replay.logs.metrics.test.ts b/packages/browser/test/index.bundle.tracing.replay.logs.metrics.test.ts index f8571872ba95..aecd1c995dda 100644 --- a/packages/browser/test/index.bundle.tracing.replay.logs.metrics.test.ts +++ b/packages/browser/test/index.bundle.tracing.replay.logs.metrics.test.ts @@ -1,7 +1,7 @@ import { logger as coreLogger, metrics as coreMetrics } from '@sentry/core/browser'; import { feedbackIntegrationShim } from '@sentry-internal/integration-shims'; import { describe, expect, it } from 'vitest'; -import { browserTracingIntegration, replayIntegration, spanStreamingIntegration } from '../src'; +import { browserTracingIntegration, replayIntegration, spanStreamingIntegration, webVitalsIntegration } from '../src'; import * as TracingReplayLogsMetricsBundle from '../src/index.bundle.tracing.replay.logs.metrics'; describe('index.bundle.tracing.replay.logs.metrics', () => { @@ -11,6 +11,7 @@ describe('index.bundle.tracing.replay.logs.metrics', () => { expect(TracingReplayLogsMetricsBundle.feedbackIntegration).toBe(feedbackIntegrationShim); expect(TracingReplayLogsMetricsBundle.replayIntegration).toBe(replayIntegration); expect(TracingReplayLogsMetricsBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingReplayLogsMetricsBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingReplayLogsMetricsBundle.logger).toBe(coreLogger); expect(TracingReplayLogsMetricsBundle.metrics).toBe(coreMetrics); diff --git a/packages/browser/test/index.bundle.tracing.replay.test.ts b/packages/browser/test/index.bundle.tracing.replay.test.ts index 1cdae8214a20..847e572be009 100644 --- a/packages/browser/test/index.bundle.tracing.replay.test.ts +++ b/packages/browser/test/index.bundle.tracing.replay.test.ts @@ -1,6 +1,6 @@ import { consoleLoggingIntegrationShim, feedbackIntegrationShim, loggerShim } from '@sentry-internal/integration-shims'; import { describe, expect, it } from 'vitest'; -import { browserTracingIntegration, replayIntegration, spanStreamingIntegration } from '../src'; +import { browserTracingIntegration, replayIntegration, spanStreamingIntegration, webVitalsIntegration } from '../src'; import * as TracingReplayBundle from '../src/index.bundle.tracing.replay'; describe('index.bundle.tracing.replay', () => { @@ -10,6 +10,7 @@ describe('index.bundle.tracing.replay', () => { expect(TracingReplayBundle.feedbackIntegration).toBe(feedbackIntegrationShim); expect(TracingReplayBundle.replayIntegration).toBe(replayIntegration); expect(TracingReplayBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingReplayBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingReplayBundle.logger).toBe(loggerShim); expect(TracingReplayBundle.consoleLoggingIntegration).toBe(consoleLoggingIntegrationShim); diff --git a/packages/browser/test/index.bundle.tracing.test.ts b/packages/browser/test/index.bundle.tracing.test.ts index 75fb658c860d..9d41fcd10e42 100644 --- a/packages/browser/test/index.bundle.tracing.test.ts +++ b/packages/browser/test/index.bundle.tracing.test.ts @@ -5,7 +5,7 @@ import { replayIntegrationShim, } from '@sentry-internal/integration-shims'; import { describe, expect, it } from 'vitest'; -import { browserTracingIntegration, spanStreamingIntegration } from '../src'; +import { browserTracingIntegration, spanStreamingIntegration, webVitalsIntegration } from '../src'; import * as TracingBundle from '../src/index.bundle.tracing'; describe('index.bundle.tracing', () => { @@ -15,6 +15,7 @@ describe('index.bundle.tracing', () => { expect(TracingBundle.feedbackIntegration).toBe(feedbackIntegrationShim); expect(TracingBundle.replayIntegration).toBe(replayIntegrationShim); expect(TracingBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingBundle.logger).toBe(loggerShim); expect(TracingBundle.consoleLoggingIntegration).toBe(consoleLoggingIntegrationShim); diff --git a/packages/browser/test/integrations/webVitals.test.ts b/packages/browser/test/integrations/webVitals.test.ts new file mode 100644 index 000000000000..5665518560ae --- /dev/null +++ b/packages/browser/test/integrations/webVitals.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { collectWebVitalsForClient, webVitalsIntegration } from '../../src/integrations/webVitals'; + +const mockRegisterInpInteractionListener = vi.hoisted(() => vi.fn()); +const mockStartTrackingINP = vi.hoisted(() => vi.fn()); +const mockStartTrackingWebVitals = vi.hoisted(() => vi.fn()); +const mockTrackClsAsSpan = vi.hoisted(() => vi.fn()); +const mockTrackInpAsSpan = vi.hoisted(() => vi.fn()); +const mockTrackLcpAsSpan = vi.hoisted(() => vi.fn()); + +vi.mock('@sentry-internal/browser-utils', () => ({ + registerInpInteractionListener: mockRegisterInpInteractionListener, + startTrackingINP: mockStartTrackingINP, + startTrackingWebVitals: mockStartTrackingWebVitals, + trackClsAsSpan: mockTrackClsAsSpan, + trackInpAsSpan: mockTrackInpAsSpan, + trackLcpAsSpan: mockTrackLcpAsSpan, +})); + +describe('webVitalsIntegration', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStartTrackingWebVitals.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('tracks web vitals with the existing non-streaming behavior by default', () => { + const client = { getOptions: () => ({}) }; + const integration = webVitalsIntegration(); + + integration.setup?.(client as never); + integration.afterAllSetup?.(client as never); + + expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ + recordClsStandaloneSpans: false, + recordLcpStandaloneSpans: false, + client, + }); + expect(mockStartTrackingINP).toHaveBeenCalledTimes(1); + expect(mockRegisterInpInteractionListener).toHaveBeenCalledTimes(1); + expect(mockTrackLcpAsSpan).not.toHaveBeenCalled(); + expect(mockTrackClsAsSpan).not.toHaveBeenCalled(); + expect(mockTrackInpAsSpan).not.toHaveBeenCalled(); + }); + + it('keeps standalone LCP and CLS experiments working', () => { + const client = { getOptions: () => ({}) }; + const integration = webVitalsIntegration({ + _experiments: { + enableStandaloneClsSpans: true, + enableStandaloneLcpSpans: true, + }, + }); + + integration.setup?.(client as never); + + expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ + recordClsStandaloneSpans: true, + recordLcpStandaloneSpans: true, + client, + }); + }); + + it('tracks LCP, CLS and INP as streamed spans when span streaming is enabled', () => { + const client = { getOptions: () => ({ traceLifecycle: 'stream' }) }; + const integration = webVitalsIntegration(); + + integration.setup?.(client as never); + integration.afterAllSetup?.(client as never); + + expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ + recordClsStandaloneSpans: undefined, + recordLcpStandaloneSpans: undefined, + client, + }); + expect(mockTrackLcpAsSpan).toHaveBeenCalledWith(client); + expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client); + expect(mockTrackInpAsSpan).toHaveBeenCalledTimes(1); + expect(mockStartTrackingINP).not.toHaveBeenCalled(); + expect(mockRegisterInpInteractionListener).toHaveBeenCalledTimes(1); + }); + + it('supports disabling selected web vitals for browserTracingIntegration compatibility', () => { + const client = { getOptions: () => ({}) }; + const integration = webVitalsIntegration({ disable: ['cls', 'inp', 'lcp'] }); + + integration.setup?.(client as never); + integration.afterAllSetup?.(client as never); + + expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ + recordClsStandaloneSpans: undefined, + recordLcpStandaloneSpans: undefined, + client, + }); + expect(mockStartTrackingINP).not.toHaveBeenCalled(); + expect(mockTrackInpAsSpan).not.toHaveBeenCalled(); + expect(mockRegisterInpInteractionListener).not.toHaveBeenCalled(); + }); + + it('exposes the web vital collection callback for browserTracingIntegration finalization', () => { + const collectWebVitals = vi.fn(); + const client = { getOptions: () => ({}) }; + mockStartTrackingWebVitals.mockReturnValue(collectWebVitals); + + webVitalsIntegration().setup?.(client as never); + collectWebVitalsForClient(client as never); + + expect(collectWebVitals).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 59c79d98bb73..aedacafb8b82 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -181,6 +181,19 @@ describe('browserTracingIntegration', () => { }); }); + it('auto-registers webVitalsIntegration', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration()], + }), + ); + setCurrentClient(client); + client.init(); + + expect(client.getIntegrationByName('WebVitals')).toBeDefined(); + }); + it('works with tracing disabled', () => { const client = new BrowserClient( getDefaultBrowserClientOptions({ From 301fee7a42f822cc203c9f600bc7f6c9463d1939 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 29 May 2026 20:41:07 +0200 Subject: [PATCH 2/4] refactor(browser): rename web vitals `disable` option to `ignore` Aligns the webVitalsIntegration denylist option with the more established `ignore` naming used elsewhere in our options. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/browser/src/integrations/webVitals.ts | 18 +++++++++--------- .../src/tracing/browserTracingIntegration.ts | 2 +- .../test/integrations/webVitals.test.ts | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/browser/src/integrations/webVitals.ts b/packages/browser/src/integrations/webVitals.ts index b7b325f81f8f..140e1a20476a 100644 --- a/packages/browser/src/integrations/webVitals.ts +++ b/packages/browser/src/integrations/webVitals.ts @@ -17,7 +17,7 @@ export interface WebVitalsOptions { /** * Web vitals to skip. */ - disable?: WebVitalName[]; + ignore?: WebVitalName[]; /** * @experimental @@ -42,7 +42,7 @@ export function collectWebVitalsForClient(client: Client): void { * needed to customize options or to use it without `browserTracingIntegration`. */ export const webVitalsIntegration = defineIntegration((options: WebVitalsOptions = {}) => { - const disabled = new Set(options.disable ?? []); + const ignored = new Set(options.ignore ?? []); return { name: WEB_VITALS_INTEGRATION_NAME, @@ -54,29 +54,29 @@ export const webVitalsIntegration = defineIntegration((options: WebVitalsOptions client, startTrackingWebVitals({ recordClsStandaloneSpans: - spanStreamingEnabled || disabled.has('cls') ? undefined : enableStandaloneClsSpans || false, + spanStreamingEnabled || ignored.has('cls') ? undefined : enableStandaloneClsSpans || false, recordLcpStandaloneSpans: - spanStreamingEnabled || disabled.has('lcp') ? undefined : enableStandaloneLcpSpans || false, + spanStreamingEnabled || ignored.has('lcp') ? undefined : enableStandaloneLcpSpans || false, client, }), ); if (spanStreamingEnabled) { - if (!disabled.has('lcp')) { + if (!ignored.has('lcp')) { trackLcpAsSpan(client); } - if (!disabled.has('cls')) { + if (!ignored.has('cls')) { trackClsAsSpan(client); } - if (!disabled.has('inp')) { + if (!ignored.has('inp')) { trackInpAsSpan(); } - } else if (!disabled.has('inp')) { + } else if (!ignored.has('inp')) { startTrackingINP(); } }, afterAllSetup() { - if (!disabled.has('inp')) { + if (!ignored.has('inp')) { registerInpInteractionListener(); } }, diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 1f6094a37fdc..2db20d527488 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -659,7 +659,7 @@ export const browserTracingIntegration = ((options: Partial { expect(mockRegisterInpInteractionListener).toHaveBeenCalledTimes(1); }); - it('supports disabling selected web vitals for browserTracingIntegration compatibility', () => { + it('supports ignoring selected web vitals for browserTracingIntegration compatibility', () => { const client = { getOptions: () => ({}) }; - const integration = webVitalsIntegration({ disable: ['cls', 'inp', 'lcp'] }); + const integration = webVitalsIntegration({ ignore: ['cls', 'inp', 'lcp'] }); integration.setup?.(client as never); integration.afterAllSetup?.(client as never); From fb0e9d0a0dd68d1ef6d2191c0ce6ac3a2418813a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 29 May 2026 22:39:59 +0200 Subject: [PATCH 3/4] refactor(browser): move all web vital logic into webVitalsIntegration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `addPerformanceEntries` (driven by `browserTracingIntegration`) both emitted pageload child spans (resource/navigation/measure) *and* wrote the web vital values onto the pageload span. This interleaved web vital logic with browserTracing's transport concerns. This fully consolidates web vital logic into `webVitalsIntegration`: - FP/FCP are now collected via their own `paint` PerformanceObserver in `startTrackingWebVitals`, instead of being scavenged from the shared `addPerformanceEntries` loop. All six vitals are now collected in one place. - The "write vitals onto the pageload span" block is extracted into a new `addWebVitalsToSpan` helper, which the integration drives via `collectWebVitalsForClient(client, span)` at span end. The integration owns the decision of whether/how to record each vital. - `connection.rtt` is decoupled from the web vital measurement flush and emitted directly by `_trackNavigator` (behavior-preserving: `setMeasurement` in v1, span attribute in span streaming). - `addPerformanceEntries` slims to resource/navigation/measure spans + navigator; its `_performanceCursor` is no longer shared with web vitals. `browserTracingIntegration` now only provides the pageload span and the span-end trigger — it makes no web vital decisions. --- packages/browser-utils/src/index.ts | 1 + .../src/metrics/browserMetrics.ts | 104 +++++++++++------- .../browser/src/integrations/webVitals.ts | 42 ++++--- .../src/tracing/browserTracingIntegration.ts | 9 +- .../test/integrations/webVitals.test.ts | 49 ++++++++- 5 files changed, 144 insertions(+), 61 deletions(-) diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index d960109c42c8..fa2752fe38de 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -8,6 +8,7 @@ export { export { addPerformanceEntries, + addWebVitalsToSpan, startTrackingInteractions, startTrackingLongTasks, startTrackingLongAnimationFrames, diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 7fa40ee101cb..dbc7357dcc11 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -123,9 +123,11 @@ export function startTrackingWebVitals({ : undefined; const ttfbCleanupCallback = _trackTtfb(); + const fpFcpCleanupCallback = _trackFpFcp(); return (): void => { ttfbCleanupCallback(); + fpFcpCleanupCallback(); lcpCleanupCallback?.(); clsCleanupCallback?.(); }; @@ -303,7 +305,47 @@ function _trackTtfb(): () => void { }); } +/** Starts tracking First Paint and First Contentful Paint on the current page. */ +function _trackFpFcp(): () => void { + return addPerformanceInstrumentationHandler('paint', ({ entries }) => { + const firstHidden = getVisibilityWatcher(); + for (const entry of entries) { + // Only report if the page wasn't hidden prior to the web vital. + const shouldRecord = entry.startTime < firstHidden.firstHiddenTime; + if (entry.name === 'first-paint' && shouldRecord) { + _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; + } + if (entry.name === 'first-contentful-paint' && shouldRecord) { + _measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' }; + } + } + }); +} + interface AddPerformanceEntriesOptions { + /** + * Resource spans with `op`s matching strings in the array will not be emitted. + * + * Default: [] + */ + ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>; + + /** + * Performance spans created from browser Performance APIs, + * `performance.mark(...)` nand `performance.measure(...)` + * with `name`s matching strings in the array will not be emitted. + * + * Default: [] + */ + ignorePerformanceApiSpans: Array; + + /** + * Whether span streaming is enabled. + */ + spanStreamingEnabled?: boolean; +} + +interface AddWebVitalsToSpanOptions { /** * Flag to determine if CLS should be recorded as a measurement on the pageload span or * sent as a standalone span instead. @@ -322,22 +364,6 @@ interface AddPerformanceEntriesOptions { */ recordLcpOnPageloadSpan: boolean; - /** - * Resource spans with `op`s matching strings in the array will not be emitted. - * - * Default: [] - */ - ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>; - - /** - * Performance spans created from browser Performance APIs, - * `performance.mark(...)` nand `performance.measure(...)` - * with `name`s matching strings in the array will not be emitted. - * - * Default: [] - */ - ignorePerformanceApiSpans: Array; - /** * Whether span streaming is enabled. */ @@ -353,13 +379,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries return; } - const { - spanStreamingEnabled, - ignorePerformanceApiSpans, - ignoreResourceSpans, - recordClsOnPageloadSpan, - recordLcpOnPageloadSpan, - } = options; + const { spanStreamingEnabled, ignorePerformanceApiSpans, ignoreResourceSpans } = options; const timeOrigin = msToSec(origin); @@ -390,18 +410,6 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries case 'paint': case 'measure': { _addMeasureSpans(span, entry, startTime, duration, timeOrigin, ignorePerformanceApiSpans); - - // capture web vitals - const firstHidden = getVisibilityWatcher(); - // Only report if the page wasn't hidden prior to the web vital. - const shouldRecord = entry.startTime < firstHidden.firstHiddenTime; - - if (entry.name === 'first-paint' && shouldRecord) { - _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; - } - if (entry.name === 'first-contentful-paint' && shouldRecord) { - _measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' }; - } break; } case 'resource': { @@ -423,9 +431,28 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries _performanceCursor = Math.max(performanceEntries.length - 1, 0); _trackNavigator(span, spanStreamingEnabled); +} + +/** + * Writes the collected web vitals (LCP, CLS, INP, TTFB, FP, FCP) onto the pageload span, + * either as measurements/attributes (v1) or as web vital attributes (span streaming). + * + * This should be called when the pageload span ends, after the web vitals have been finalized. + * It is a no-op for non-pageload spans, but always resets the collected web vital state so it + * doesn't leak into a subsequent navigation. + */ +export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOptions): void { + const origin = browserPerformanceTimeOrigin(); + if (!getBrowserPerformanceAPI()?.getEntries || !origin) { + // Gatekeeper if performance API not available + return; + } + + const { spanStreamingEnabled, recordClsOnPageloadSpan, recordLcpOnPageloadSpan } = options; + const timeOrigin = msToSec(origin); // Measurements are only available for pageload transactions - if (op === 'pageload') { + if (spanToJSON(span).op === 'pageload') { _addTtfbRequestTimeToMeasurements(_measurements); if (spanStreamingEnabled) { @@ -794,9 +821,10 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined): } if (isMeasurementValue(connection.rtt)) { - _measurements['connection.rtt'] = { value: connection.rtt, unit: 'millisecond' }; if (spanStreamingEnabled) { span.setAttribute('network.connection.rtt', connection.rtt); + } else { + setMeasurement('connection.rtt', connection.rtt, 'millisecond'); } } } @@ -819,7 +847,7 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined): } /** Add LCP / CLS data to span to allow debugging */ -function _setWebVitalAttributes(span: Span, options: AddPerformanceEntriesOptions): void { +function _setWebVitalAttributes(span: Span, options: AddWebVitalsToSpanOptions): void { // Only add LCP attributes if LCP is being recorded on the pageload span if (_lcpEntry && options.recordLcpOnPageloadSpan) { // Capture Properties of the LCP element that contributes to the LCP. diff --git a/packages/browser/src/integrations/webVitals.ts b/packages/browser/src/integrations/webVitals.ts index 140e1a20476a..3d1b02006816 100644 --- a/packages/browser/src/integrations/webVitals.ts +++ b/packages/browser/src/integrations/webVitals.ts @@ -1,6 +1,7 @@ -import type { Client, IntegrationFn } from '@sentry/core/browser'; +import type { Client, IntegrationFn, Span } from '@sentry/core/browser'; import { defineIntegration, hasSpanStreamingEnabled } from '@sentry/core/browser'; import { + addWebVitalsToSpan, registerInpInteractionListener, startTrackingINP, startTrackingWebVitals, @@ -28,10 +29,14 @@ export interface WebVitalsOptions { }>; } -const collectWebVitalsCallbacks = new WeakMap void>(); +const collectWebVitalsCallbacks = new WeakMap void>(); -export function collectWebVitalsForClient(client: Client): void { - collectWebVitalsCallbacks.get(client)?.(); +/** + * Finalizes the collected web vitals and writes them onto the given (pageload) span. + * Called by `browserTracingIntegration` when the pageload/navigation span ends. + */ +export function collectWebVitalsForClient(client: Client, span: Span): void { + collectWebVitalsCallbacks.get(client)?.(span); } /** @@ -50,16 +55,27 @@ export const webVitalsIntegration = defineIntegration((options: WebVitalsOptions const spanStreamingEnabled = hasSpanStreamingEnabled(client); const { enableStandaloneClsSpans, enableStandaloneLcpSpans } = options._experiments ?? {}; - collectWebVitalsCallbacks.set( + const recordClsStandaloneSpans = + spanStreamingEnabled || ignored.has('cls') ? undefined : enableStandaloneClsSpans || false; + const recordLcpStandaloneSpans = + spanStreamingEnabled || ignored.has('lcp') ? undefined : enableStandaloneLcpSpans || false; + + const finalizeWebVitals = startTrackingWebVitals({ + recordClsStandaloneSpans, + recordLcpStandaloneSpans, client, - startTrackingWebVitals({ - recordClsStandaloneSpans: - spanStreamingEnabled || ignored.has('cls') ? undefined : enableStandaloneClsSpans || false, - recordLcpStandaloneSpans: - spanStreamingEnabled || ignored.has('lcp') ? undefined : enableStandaloneLcpSpans || false, - client, - }), - ); + }); + + collectWebVitalsCallbacks.set(client, span => { + finalizeWebVitals(); + addWebVitalsToSpan(span, { + // CLS/LCP are recorded as pageload span measurements only when they're neither + // tracked as standalone spans nor handled by span streaming (and not ignored). + recordClsOnPageloadSpan: recordClsStandaloneSpans === false, + recordLcpOnPageloadSpan: recordLcpStandaloneSpans === false, + spanStreamingEnabled, + }); + }); if (spanStreamingEnabled) { if (!ignored.has('lcp')) { diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 2db20d527488..e63ade9b575f 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -456,14 +456,13 @@ export const browserTracingIntegration = ((options: Partial { - collectWebVitalsForClient(client); - const spanStreamingEnabled = hasSpanStreamingEnabled(client); + // The webVitalsIntegration owns all web vital logic; it finalizes the collected + // web vitals and writes them onto the pageload span. + collectWebVitalsForClient(client, span); addPerformanceEntries(span, { - recordClsOnPageloadSpan: !spanStreamingEnabled && !enableStandaloneClsSpans, - recordLcpOnPageloadSpan: !spanStreamingEnabled && !enableStandaloneLcpSpans, ignoreResourceSpans, ignorePerformanceApiSpans, - spanStreamingEnabled, + spanStreamingEnabled: hasSpanStreamingEnabled(client), }); setActiveIdleSpan(client, undefined); diff --git a/packages/browser/test/integrations/webVitals.test.ts b/packages/browser/test/integrations/webVitals.test.ts index b34a51e67835..0c2bb196d27f 100644 --- a/packages/browser/test/integrations/webVitals.test.ts +++ b/packages/browser/test/integrations/webVitals.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { collectWebVitalsForClient, webVitalsIntegration } from '../../src/integrations/webVitals'; +const mockAddWebVitalsToSpan = vi.hoisted(() => vi.fn()); const mockRegisterInpInteractionListener = vi.hoisted(() => vi.fn()); const mockStartTrackingINP = vi.hoisted(() => vi.fn()); const mockStartTrackingWebVitals = vi.hoisted(() => vi.fn()); @@ -9,6 +10,7 @@ const mockTrackInpAsSpan = vi.hoisted(() => vi.fn()); const mockTrackLcpAsSpan = vi.hoisted(() => vi.fn()); vi.mock('@sentry-internal/browser-utils', () => ({ + addWebVitalsToSpan: mockAddWebVitalsToSpan, registerInpInteractionListener: mockRegisterInpInteractionListener, startTrackingINP: mockStartTrackingINP, startTrackingWebVitals: mockStartTrackingWebVitals, @@ -100,14 +102,51 @@ describe('webVitalsIntegration', () => { expect(mockRegisterInpInteractionListener).not.toHaveBeenCalled(); }); - it('exposes the web vital collection callback for browserTracingIntegration finalization', () => { - const collectWebVitals = vi.fn(); + it('finalizes web vitals and writes them onto the span when collected for the client', () => { + const finalizeWebVitals = vi.fn(); const client = { getOptions: () => ({}) }; - mockStartTrackingWebVitals.mockReturnValue(collectWebVitals); + const span = {}; + mockStartTrackingWebVitals.mockReturnValue(finalizeWebVitals); webVitalsIntegration().setup?.(client as never); - collectWebVitalsForClient(client as never); + collectWebVitalsForClient(client as never, span as never); - expect(collectWebVitals).toHaveBeenCalledTimes(1); + expect(finalizeWebVitals).toHaveBeenCalledTimes(1); + expect(mockAddWebVitalsToSpan).toHaveBeenCalledWith(span, { + recordClsOnPageloadSpan: true, + recordLcpOnPageloadSpan: true, + spanStreamingEnabled: false, + }); + }); + + it('does not record CLS/LCP on the pageload span when span streaming is enabled', () => { + const client = { getOptions: () => ({ traceLifecycle: 'stream' }) }; + const span = {}; + + webVitalsIntegration().setup?.(client as never); + collectWebVitalsForClient(client as never, span as never); + + expect(mockAddWebVitalsToSpan).toHaveBeenCalledWith(span, { + recordClsOnPageloadSpan: false, + recordLcpOnPageloadSpan: false, + spanStreamingEnabled: true, + }); + }); + + it('does not record CLS/LCP on the pageload span when standalone spans are enabled', () => { + const client = { getOptions: () => ({}) }; + const span = {}; + const integration = webVitalsIntegration({ + _experiments: { enableStandaloneClsSpans: true, enableStandaloneLcpSpans: true }, + }); + + integration.setup?.(client as never); + collectWebVitalsForClient(client as never, span as never); + + expect(mockAddWebVitalsToSpan).toHaveBeenCalledWith(span, { + recordClsOnPageloadSpan: false, + recordLcpOnPageloadSpan: false, + spanStreamingEnabled: false, + }); }); }); From 98a76d7e1193e3bcf4b64914361e6d667c6238bf Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 30 May 2026 00:28:08 +0200 Subject: [PATCH 4/4] fix(browser): keep connection.rtt a pageload-only measurement The web vital refactor moved `connection.rtt` out of the shared `_measurements` object and emitted it directly via `setMeasurement` in `_trackNavigator`. Since `_trackNavigator` runs from `addPerformanceEntries` for every idle span (pageload and navigation), this regressed `connection.rtt` onto navigation transactions too. Previously it was only flushed inside the `op === 'pageload'` guard. Restore the pageload-only scope for the non-streaming measurement (the span-streaming attribute path was already emitted for all spans and is unchanged) and add a navigation regression test. --- .../metrics/connection-rtt-navigation/init.js | 13 +++++++++ .../connection-rtt-navigation/template.html | 9 ++++++ .../metrics/connection-rtt-navigation/test.ts | 28 +++++++++++++++++++ .../src/metrics/browserMetrics.ts | 4 ++- 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/init.js new file mode 100644 index 000000000000..3e7a3d67bff0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/template.html new file mode 100644 index 000000000000..b81f11e967e3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/template.html @@ -0,0 +1,9 @@ + + + + + + +
Rendered
+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/test.ts new file mode 100644 index 000000000000..02a9329f04d8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/test.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest.beforeEach(({ browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + sentryTest.skip(); + } +}); + +// `connection.rtt` is recorded as a measurement, which is only flushed on the pageload +// transaction. It must not leak onto navigation transactions. +sentryTest( + 'records `connection.rtt` as a measurement on pageload but not on navigation transactions', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadRequest = await getFirstSentryEnvelopeRequest(page, url); + const navigationRequest = await getFirstSentryEnvelopeRequest(page, `${url}#foo`); + + expect(pageloadRequest.contexts?.trace?.op).toBe('pageload'); + expect(navigationRequest.contexts?.trace?.op).toBe('navigation'); + + expect(pageloadRequest.measurements?.['connection.rtt']?.value).toBeDefined(); + expect(navigationRequest.measurements?.['connection.rtt']).toBeUndefined(); + }, +); diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index dbc7357dcc11..75f81e3e7d45 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -823,7 +823,9 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined): if (isMeasurementValue(connection.rtt)) { if (spanStreamingEnabled) { span.setAttribute('network.connection.rtt', connection.rtt); - } else { + } else if (spanToJSON(span).op === 'pageload') { + // Measurements are only recorded on the pageload span, matching the historical + // behavior where `connection.rtt` was only flushed for pageload transactions. setMeasurement('connection.rtt', connection.rtt, 'millisecond'); } }