From 54b2d665194ede26eb5f6ff193704f7f9ef788f5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 16 Mar 2026 13:42:09 -0400 Subject: [PATCH 01/14] feat(browser-utils): Add FCP instrumentation handler and export INP_ENTRY_MAP Add `addFcpInstrumentationHandler` using the existing `onFCP` web-vitals library integration, following the same pattern as the other metric handlers. Export `INP_ENTRY_MAP` from inp.ts for reuse in the new web vital spans module. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser-utils/src/metrics/inp.ts | 2 +- .../browser-utils/src/metrics/instrument.ts | 21 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index 831565f07408..f6411ef8544d 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -54,7 +54,7 @@ export function startTrackingINP(): () => void { return () => undefined; } -const INP_ENTRY_MAP: Record = { +export const INP_ENTRY_MAP: Record = { click: 'click', pointerdown: 'click', pointerup: 'click', diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 4c461ec6776c..4b4be90f191b 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -4,6 +4,7 @@ import { onCLS } from './web-vitals/getCLS'; import { onINP } from './web-vitals/getINP'; import { onLCP } from './web-vitals/getLCP'; import { observe } from './web-vitals/lib/observe'; +import { onFCP } from './web-vitals/onFCP'; import { onTTFB } from './web-vitals/onTTFB'; type InstrumentHandlerTypePerformanceObserver = @@ -16,7 +17,7 @@ type InstrumentHandlerTypePerformanceObserver = // fist-input is still needed for INP | 'first-input'; -type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'ttfb' | 'inp'; +type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'ttfb' | 'inp' | 'fcp'; // We provide this here manually instead of relying on a global, as this is not available in non-browser environements // And we do not want to expose such types @@ -114,6 +115,7 @@ let _previousCls: Metric | undefined; let _previousLcp: Metric | undefined; let _previousTtfb: Metric | undefined; let _previousInp: Metric | undefined; +let _previousFcp: Metric | undefined; /** * Add a callback that will be triggered when a CLS metric is available. @@ -164,6 +166,14 @@ export function addInpInstrumentationHandler(callback: InstrumentationHandlerCal return addMetricObserver('inp', callback, instrumentInp, _previousInp); } +/** + * Add a callback that will be triggered when a FCP metric is available. + * Returns a cleanup callback which can be called to remove the instrumentation handler. + */ +export function addFcpInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback { + return addMetricObserver('fcp', callback, instrumentFcp, _previousFcp); +} + export function addPerformanceInstrumentationHandler( type: 'event', callback: (data: { entries: ((PerformanceEntry & { target?: unknown | null }) | PerformanceEventTiming)[] }) => void, @@ -259,6 +269,15 @@ function instrumentInp(): void { }); } +function instrumentFcp(): StopListening { + return onFCP(metric => { + triggerHandlers('fcp', { + metric, + }); + _previousFcp = metric; + }); +} + function addMetricObserver( type: InstrumentHandlerTypeMetric, callback: InstrumentHandlerCallback, From 728f0eccfec5656485d69543d3db4ad90457cf5d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 16 Mar 2026 13:42:21 -0400 Subject: [PATCH 02/14] feat(browser): Emit web vitals as streamed spans when span streaming is enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add non-standalone web vital spans that flow through the v2 span streaming pipeline (afterSpanEnd -> captureSpan -> SpanBuffer). Each web vital gets `browser.web_vital..value` attributes and span events for measurement extraction. Spans have meaningful durations showing time from navigation start to the web vital event (except CLS which is a score, not a duration). New tracking functions: trackLcpAsSpan, trackClsAsSpan, trackInpAsSpan, trackTtfbAsSpan, trackFcpAsSpan, trackFpAsSpan — wired up in browserTracingIntegration.setup() when hasSpanStreamingEnabled(client). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser-utils/src/index.ts | 10 + .../src/metrics/webVitalSpans.ts | 377 +++++++++++++ .../test/metrics/webVitalSpans.test.ts | 525 ++++++++++++++++++ .../src/tracing/browserTracingIntegration.ts | 16 + 4 files changed, 928 insertions(+) create mode 100644 packages/browser-utils/src/metrics/webVitalSpans.ts create mode 100644 packages/browser-utils/test/metrics/webVitalSpans.test.ts diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 2b2d4b7f9397..bbe5dc56c8e9 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -4,6 +4,7 @@ export { addTtfbInstrumentationHandler, addLcpInstrumentationHandler, addInpInstrumentationHandler, + addFcpInstrumentationHandler, } from './metrics/instrument'; export { @@ -20,6 +21,15 @@ export { elementTimingIntegration, startTrackingElementTiming } from './metrics/ export { extractNetworkProtocol } from './metrics/utils'; +export { + trackClsAsSpan, + trackFcpAsSpan, + trackFpAsSpan, + trackInpAsSpan, + trackLcpAsSpan, + trackTtfbAsSpan, +} from './metrics/webVitalSpans'; + export { addClickKeypressInstrumentationHandler } from './instrument/dom'; export { addHistoryInstrumentationHandler } from './instrument/history'; diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts new file mode 100644 index 000000000000..e7d62874d0d5 --- /dev/null +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -0,0 +1,377 @@ +import type { Client, SpanAttributes } from '@sentry/core'; +import { + browserPerformanceTimeOrigin, + debug, + getActiveSpan, + getCurrentScope, + getRootSpan, + htmlTreeAsString, + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, + startInactiveSpan, + timestampInSeconds, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../types'; +import { INP_ENTRY_MAP } from './inp'; +import type { InstrumentationHandlerCallback } from './instrument'; +import { + addClsInstrumentationHandler, + addFcpInstrumentationHandler, + addInpInstrumentationHandler, + addLcpInstrumentationHandler, + addPerformanceInstrumentationHandler, + addTtfbInstrumentationHandler, +} from './instrument'; +import { listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils'; +import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher'; + +interface WebVitalSpanOptions { + name: string; + op: string; + origin: string; + metricName: string; + value: number; + unit: string; + attributes?: SpanAttributes; + pageloadSpanId?: string; + startTime: number; + endTime?: number; +} + +/** + * Emits a web vital span that flows through the span streaming pipeline. + */ +export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { + const { + name, + op, + origin, + metricName, + value, + unit, + attributes: passedAttributes, + pageloadSpanId, + startTime, + endTime, + } = options; + + const routeName = getCurrentScope().getScopeData().transactionName; + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, + [`browser.web_vital.${metricName}.value`]: value, + transaction: routeName, + // Web vital score calculation relies on the user agent + 'user_agent.original': WINDOW.navigator?.userAgent, + ...passedAttributes, + }; + + if (pageloadSpanId) { + attributes['sentry.pageload.span_id'] = pageloadSpanId; + } + + const span = startInactiveSpan({ + name, + attributes, + startTime, + }); + + if (span) { + span.addEvent(metricName, { + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: unit, + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: value, + }); + + span.end(endTime ?? startTime); + } +} + +/** + * Tracks LCP as a streamed span. + */ +export function trackLcpAsSpan(client: Client): void { + let lcpValue = 0; + let lcpEntry: LargestContentfulPaint | undefined; + + if (!supportsWebVital('largest-contentful-paint')) { + return; + } + + const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; + if (!entry) { + return; + } + lcpValue = metric.value; + lcpEntry = entry; + }, true); + + listenForWebVitalReportEvents(client, (_reportEvent, pageloadSpanId) => { + _sendLcpSpan(lcpValue, lcpEntry, pageloadSpanId); + cleanupLcpHandler(); + }); +} + +/** + * Exported only for testing. + */ +export function _sendLcpSpan( + lcpValue: number, + entry: LargestContentfulPaint | undefined, + pageloadSpanId: string, +): void { + DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`); + + const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); + const endTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0)); + const name = entry ? htmlTreeAsString(entry.element) : 'Largest contentful paint'; + + const attributes: SpanAttributes = {}; + + if (entry) { + entry.element && (attributes['browser.web_vital.lcp.element'] = htmlTreeAsString(entry.element)); + entry.id && (attributes['browser.web_vital.lcp.id'] = entry.id); + entry.url && (attributes['browser.web_vital.lcp.url'] = entry.url); + entry.loadTime != null && (attributes['browser.web_vital.lcp.load_time'] = entry.loadTime); + entry.renderTime != null && (attributes['browser.web_vital.lcp.render_time'] = entry.renderTime); + entry.size != null && (attributes['browser.web_vital.lcp.size'] = entry.size); + } + + _emitWebVitalSpan({ + name, + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', + value: lcpValue, + unit: 'millisecond', + attributes, + pageloadSpanId, + startTime: timeOrigin, + endTime, + }); +} + +/** + * Tracks CLS as a streamed span. + */ +export function trackClsAsSpan(client: Client): void { + let clsValue = 0; + let clsEntry: LayoutShift | undefined; + + if (!supportsWebVital('layout-shift')) { + return; + } + + const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; + if (!entry) { + return; + } + clsValue = metric.value; + clsEntry = entry; + }, true); + + listenForWebVitalReportEvents(client, (_reportEvent, pageloadSpanId) => { + _sendClsSpan(clsValue, clsEntry, pageloadSpanId); + cleanupClsHandler(); + }); +} + +/** + * Exported only for testing. + */ +export function _sendClsSpan(clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string): void { + DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`); + + const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds(); + const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift'; + + const attributes: SpanAttributes = {}; + + if (entry?.sources) { + entry.sources.forEach((source, index) => { + attributes[`browser.web_vital.cls.source.${index + 1}`] = htmlTreeAsString(source.node); + }); + } + + _emitWebVitalSpan({ + name, + op: 'ui.webvital.cls', + origin: 'auto.http.browser.cls', + metricName: 'cls', + value: clsValue, + unit: '', + attributes, + pageloadSpanId, + startTime, + }); +} + +/** + * Tracks INP as a streamed span. + */ +export function trackInpAsSpan(_client: Client): void { + const onInp: InstrumentationHandlerCallback = ({ metric }) => { + if (metric.value == null) { + return; + } + + const entry = metric.entries.find(e => e.duration === metric.value && INP_ENTRY_MAP[e.name]); + + if (!entry) { + return; + } + + _sendInpSpan(metric.value, entry); + }; + + addInpInstrumentationHandler(onInp); +} + +/** + * Exported only for testing. + */ +export function _sendInpSpan( + inpValue: number, + entry: { name: string; startTime: number; duration: number; target?: unknown | null }, +): void { + DEBUG_BUILD && debug.log(`Sending INP span (${inpValue})`); + + const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime); + const interactionType = INP_ENTRY_MAP[entry.name]; + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + const routeName = rootSpan ? spanToJSON(rootSpan).description : getCurrentScope().getScopeData().transactionName; + const name = htmlTreeAsString(entry.target); + + _emitWebVitalSpan({ + name, + op: `ui.interaction.${interactionType}`, + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: inpValue, + unit: 'millisecond', + attributes: { + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration, + transaction: routeName, + }, + startTime, + endTime: startTime + msToSec(entry.duration), + }); +} + +/** + * Tracks TTFB as a streamed span. + */ +export function trackTtfbAsSpan(client: Client): void { + addTtfbInstrumentationHandler(({ metric }) => { + _sendTtfbSpan(metric.value, client); + }); +} + +/** + * Exported only for testing. + */ +export function _sendTtfbSpan(ttfbValue: number, _client: Client): void { + DEBUG_BUILD && debug.log(`Sending TTFB span (${ttfbValue})`); + + const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); + + const attributes: SpanAttributes = {}; + + // Try to get request_time from navigation timing + try { + const navEntry = WINDOW.performance?.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined; + if (navEntry) { + attributes['browser.web_vital.ttfb.request_time'] = navEntry.responseStart - navEntry.requestStart; + } + } catch { + // ignore + } + + _emitWebVitalSpan({ + name: 'TTFB', + op: 'ui.webvital.ttfb', + origin: 'auto.http.browser.ttfb', + metricName: 'ttfb', + value: ttfbValue, + unit: 'millisecond', + attributes, + startTime: timeOrigin, + endTime: timeOrigin + msToSec(ttfbValue), + }); +} + +/** + * Tracks FCP as a streamed span. + */ +export function trackFcpAsSpan(_client: Client): void { + addFcpInstrumentationHandler(({ metric }) => { + _sendFcpSpan(metric.value); + }); +} + +/** + * Exported only for testing. + */ +export function _sendFcpSpan(fcpValue: number): void { + DEBUG_BUILD && debug.log(`Sending FCP span (${fcpValue})`); + + const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); + + _emitWebVitalSpan({ + name: 'FCP', + op: 'ui.webvital.fcp', + origin: 'auto.http.browser.fcp', + metricName: 'fcp', + value: fcpValue, + unit: 'millisecond', + startTime: timeOrigin, + endTime: timeOrigin + msToSec(fcpValue), + }); +} + +/** + * Tracks FP (First Paint) as a streamed span. + */ +export function trackFpAsSpan(_client: Client): void { + const visibilityWatcher = getVisibilityWatcher(); + + addPerformanceInstrumentationHandler('paint', ({ entries }) => { + for (const entry of entries) { + if (entry.name === 'first-paint') { + if (entry.startTime < visibilityWatcher.firstHiddenTime) { + _sendFpSpan(entry.startTime); + } + break; + } + } + }); +} + +/** + * Exported only for testing. + */ +export function _sendFpSpan(fpStartTime: number): void { + DEBUG_BUILD && debug.log(`Sending FP span (${fpStartTime})`); + + const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); + + _emitWebVitalSpan({ + name: 'FP', + op: 'ui.webvital.fp', + origin: 'auto.http.browser.fp', + metricName: 'fp', + value: fpStartTime, + unit: 'millisecond', + startTime: timeOrigin, + endTime: timeOrigin + msToSec(fpStartTime), + }); +} diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts new file mode 100644 index 000000000000..2e369c76b1ac --- /dev/null +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -0,0 +1,525 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _emitWebVitalSpan, + _sendClsSpan, + _sendFcpSpan, + _sendFpSpan, + _sendInpSpan, + _sendLcpSpan, + _sendTtfbSpan, +} from '../../src/metrics/webVitalSpans'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + browserPerformanceTimeOrigin: vi.fn(), + timestampInSeconds: vi.fn(), + getCurrentScope: vi.fn(), + htmlTreeAsString: vi.fn(), + startInactiveSpan: vi.fn(), + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + spanToJSON: vi.fn(), + }; +}); + +// Mock WINDOW +vi.mock('../../src/types', () => ({ + WINDOW: { + navigator: { userAgent: 'test-user-agent' }, + performance: { + getEntriesByType: vi.fn().mockReturnValue([]), + }, + }, +})); + +describe('_emitWebVitalSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-transaction', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('creates a non-standalone span with correct attributes', () => { + _emitWebVitalSpan({ + name: 'Test Vital', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 100, + unit: 'millisecond', + startTime: 1.5, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({ + name: 'Test Vital', + attributes: { + 'sentry.origin': 'auto.http.browser.test', + 'sentry.op': 'ui.webvital.test', + 'sentry.exclusive_time': 0, + 'browser.web_vital.test.value': 100, + transaction: 'test-transaction', + 'user_agent.original': 'test-user-agent', + }, + startTime: 1.5, + }); + + // No standalone flag + expect(SentryCore.startInactiveSpan).not.toHaveBeenCalledWith( + expect.objectContaining({ experimental: expect.anything() }), + ); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('test', { + 'sentry.measurement_unit': 'millisecond', + 'sentry.measurement_value': 100, + }); + + expect(mockSpan.end).toHaveBeenCalledWith(1.5); + }); + + it('includes pageloadSpanId when provided', () => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 50, + unit: 'millisecond', + pageloadSpanId: 'abc123', + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.pageload.span_id': 'abc123', + }), + }), + ); + }); + + it('merges additional attributes', () => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 50, + unit: 'millisecond', + attributes: { 'custom.attr': 'value' }, + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'custom.attr': 'value', + }), + }), + ); + }); + + it('handles when startInactiveSpan returns undefined', () => { + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(undefined as any); + + expect(() => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 50, + unit: 'millisecond', + startTime: 1.0, + }); + }).not.toThrow(); + }); +}); + +describe('_sendLcpSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sends a streamed LCP span with entry data', () => { + const mockEntry = { + element: { tagName: 'img' } as Element, + id: 'hero', + url: 'https://example.com/hero.jpg', + loadTime: 100, + renderTime: 150, + size: 50000, + startTime: 200, + } as LargestContentfulPaint; + + _sendLcpSpan(250, mockEntry, 'pageload-123'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.op': 'ui.webvital.lcp', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': 'pageload-123', + 'browser.web_vital.lcp.element': '', + 'browser.web_vital.lcp.id': 'hero', + 'browser.web_vital.lcp.url': 'https://example.com/hero.jpg', + 'browser.web_vital.lcp.load_time': 100, + 'browser.web_vital.lcp.render_time': 150, + 'browser.web_vital.lcp.size': 50000, + }), + startTime: 1, // timeOrigin: 1000 / 1000 + }), + ); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('lcp', { + 'sentry.measurement_unit': 'millisecond', + 'sentry.measurement_value': 250, + }); + + // endTime = timeOrigin + entry.startTime = (1000 + 200) / 1000 = 1.2 + expect(mockSpan.end).toHaveBeenCalledWith(1.2); + }); + + it('sends a streamed LCP span without entry data', () => { + _sendLcpSpan(0, undefined, 'pageload-456'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Largest contentful paint', + startTime: 1, // timeOrigin: 1000 / 1000 + }), + ); + }); +}); + +describe('_sendClsSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); + vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sends a streamedCLS span with entry data and sources', () => { + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 100, + duration: 0, + value: 0.1, + hadRecentInput: false, + sources: [ + // @ts-expect-error - other properties are irrelevant + { node: { tagName: 'div' } as Element }, + // @ts-expect-error - other properties are irrelevant + { node: { tagName: 'span' } as Element }, + ], + toJSON: vi.fn(), + }; + + vi.mocked(SentryCore.htmlTreeAsString) + .mockReturnValueOnce('
') // for the name + .mockReturnValueOnce('
') // for source 1 + .mockReturnValueOnce(''); // for source 2 + + _sendClsSpan(0.1, mockEntry, 'pageload-789'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '
', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.pageload.span_id': 'pageload-789', + 'browser.web_vital.cls.source.1': '
', + 'browser.web_vital.cls.source.2': '', + }), + }), + ); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', { + 'sentry.measurement_unit': '', + 'sentry.measurement_value': 0.1, + }); + }); + + it('sends a streamedCLS span without entry data', () => { + _sendClsSpan(0, undefined, 'pageload-000'); + + expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Layout shift', + startTime: 1.5, + }), + ); + }); +}); + +describe('_sendInpSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.htmlTreeAsString).mockReturnValue(' + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/test.ts new file mode 100644 index 000000000000..30112f875ab8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/test.ts @@ -0,0 +1,84 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +sentryTest.beforeEach(async ({ browserName }) => { + if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') { + sentryTest.skip(); + } +}); + +sentryTest('captures FCP as a streamed span with duration from navigation start', async ({ getLocalTestUrl, page }) => { + const fcpSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => { + const spans = getSpansFromEnvelope(env); + return spans.some(s => getSpanOp(s) === 'ui.webvital.fcp'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const fcpEnvelope = await fcpSpanEnvelopePromise; + const fcpSpan = getSpansFromEnvelope(fcpEnvelope).find(s => getSpanOp(s) === 'ui.webvital.fcp')!; + + expect(fcpSpan).toBeDefined(); + expect(fcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.fcp' }); + expect(fcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.fcp' }); + expect(fcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); + expect(fcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome')); + expect(fcpSpan.name).toBe('FCP'); + expect(fcpSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(fcpSpan.trace_id).toMatch(/^[\da-f]{32}$/); + + // Span should have meaningful duration (navigation start -> FCP event) + expect(fcpSpan.end_timestamp).toBeGreaterThan(fcpSpan.start_timestamp); +}); + +sentryTest('captures FP as a streamed span with duration from navigation start', async ({ getLocalTestUrl, page }) => { + const fpSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => { + const spans = getSpansFromEnvelope(env); + return spans.some(s => getSpanOp(s) === 'ui.webvital.fp'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const fpEnvelope = await fpSpanEnvelopePromise; + const fpSpan = getSpansFromEnvelope(fpEnvelope).find(s => getSpanOp(s) === 'ui.webvital.fp')!; + + expect(fpSpan).toBeDefined(); + expect(fpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.fp' }); + expect(fpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.fp' }); + expect(fpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); + expect(fpSpan.name).toBe('FP'); + expect(fpSpan.span_id).toMatch(/^[\da-f]{16}$/); + + // Span should have meaningful duration (navigation start -> FP event) + expect(fpSpan.end_timestamp).toBeGreaterThan(fpSpan.start_timestamp); +}); + +sentryTest( + 'captures TTFB as a streamed span with duration from navigation start', + async ({ getLocalTestUrl, page }) => { + const ttfbSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => { + const spans = getSpansFromEnvelope(env); + return spans.some(s => getSpanOp(s) === 'ui.webvital.ttfb'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const ttfbEnvelope = await ttfbSpanEnvelopePromise; + const ttfbSpan = getSpansFromEnvelope(ttfbEnvelope).find(s => getSpanOp(s) === 'ui.webvital.ttfb')!; + + expect(ttfbSpan).toBeDefined(); + expect(ttfbSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.ttfb' }); + expect(ttfbSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.ttfb' }); + expect(ttfbSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); + expect(ttfbSpan.name).toBe('TTFB'); + expect(ttfbSpan.span_id).toMatch(/^[\da-f]{16}$/); + + // Span should have meaningful duration (navigation start -> first byte) + expect(ttfbSpan.end_timestamp).toBeGreaterThan(ttfbSpan.start_timestamp); + }, +); From 6f0add9eb6a991e0f0c9fd6ad48894a23791bbc5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 14:12:32 -0400 Subject: [PATCH 04/14] fix(browser): Only emit LCP, CLS, INP as streamed spans; disable standalone spans when streaming TTFB, FCP, and FP should remain as attributes on the pageload span rather than separate streamed spans. Also ensures standalone CLS/LCP spans are disabled when span streaming is enabled to prevent duplicate spans. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../metrics/web-vitals-streamed-spans/init.js | 11 -- .../web-vitals-streamed-spans/template.html | 10 -- .../metrics/web-vitals-streamed-spans/test.ts | 84 ---------- packages/browser-utils/src/index.ts | 10 +- .../src/metrics/webVitalSpans.ts | 119 +------------- .../test/metrics/webVitalSpans.test.ts | 150 +----------------- .../src/tracing/browserTracingIntegration.ts | 14 +- 7 files changed, 8 insertions(+), 390 deletions(-) delete mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/init.js delete mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/template.html delete mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/init.js deleted file mode 100644 index bd3b6ed17872..000000000000 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/init.js +++ /dev/null @@ -1,11 +0,0 @@ -import * as Sentry from '@sentry/browser'; - -window.Sentry = Sentry; -window._testBaseTimestamp = performance.timeOrigin / 1000; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], - traceLifecycle: 'stream', - tracesSampleRate: 1, -}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/template.html deleted file mode 100644 index 0a94c016ff92..000000000000 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/template.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - -
Hello World
- - - diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/test.ts deleted file mode 100644 index 30112f875ab8..000000000000 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { expect } from '@playwright/test'; -import { sentryTest } from '../../../../utils/fixtures'; -import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; -import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; - -sentryTest.beforeEach(async ({ browserName }) => { - if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') { - sentryTest.skip(); - } -}); - -sentryTest('captures FCP as a streamed span with duration from navigation start', async ({ getLocalTestUrl, page }) => { - const fcpSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => { - const spans = getSpansFromEnvelope(env); - return spans.some(s => getSpanOp(s) === 'ui.webvital.fcp'); - }); - - const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - - const fcpEnvelope = await fcpSpanEnvelopePromise; - const fcpSpan = getSpansFromEnvelope(fcpEnvelope).find(s => getSpanOp(s) === 'ui.webvital.fcp')!; - - expect(fcpSpan).toBeDefined(); - expect(fcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.fcp' }); - expect(fcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.fcp' }); - expect(fcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); - expect(fcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome')); - expect(fcpSpan.name).toBe('FCP'); - expect(fcpSpan.span_id).toMatch(/^[\da-f]{16}$/); - expect(fcpSpan.trace_id).toMatch(/^[\da-f]{32}$/); - - // Span should have meaningful duration (navigation start -> FCP event) - expect(fcpSpan.end_timestamp).toBeGreaterThan(fcpSpan.start_timestamp); -}); - -sentryTest('captures FP as a streamed span with duration from navigation start', async ({ getLocalTestUrl, page }) => { - const fpSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => { - const spans = getSpansFromEnvelope(env); - return spans.some(s => getSpanOp(s) === 'ui.webvital.fp'); - }); - - const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - - const fpEnvelope = await fpSpanEnvelopePromise; - const fpSpan = getSpansFromEnvelope(fpEnvelope).find(s => getSpanOp(s) === 'ui.webvital.fp')!; - - expect(fpSpan).toBeDefined(); - expect(fpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.fp' }); - expect(fpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.fp' }); - expect(fpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); - expect(fpSpan.name).toBe('FP'); - expect(fpSpan.span_id).toMatch(/^[\da-f]{16}$/); - - // Span should have meaningful duration (navigation start -> FP event) - expect(fpSpan.end_timestamp).toBeGreaterThan(fpSpan.start_timestamp); -}); - -sentryTest( - 'captures TTFB as a streamed span with duration from navigation start', - async ({ getLocalTestUrl, page }) => { - const ttfbSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => { - const spans = getSpansFromEnvelope(env); - return spans.some(s => getSpanOp(s) === 'ui.webvital.ttfb'); - }); - - const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - - const ttfbEnvelope = await ttfbSpanEnvelopePromise; - const ttfbSpan = getSpansFromEnvelope(ttfbEnvelope).find(s => getSpanOp(s) === 'ui.webvital.ttfb')!; - - expect(ttfbSpan).toBeDefined(); - expect(ttfbSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.ttfb' }); - expect(ttfbSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.ttfb' }); - expect(ttfbSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); - expect(ttfbSpan.name).toBe('TTFB'); - expect(ttfbSpan.span_id).toMatch(/^[\da-f]{16}$/); - - // Span should have meaningful duration (navigation start -> first byte) - expect(ttfbSpan.end_timestamp).toBeGreaterThan(ttfbSpan.start_timestamp); - }, -); diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index bbe5dc56c8e9..888524ed7c21 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -4,7 +4,6 @@ export { addTtfbInstrumentationHandler, addLcpInstrumentationHandler, addInpInstrumentationHandler, - addFcpInstrumentationHandler, } from './metrics/instrument'; export { @@ -21,14 +20,7 @@ export { elementTimingIntegration, startTrackingElementTiming } from './metrics/ export { extractNetworkProtocol } from './metrics/utils'; -export { - trackClsAsSpan, - trackFcpAsSpan, - trackFpAsSpan, - trackInpAsSpan, - trackLcpAsSpan, - trackTtfbAsSpan, -} from './metrics/webVitalSpans'; +export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './metrics/webVitalSpans'; export { addClickKeypressInstrumentationHandler } from './instrument/dom'; diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index e7d62874d0d5..0c4f2d98c564 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -19,16 +19,8 @@ import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../types'; import { INP_ENTRY_MAP } from './inp'; import type { InstrumentationHandlerCallback } from './instrument'; -import { - addClsInstrumentationHandler, - addFcpInstrumentationHandler, - addInpInstrumentationHandler, - addLcpInstrumentationHandler, - addPerformanceInstrumentationHandler, - addTtfbInstrumentationHandler, -} from './instrument'; +import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler } from './instrument'; import { listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils'; -import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher'; interface WebVitalSpanOptions { name: string; @@ -266,112 +258,3 @@ export function _sendInpSpan( endTime: startTime + msToSec(entry.duration), }); } - -/** - * Tracks TTFB as a streamed span. - */ -export function trackTtfbAsSpan(client: Client): void { - addTtfbInstrumentationHandler(({ metric }) => { - _sendTtfbSpan(metric.value, client); - }); -} - -/** - * Exported only for testing. - */ -export function _sendTtfbSpan(ttfbValue: number, _client: Client): void { - DEBUG_BUILD && debug.log(`Sending TTFB span (${ttfbValue})`); - - const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); - - const attributes: SpanAttributes = {}; - - // Try to get request_time from navigation timing - try { - const navEntry = WINDOW.performance?.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined; - if (navEntry) { - attributes['browser.web_vital.ttfb.request_time'] = navEntry.responseStart - navEntry.requestStart; - } - } catch { - // ignore - } - - _emitWebVitalSpan({ - name: 'TTFB', - op: 'ui.webvital.ttfb', - origin: 'auto.http.browser.ttfb', - metricName: 'ttfb', - value: ttfbValue, - unit: 'millisecond', - attributes, - startTime: timeOrigin, - endTime: timeOrigin + msToSec(ttfbValue), - }); -} - -/** - * Tracks FCP as a streamed span. - */ -export function trackFcpAsSpan(_client: Client): void { - addFcpInstrumentationHandler(({ metric }) => { - _sendFcpSpan(metric.value); - }); -} - -/** - * Exported only for testing. - */ -export function _sendFcpSpan(fcpValue: number): void { - DEBUG_BUILD && debug.log(`Sending FCP span (${fcpValue})`); - - const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); - - _emitWebVitalSpan({ - name: 'FCP', - op: 'ui.webvital.fcp', - origin: 'auto.http.browser.fcp', - metricName: 'fcp', - value: fcpValue, - unit: 'millisecond', - startTime: timeOrigin, - endTime: timeOrigin + msToSec(fcpValue), - }); -} - -/** - * Tracks FP (First Paint) as a streamed span. - */ -export function trackFpAsSpan(_client: Client): void { - const visibilityWatcher = getVisibilityWatcher(); - - addPerformanceInstrumentationHandler('paint', ({ entries }) => { - for (const entry of entries) { - if (entry.name === 'first-paint') { - if (entry.startTime < visibilityWatcher.firstHiddenTime) { - _sendFpSpan(entry.startTime); - } - break; - } - } - }); -} - -/** - * Exported only for testing. - */ -export function _sendFpSpan(fpStartTime: number): void { - DEBUG_BUILD && debug.log(`Sending FP span (${fpStartTime})`); - - const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); - - _emitWebVitalSpan({ - name: 'FP', - op: 'ui.webvital.fp', - origin: 'auto.http.browser.fp', - metricName: 'fp', - value: fpStartTime, - unit: 'millisecond', - startTime: timeOrigin, - endTime: timeOrigin + msToSec(fpStartTime), - }); -} diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts index 2e369c76b1ac..44f91a779b64 100644 --- a/packages/browser-utils/test/metrics/webVitalSpans.test.ts +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -1,14 +1,6 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - _emitWebVitalSpan, - _sendClsSpan, - _sendFcpSpan, - _sendFpSpan, - _sendInpSpan, - _sendLcpSpan, - _sendTtfbSpan, -} from '../../src/metrics/webVitalSpans'; +import { _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan } from '../../src/metrics/webVitalSpans'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -383,143 +375,3 @@ describe('_sendInpSpan', () => { ); }); }); - -describe('_sendTtfbSpan', () => { - const mockSpan = { - addEvent: vi.fn(), - end: vi.fn(), - }; - - const mockScope = { - getScopeData: vi.fn().mockReturnValue({ - transactionName: 'test-route', - }), - }; - - const mockClient = {} as any; - - beforeEach(() => { - vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); - vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); - vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('sends a streamed TTFB span with duration from navigation start to first byte', () => { - _sendTtfbSpan(300, mockClient); - - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'TTFB', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.http.browser.ttfb', - 'sentry.op': 'ui.webvital.ttfb', - }), - startTime: 1, // timeOrigin: 1000 / 1000 - }), - ); - - expect(mockSpan.addEvent).toHaveBeenCalledWith('ttfb', { - 'sentry.measurement_unit': 'millisecond', - 'sentry.measurement_value': 300, - }); - - // endTime = timeOrigin + ttfbValue = 1 + 300/1000 = 1.3 - expect(mockSpan.end).toHaveBeenCalledWith(1.3); - }); -}); - -describe('_sendFcpSpan', () => { - const mockSpan = { - addEvent: vi.fn(), - end: vi.fn(), - }; - - const mockScope = { - getScopeData: vi.fn().mockReturnValue({ - transactionName: 'test-route', - }), - }; - - beforeEach(() => { - vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); - vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); - vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('sends a streamed FCP span with duration from navigation start to first contentful paint', () => { - _sendFcpSpan(1200); - - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'FCP', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.http.browser.fcp', - 'sentry.op': 'ui.webvital.fcp', - }), - startTime: 1, // timeOrigin: 1000 / 1000 - }), - ); - - expect(mockSpan.addEvent).toHaveBeenCalledWith('fcp', { - 'sentry.measurement_unit': 'millisecond', - 'sentry.measurement_value': 1200, - }); - - // endTime = timeOrigin + fcpValue = 1 + 1200/1000 = 2.2 - expect(mockSpan.end).toHaveBeenCalledWith(2.2); - }); -}); - -describe('_sendFpSpan', () => { - const mockSpan = { - addEvent: vi.fn(), - end: vi.fn(), - }; - - const mockScope = { - getScopeData: vi.fn().mockReturnValue({ - transactionName: 'test-route', - }), - }; - - beforeEach(() => { - vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); - vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); - vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('sends a streamed FP span with duration from navigation start to first paint', () => { - _sendFpSpan(800); - - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'FP', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.http.browser.fp', - 'sentry.op': 'ui.webvital.fp', - }), - startTime: 1, // timeOrigin: 1000 / 1000 - }), - ); - - expect(mockSpan.addEvent).toHaveBeenCalledWith('fp', { - 'sentry.measurement_unit': 'millisecond', - 'sentry.measurement_value': 800, - }); - - // endTime = timeOrigin + fpValue = 1 + 800/1000 = 1.8 - expect(mockSpan.end).toHaveBeenCalledWith(1.8); - }); -}); diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 27bd4ad4cda6..012a513aa51d 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -47,11 +47,8 @@ import { startTrackingLongTasks, startTrackingWebVitals, trackClsAsSpan, - trackFcpAsSpan, - trackFpAsSpan, trackInpAsSpan, trackLcpAsSpan, - trackTtfbAsSpan, } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; import { getHttpRequestData, WINDOW } from '../helpers'; @@ -521,19 +518,18 @@ export const browserTracingIntegration = ((options: Partial Date: Mon, 23 Mar 2026 14:13:17 -0400 Subject: [PATCH 05/14] fix(browser): Add MAX_PLAUSIBLE_INP_DURATION check to streamed INP span path The standalone INP handler filters out unrealistically long INP values (>60s) but the streamed span path was missing this sanity check. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser-utils/src/metrics/webVitalSpans.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index 0c4f2d98c564..e3120cfc7f9c 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -22,6 +22,9 @@ import type { InstrumentationHandlerCallback } from './instrument'; import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler } from './instrument'; import { listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils'; +// Maximum plausible INP duration in seconds (matches standalone INP handler) +const MAX_PLAUSIBLE_INP_DURATION = 60; + interface WebVitalSpanOptions { name: string; op: string; @@ -215,6 +218,11 @@ export function trackInpAsSpan(_client: Client): void { return; } + // Guard against unrealistically long INP values (matching standalone INP handler) + if (msToSec(metric.value) > MAX_PLAUSIBLE_INP_DURATION) { + return; + } + const entry = metric.entries.find(e => e.duration === metric.value && INP_ENTRY_MAP[e.name]); if (!entry) { From 41d2d5a54eb7ed6cdac04f29d3e25229a933526c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 14:35:04 -0400 Subject: [PATCH 06/14] fix(browser): Prevent duplicate INP spans when span streaming is enabled Gate standalone INP (`startTrackingINP`) behind `!spanStreamingEnabled` and gate streamed INP (`trackInpAsSpan`) behind `enableInp` so both paths respect the user's preference and don't produce duplicate data. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/tracing/browserTracingIntegration.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 012a513aa51d..f8ea13d2728a 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -529,10 +529,12 @@ export const browserTracingIntegration = ((options: Partial Date: Mon, 23 Mar 2026 14:35:09 -0400 Subject: [PATCH 07/14] fix(browser-utils): Remove dead FCP instrumentation code Remove `addFcpInstrumentationHandler`, `instrumentFcp`, and `_previousFcp` which were added to support FCP streamed spans but are no longer called after FCP spans were removed from the implementation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../browser-utils/src/metrics/instrument.ts | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 4b4be90f191b..5dc9d78f4ce8 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -4,7 +4,6 @@ import { onCLS } from './web-vitals/getCLS'; import { onINP } from './web-vitals/getINP'; import { onLCP } from './web-vitals/getLCP'; import { observe } from './web-vitals/lib/observe'; -import { onFCP } from './web-vitals/onFCP'; import { onTTFB } from './web-vitals/onTTFB'; type InstrumentHandlerTypePerformanceObserver = @@ -17,7 +16,7 @@ type InstrumentHandlerTypePerformanceObserver = // fist-input is still needed for INP | 'first-input'; -type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'ttfb' | 'inp' | 'fcp'; +type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'ttfb' | 'inp'; // We provide this here manually instead of relying on a global, as this is not available in non-browser environements // And we do not want to expose such types @@ -115,8 +114,6 @@ let _previousCls: Metric | undefined; let _previousLcp: Metric | undefined; let _previousTtfb: Metric | undefined; let _previousInp: Metric | undefined; -let _previousFcp: Metric | undefined; - /** * Add a callback that will be triggered when a CLS metric is available. * Returns a cleanup callback which can be called to remove the instrumentation handler. @@ -166,14 +163,6 @@ export function addInpInstrumentationHandler(callback: InstrumentationHandlerCal return addMetricObserver('inp', callback, instrumentInp, _previousInp); } -/** - * Add a callback that will be triggered when a FCP metric is available. - * Returns a cleanup callback which can be called to remove the instrumentation handler. - */ -export function addFcpInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback { - return addMetricObserver('fcp', callback, instrumentFcp, _previousFcp); -} - export function addPerformanceInstrumentationHandler( type: 'event', callback: (data: { entries: ((PerformanceEntry & { target?: unknown | null }) | PerformanceEventTiming)[] }) => void, @@ -269,15 +258,6 @@ function instrumentInp(): void { }); } -function instrumentFcp(): StopListening { - return onFCP(metric => { - triggerHandlers('fcp', { - metric, - }); - _previousFcp = metric; - }); -} - function addMetricObserver( type: InstrumentHandlerTypeMetric, callback: InstrumentHandlerCallback, From 949df64605d1e6c5bba2e78d50e8b93354d4ae1b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 14:35:43 -0400 Subject: [PATCH 08/14] fix(browser-utils): Add fallback for browserPerformanceTimeOrigin in _sendInpSpan Use `|| 0` fallback instead of `as number` cast, consistent with the LCP and CLS span handlers that already guard against undefined. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser-utils/src/metrics/webVitalSpans.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index e3120cfc7f9c..665123875398 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -244,7 +244,7 @@ export function _sendInpSpan( ): void { DEBUG_BUILD && debug.log(`Sending INP span (${inpValue})`); - const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime); + const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime); const interactionType = INP_ENTRY_MAP[entry.name]; const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; From 211790f4a3cdf1287763b92bfab5984c97972a62 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 14:35:55 -0400 Subject: [PATCH 09/14] fix(browser-utils): Cache browserPerformanceTimeOrigin call in _sendLcpSpan Avoid calling browserPerformanceTimeOrigin() twice by caching the result in a local variable. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser-utils/src/metrics/webVitalSpans.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index 665123875398..5af964f8bb2a 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -124,8 +124,9 @@ export function _sendLcpSpan( ): void { DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`); - const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); - const endTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0)); + const performanceTimeOrigin = browserPerformanceTimeOrigin() || 0; + const timeOrigin = msToSec(performanceTimeOrigin); + const endTime = msToSec(performanceTimeOrigin + (entry?.startTime || 0)); const name = entry ? htmlTreeAsString(entry.element) : 'Largest contentful paint'; const attributes: SpanAttributes = {}; From 739fcf4572660aa2618035cc406eafe36612c5f1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 24 Mar 2026 10:54:00 -0400 Subject: [PATCH 10/14] fix(browser): Skip INP interaction listeners when span streaming is enabled The streamed INP path does not use INTERACTIONS_SPAN_MAP or ELEMENT_NAME_TIMESTAMP_MAP, so registering the listeners is unnecessary overhead. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/tracing/browserTracingIntegration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index f8ea13d2728a..2a5f95835bd2 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -736,7 +736,7 @@ export const browserTracingIntegration = ((options: Partial Date: Tue, 24 Mar 2026 10:58:57 -0400 Subject: [PATCH 11/14] fix(browser): Skip CLS/LCP measurements on pageload span when streaming When span streaming is enabled, CLS and LCP are emitted as streamed spans. Previously they were also recorded as measurements on the pageload span because the flags only checked enableStandaloneClsSpans and enableStandaloneLcpSpans, which default to undefined. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/tracing/browserTracingIntegration.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 2a5f95835bd2..ac9048b61419 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -457,9 +457,10 @@ export const browserTracingIntegration = ((options: Partial Date: Tue, 24 Mar 2026 11:00:06 -0400 Subject: [PATCH 12/14] refactor(browser-utils): Share MAX_PLAUSIBLE_INP_DURATION between INP handlers Export the constant from inp.ts and import it in webVitalSpans.ts to avoid the two definitions drifting apart. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser-utils/src/metrics/inp.ts | 2 +- packages/browser-utils/src/metrics/webVitalSpans.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index f6411ef8544d..b348d8195c84 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -37,7 +37,7 @@ const ELEMENT_NAME_TIMESTAMP_MAP = new Map(); * 60 seconds is the maximum for a plausible INP value * (source: Me) */ -const MAX_PLAUSIBLE_INP_DURATION = 60; +export const MAX_PLAUSIBLE_INP_DURATION = 60; /** * Start tracking INP webvital events. */ diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index 5af964f8bb2a..deeec8ede191 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -17,14 +17,11 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../types'; -import { INP_ENTRY_MAP } from './inp'; +import { INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp'; import type { InstrumentationHandlerCallback } from './instrument'; import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler } from './instrument'; import { listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils'; -// Maximum plausible INP duration in seconds (matches standalone INP handler) -const MAX_PLAUSIBLE_INP_DURATION = 60; - interface WebVitalSpanOptions { name: string; op: string; From 8e5fa78d07527c7f498ebf56dedf652cdfcc920d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 24 Mar 2026 11:10:18 -0400 Subject: [PATCH 13/14] fix(browser): Fix ReferenceError for spanStreamingEnabled in afterAllSetup spanStreamingEnabled was declared in setup() but referenced in afterAllSetup(), a separate scope. Replace with inline hasSpanStreamingEnabled(client) call. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/tracing/browserTracingIntegration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index ac9048b61419..46c108febf74 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -737,7 +737,7 @@ export const browserTracingIntegration = ((options: Partial Date: Tue, 24 Mar 2026 12:27:18 -0400 Subject: [PATCH 14/14] fix(browser): Skip redundant CLS/LCP handlers when span streaming is enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When span streaming handles CLS/LCP, `startTrackingWebVitals` no longer registers throwaway `_trackCLS()`/`_trackLCP()` handlers. Instead of adding a separate skip flag, the existing `recordClsStandaloneSpans` and `recordLcpStandaloneSpans` options now accept `undefined` to mean "skip entirely" — three states via two flags instead of three flags. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/metrics/browserMetrics.ts | 31 ++++++++++++++++--- .../src/tracing/browserTracingIntegration.ts | 4 +-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 28d1f2bfaec8..1e6c974b543d 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -75,8 +75,18 @@ let _lcpEntry: LargestContentfulPaint | undefined; let _clsEntry: LayoutShift | undefined; interface StartTrackingWebVitalsOptions { - recordClsStandaloneSpans: boolean; - recordLcpStandaloneSpans: boolean; + /** + * When `true`, CLS is tracked as a standalone span. When `false`, CLS is + * recorded as a measurement on the pageload span. When `undefined`, CLS + * tracking is skipped entirely (e.g. because span streaming handles it). + */ + recordClsStandaloneSpans: boolean | undefined; + /** + * When `true`, LCP is tracked as a standalone span. When `false`, LCP is + * recorded as a measurement on the pageload span. When `undefined`, LCP + * tracking is skipped entirely (e.g. because span streaming handles it). + */ + recordLcpStandaloneSpans: boolean | undefined; client: Client; } @@ -97,9 +107,22 @@ export function startTrackingWebVitals({ if (performance.mark) { WINDOW.performance.mark('sentry-tracing-init'); } - const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : _trackLCP(); + + const lcpCleanupCallback = + recordLcpStandaloneSpans === true + ? trackLcpAsStandaloneSpan(client) + : recordLcpStandaloneSpans === false + ? _trackLCP() + : undefined; + + const clsCleanupCallback = + recordClsStandaloneSpans === true + ? trackClsAsStandaloneSpan(client) + : recordClsStandaloneSpans === false + ? _trackCLS() + : undefined; + const ttfbCleanupCallback = _trackTtfb(); - const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : _trackCLS(); return (): void => { lcpCleanupCallback?.(); diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 46c108febf74..ecae52175364 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -522,8 +522,8 @@ export const browserTracingIntegration = ((options: Partial