From 4caaa7b7d62c9800d280a3e704bff56766f83e9a 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 8eaf2ef83d59c9d1c8a57e1772a29483e8044214 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 a4d0960b1ccb..3d200c36db84 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 { startTrackingElementTiming } from './metrics/elementTiming'; 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 fa6b8ece5e07251038cc4505c749b6280c7e793d 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 3d200c36db84..64b77142dd6a 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 { startTrackingElementTiming } from './metrics/elementTiming'; 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 22582d47d4e9..a00b8b3d73d2 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'; @@ -516,19 +513,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 abfc1cdd0c230e67d3b7d274428738c34ce765d4 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 a00b8b3d73d2..a44a13e538d3 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -524,10 +524,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 ac9a2b2cf199b5a642cb6f56bb774d20e0c16af3 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 0d40b7c6a53cf8e355c60b3cc7bff7fa90b2c163 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 5daf97fd90c40781386a75809b0ffea9825f5a2a 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 a44a13e538d3..825783cdfccb 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -735,7 +735,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 825783cdfccb..d40248cbafcc 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -452,9 +452,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 4bf129b39ac510640738f2d45192c831925de6e3 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 d40248cbafcc..e97ae68e6516 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 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 e97ae68e6516..32da0a04eb91 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -517,8 +517,8 @@ export const browserTracingIntegration = ((options: Partial