From 56b5617d597f84ec665f351dc7d0b6275a18090c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 16 Mar 2026 13:42:09 -0400 Subject: [PATCH 01/22] 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 d7ae26326c014479132589c8904f7733f0b95c40 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 16 Mar 2026 13:42:21 -0400 Subject: [PATCH 02/22] 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 f093a6d803e9261e23e5f94480f73ef690cd28ed Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 14:12:32 -0400 Subject: [PATCH 04/22] 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 5fbafec55675..432146b770c6 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/22] 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 6cba655a3c5c912f0afdb1b47f4d81497ff2202e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 14:35:04 -0400 Subject: [PATCH 06/22] 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 432146b770c6..7355f8df19f9 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/22] 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 e92046f39f423b497afbf29266a7fea22670cc21 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 14:35:43 -0400 Subject: [PATCH 08/22] 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 86aadbbb8a65ded06e76bd367a1d8d7ddca6cebf Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 14:35:55 -0400 Subject: [PATCH 09/22] 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 e973a8451d9ff9d511a0760be869bb1c862617a0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 24 Mar 2026 10:54:00 -0400 Subject: [PATCH 10/22] 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 7355f8df19f9..433c36604806 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/22] 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 433c36604806..f5f0220c6016 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/22] 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 5dec7ce94ef325f6701b824408ab45415aa53abc Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 24 Mar 2026 11:10:18 -0400 Subject: [PATCH 13/22] 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 f5f0220c6016..4695da4ca8b4 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/22] 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 4695da4ca8b4..58b1eb95cd37 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -522,8 +522,8 @@ export const browserTracingIntegration = ((options: Partial Date: Tue, 14 Apr 2026 17:24:21 +0200 Subject: [PATCH 15/22] send wv spans as child spans of pageload/inp root span add INP tests remove adding events --- .size-limit.js | 7 ++ .../web-vitals-cls-streamed-spans/init.js | 2 +- .../web-vitals-cls-streamed-spans/test.ts | 15 ++- .../web-vitals-inp-streamed-spans/init.js | 13 ++ .../web-vitals-inp-streamed-spans/subject.js | 19 +++ .../template.html | 10 ++ .../web-vitals-inp-streamed-spans/test.ts | 79 ++++++++++++ .../web-vitals-lcp-streamed-spans/init.js | 2 +- .../web-vitals-lcp-streamed-spans/test.ts | 19 ++- .../src/metrics/browserMetrics.ts | 25 ++-- packages/browser-utils/src/metrics/inp.ts | 8 ++ .../browser-utils/src/metrics/instrument.ts | 3 +- packages/browser-utils/src/metrics/utils.ts | 11 +- .../src/metrics/webVitalSpans.ts | 117 +++++++++++------- .../test/metrics/webVitalSpans.test.ts | 101 ++++++++++++--- .../browser/src/integrations/spanstreaming.ts | 2 +- .../src/tracing/browserTracingIntegration.ts | 8 +- 17 files changed, 331 insertions(+), 110 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts diff --git a/.size-limit.js b/.size-limit.js index 351c85ccca79..bc86dd3ec8ab 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -40,6 +40,13 @@ module.exports = [ gzip: true, limit: '43 KB', }, + { + name: '@sentry/browser (incl. Tracing + Span Streaming)', + path: 'packages/browser/build/npm/esm/prod/index.js', + import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'), + gzip: true, + limit: '48 KB', + }, { name: '@sentry/browser (incl. Tracing, Profiling)', path: 'packages/browser/build/npm/esm/prod/index.js', diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js index 0c1792f0bd3f..bd3b6ed17872 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js @@ -5,7 +5,7 @@ window._testBaseTimestamp = performance.timeOrigin / 1000; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration({ idleTimeout: 9000 }), Sentry.spanStreamingIntegration()], + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], traceLifecycle: 'stream', tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts index cf995f7a912d..31ddd09977cb 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts @@ -1,7 +1,7 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; -import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; sentryTest.beforeEach(async ({ browserName, page }) => { @@ -20,22 +20,18 @@ function waitForLayoutShift(page: Page): Promise { }); } -function hidePage(page: Page): Promise { - return page.evaluate(() => { - window.dispatchEvent(new Event('pagehide')); - }); -} - sentryTest('captures CLS as a streamed span with source attributes', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls'); + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); await page.goto(`${url}#0.15`); await waitForLayoutShift(page); await hidePage(page); const clsSpan = await clsSpanPromise; + const pageloadSpan = await pageloadSpanPromise; expect(clsSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.cls' }); expect(clsSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.cls' }); @@ -48,13 +44,16 @@ sentryTest('captures CLS as a streamed span with source attributes', async ({ ge ); // Check pageload span id is present - expect(clsSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/); + expect(clsSpan.attributes?.['sentry.pageload.span_id']?.value).toBe(pageloadSpan.span_id); // CLS is a point-in-time metric expect(clsSpan.start_timestamp).toEqual(clsSpan.end_timestamp); expect(clsSpan.span_id).toMatch(/^[\da-f]{16}$/); expect(clsSpan.trace_id).toMatch(/^[\da-f]{32}$/); + + expect(clsSpan.parent_span_id).toBe(pageloadSpan.span_id); + expect(clsSpan.trace_id).toBe(pageloadSpan.trace_id); }); sentryTest('CLS streamed span has web vital value attribute', async ({ getLocalTestUrl, page }) => { diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js new file mode 100644 index 000000000000..721f9f89fb9a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js @@ -0,0 +1,13 @@ +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({ idleTimeout: 4000 }), + Sentry.spanStreamingIntegration(), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/subject.js new file mode 100644 index 000000000000..f4ea1cd46d67 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/subject.js @@ -0,0 +1,19 @@ +const blockUI = + (delay = 70) => + e => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < delay) { + // + } + + e.target.classList.add('clicked'); + }; + +document.querySelector('[data-test-id=slow-button]').addEventListener('click', blockUI(450)); +document.querySelector('[data-test-id=normal-button]').addEventListener('click', blockUI()); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/template.html new file mode 100644 index 000000000000..d5f28c7c8847 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/template.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts new file mode 100644 index 000000000000..30dd4f92dbfc --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts @@ -0,0 +1,79 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest.beforeEach(async ({ browserName }) => { + if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') { + sentryTest.skip(); + } +}); + +sentryTest('captures INP click as a streamed span', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const inpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.interaction.click'); + + await page.goto(url); + + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); + + await hidePage(page); + + const inpSpan = await inpSpanPromise; + const pageloadSpan = await pageloadSpanPromise; + + expect(inpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.interaction.click' }); + expect(inpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.inp' }); + expect(inpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome')); + + const inpValue = inpSpan.attributes?.['browser.web_vital.inp.value']?.value as number; + expect(inpValue).toBeGreaterThan(0); + + expect(inpSpan.attributes?.['sentry.exclusive_time']?.value).toBeGreaterThan(0); + + expect(inpSpan.name).toBe('body > NormalButton'); + + expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp); + + expect(inpSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(inpSpan.trace_id).toMatch(/^[\da-f]{32}$/); + + expect(inpSpan.parent_span_id).toBe(pageloadSpan.span_id); + expect(inpSpan.trace_id).toBe(pageloadSpan.trace_id); +}); + +sentryTest('captures the slowest interaction as streamed INP span', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); + + const inpSpanPromise = waitForStreamedSpan(page, span => { + const op = getSpanOp(span); + return op === 'ui.interaction.click'; + }); + + await page.locator('[data-test-id=slow-button]').click(); + await page.locator('.clicked[data-test-id=slow-button]').isVisible(); + + await page.waitForTimeout(500); + + await hidePage(page); + + const inpSpan = await inpSpanPromise; + + expect(inpSpan.name).toBe('body > SlowButton'); + expect(inpSpan.attributes?.['sentry.exclusive_time']?.value).toBeGreaterThan(400); + + const inpValue = inpSpan.attributes?.['browser.web_vital.inp.value']?.value as number; + expect(inpValue).toBeGreaterThan(400); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js index 0c1792f0bd3f..bd3b6ed17872 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js @@ -5,7 +5,7 @@ window._testBaseTimestamp = performance.timeOrigin / 1000; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration({ idleTimeout: 9000 }), Sentry.spanStreamingIntegration()], + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], traceLifecycle: 'stream', tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts index c91ebd2bbd51..1f71cb8d76a7 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts @@ -1,7 +1,7 @@ -import type { Page, Route } from '@playwright/test'; +import type { Route } from '@playwright/test'; import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; -import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; sentryTest.beforeEach(async ({ browserName, page }) => { @@ -12,12 +12,6 @@ sentryTest.beforeEach(async ({ browserName, page }) => { await page.setViewportSize({ width: 800, height: 1200 }); }); -function hidePage(page: Page): Promise { - return page.evaluate(() => { - window.dispatchEvent(new Event('pagehide')); - }); -} - sentryTest('captures LCP as a streamed span with element attributes', async ({ getLocalTestUrl, page }) => { page.route('**', route => route.continue()); page.route('**/my/image.png', async (route: Route) => { @@ -29,6 +23,7 @@ sentryTest('captures LCP as a streamed span with element attributes', async ({ g const url = await getLocalTestUrl({ testDir: __dirname }); const lcpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.lcp'); + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); await page.goto(url); @@ -38,6 +33,7 @@ sentryTest('captures LCP as a streamed span with element attributes', async ({ g await hidePage(page); const lcpSpan = await lcpSpanPromise; + const pageloadSpan = await pageloadSpanPromise; expect(lcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.lcp' }); expect(lcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.lcp' }); @@ -52,15 +48,18 @@ sentryTest('captures LCP as a streamed span with element attributes', async ({ g expect(lcpSpan.attributes?.['browser.web_vital.lcp.size']?.value).toEqual(expect.any(Number)); // Check web vital value attribute - expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.type).toBe('double'); + expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.type).toMatch(/^(double)|(integer)$/); expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.value).toBeGreaterThan(0); // Check pageload span id is present - expect(lcpSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/); + expect(lcpSpan.attributes?.['sentry.pageload.span_id']?.value).toBe(pageloadSpan.span_id); // Span should have meaningful duration (navigation start -> LCP event) expect(lcpSpan.end_timestamp).toBeGreaterThan(lcpSpan.start_timestamp); expect(lcpSpan.span_id).toMatch(/^[\da-f]{16}$/); expect(lcpSpan.trace_id).toMatch(/^[\da-f]{32}$/); + + expect(lcpSpan.parent_span_id).toBe(pageloadSpan.span_id); + expect(lcpSpan.trace_id).toBe(pageloadSpan.trace_id); }); diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 1e6c974b543d..39aeeb0aa460 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -94,6 +94,7 @@ interface StartTrackingWebVitalsOptions { * Start tracking web vitals. * The callback returned by this function can be used to stop tracking & ensure all measurements are final & captured. * + * @deprecated this function will be removed and streamlined once we stop supporting standalone v1 * @returns A function that forces web vitals collection */ export function startTrackingWebVitals({ @@ -108,25 +109,23 @@ export function startTrackingWebVitals({ WINDOW.performance.mark('sentry-tracing-init'); } - const lcpCleanupCallback = - recordLcpStandaloneSpans === true - ? trackLcpAsStandaloneSpan(client) - : recordLcpStandaloneSpans === false - ? _trackLCP() - : undefined; + const lcpCleanupCallback = recordLcpStandaloneSpans + ? trackLcpAsStandaloneSpan(client) + : recordLcpStandaloneSpans === false + ? _trackLCP() + : undefined; - const clsCleanupCallback = - recordClsStandaloneSpans === true - ? trackClsAsStandaloneSpan(client) - : recordClsStandaloneSpans === false - ? _trackCLS() - : undefined; + const clsCleanupCallback = recordClsStandaloneSpans + ? trackClsAsStandaloneSpan(client) + : recordClsStandaloneSpans === false + ? _trackCLS() + : undefined; const ttfbCleanupCallback = _trackTtfb(); return (): void => { - lcpCleanupCallback?.(); ttfbCleanupCallback(); + lcpCleanupCallback?.(); clsCleanupCallback?.(); }; } diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index b348d8195c84..3eb0b2920a75 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -155,6 +155,14 @@ export const _onInp: InstrumentationHandlerCallback = ({ metric }) => { } }; +/** + * Look up a cached interaction context (element name + root span) by interactionId. + * Returns undefined if no context was cached for this interaction. + */ +export function getCachedInteractionContext(interactionId: number | undefined): InteractionContext | undefined { + return interactionId != null ? INTERACTIONS_SPAN_MAP.get(interactionId) : undefined; +} + /** * Register a listener to cache route information for INP interactions. */ diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 5dc9d78f4ce8..608a5fd11511 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -27,7 +27,7 @@ interface PerformanceEntry { readonly startTime: number; toJSON(): Record; } -interface PerformanceEventTiming extends PerformanceEntry { +export interface PerformanceEventTiming extends PerformanceEntry { processingStart: number; processingEnd: number; duration: number; @@ -114,6 +114,7 @@ let _previousCls: Metric | undefined; let _previousLcp: Metric | undefined; let _previousTtfb: Metric | undefined; let _previousInp: 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. diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index 084d17becb8d..a3f3ea0e2cf8 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -203,17 +203,18 @@ export function supportsWebVital(entryType: 'layout-shift' | 'largest-contentful * @param collectorCallback the callback to be called when the first of these events is triggered. Parameters: * - event: the event that triggered the reporting of the web vital value. * - pageloadSpanId: the span id of the pageload span. This is used to link the web vital span to the pageload span. + * - pageloadSpan: the pageload span instance. This is used for full access to the pageload span for span streaming. */ export function listenForWebVitalReportEvents( client: Client, - collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string) => void, + collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string, pageloadSpan?: Span) => void, ) { - let pageloadSpanId: string | undefined; + let pageloadSpan: Span | undefined; let collected = false; function _runCollectorCallbackOnce(event: WebVitalReportEvent) { - if (!collected && pageloadSpanId) { - collectorCallback(event, pageloadSpanId); + if (!collected && pageloadSpan) { + collectorCallback(event, pageloadSpan.spanContext().spanId, pageloadSpan); } collected = true; } @@ -233,7 +234,7 @@ export function listenForWebVitalReportEvents( }); const unsubscribeAfterStartPageLoadSpan = client.on('afterStartPageLoadSpan', span => { - pageloadSpanId = span.spanContext().spanId; + pageloadSpan = span; unsubscribeAfterStartPageLoadSpan(); }); } diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index deeec8ede191..765731bb8d32 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -1,4 +1,4 @@ -import type { Client, SpanAttributes } from '@sentry/core'; +import type { Client, Span, SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, debug, @@ -7,30 +7,31 @@ import { 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, + spanToStreamedSpanJSON, startInactiveSpan, timestampInSeconds, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../types'; -import { INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp'; +import { getCachedInteractionContext, 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'; - +import type { WebVitalReportEvent } from './utils'; +import { getBrowserPerformanceAPI, listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils'; +import type { PerformanceEventTiming } from './instrument'; interface WebVitalSpanOptions { name: string; op: string; origin: string; - metricName: string; + metricName: 'lcp' | 'cls' | 'inp'; value: number; unit: string; attributes?: SpanAttributes; - pageloadSpanId?: string; + parentSpan?: Span; + reportEvent?: WebVitalReportEvent; startTime: number; endTime?: number; } @@ -45,9 +46,9 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { origin, metricName, value, - unit, attributes: passedAttributes, - pageloadSpanId, + parentSpan, + reportEvent, startTime, endTime, } = options; @@ -59,28 +60,32 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, [`browser.web_vital.${metricName}.value`]: value, - transaction: routeName, + 'sentry.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; + if (parentSpan && spanToStreamedSpanJSON(parentSpan).attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'pageload') { + // for LCP and CLS, we collect the pageload span id as an attribute + attributes['sentry.pageload.span_id'] = parentSpan.spanContext().spanId; + } + + if (reportEvent) { + attributes[`browser.web_vital.${metricName}.report_event`] = reportEvent; } const span = startInactiveSpan({ name, attributes, startTime, + // if we have a pageload span, we let the web vital span start as its parent. This ensures that + // it is not started as a segment span, without having to manually set it to a "standalone" v2 span + // that has `segment: false` but no actual parent span. + parentSpan: parentSpan, }); if (span) { - span.addEvent(metricName, { - [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: unit, - [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: value, - }); - span.end(endTime ?? startTime); } } @@ -105,8 +110,8 @@ export function trackLcpAsSpan(client: Client): void { lcpEntry = entry; }, true); - listenForWebVitalReportEvents(client, (_reportEvent, pageloadSpanId) => { - _sendLcpSpan(lcpValue, lcpEntry, pageloadSpanId); + listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => { + _sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, reportEvent); cleanupLcpHandler(); }); } @@ -117,7 +122,8 @@ export function trackLcpAsSpan(client: Client): void { export function _sendLcpSpan( lcpValue: number, entry: LargestContentfulPaint | undefined, - pageloadSpanId: string, + pageloadSpan?: Span, + reportEvent?: WebVitalReportEvent, ): void { DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`); @@ -128,14 +134,12 @@ export function _sendLcpSpan( 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); - } + 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, @@ -145,7 +149,8 @@ export function _sendLcpSpan( value: lcpValue, unit: 'millisecond', attributes, - pageloadSpanId, + parentSpan: pageloadSpan, + reportEvent, startTime: timeOrigin, endTime, }); @@ -171,8 +176,8 @@ export function trackClsAsSpan(client: Client): void { clsEntry = entry; }, true); - listenForWebVitalReportEvents(client, (_reportEvent, pageloadSpanId) => { - _sendClsSpan(clsValue, clsEntry, pageloadSpanId); + listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => { + _sendClsSpan(clsValue, clsEntry, pageloadSpan, reportEvent); cleanupClsHandler(); }); } @@ -180,7 +185,12 @@ export function trackClsAsSpan(client: Client): void { /** * Exported only for testing. */ -export function _sendClsSpan(clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string): void { +export function _sendClsSpan( + clsValue: number, + entry: LayoutShift | undefined, + pageloadSpan?: Span, + reportEvent?: WebVitalReportEvent, +): void { DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`); const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds(); @@ -202,22 +212,34 @@ export function _sendClsSpan(clsValue: number, entry: LayoutShift | undefined, p value: clsValue, unit: '', attributes, - pageloadSpanId, + parentSpan: pageloadSpan, + reportEvent, startTime, }); } /** * Tracks INP as a streamed span. + * + * This mirrors the standalone INP tracking logic (`startTrackingINP`) but emits + * spans through the streaming pipeline instead of as standalone spans. + * Requires `registerInpInteractionListener()` to be called separately for + * cached element names and root spans per interaction. */ -export function trackInpAsSpan(_client: Client): void { +export function trackInpAsSpan(): void { + const performance = getBrowserPerformanceAPI(); + if (!performance || !browserPerformanceTimeOrigin()) { + return; + } + const onInp: InstrumentationHandlerCallback = ({ metric }) => { if (metric.value == null) { return; } - // Guard against unrealistically long INP values (matching standalone INP handler) - if (msToSec(metric.value) > MAX_PLAUSIBLE_INP_DURATION) { + const duration = msToSec(metric.value); + + if (duration > MAX_PLAUSIBLE_INP_DURATION) { return; } @@ -236,18 +258,20 @@ export function trackInpAsSpan(_client: Client): void { /** * Exported only for testing. */ -export function _sendInpSpan( - inpValue: number, - entry: { name: string; startTime: number; duration: number; target?: unknown | null }, -): void { +export function _sendInpSpan(inpValue: number, entry: PerformanceEventTiming): void { DEBUG_BUILD && debug.log(`Sending INP span (${inpValue})`); - const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime); + const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime); + const duration = msToSec(inpValue); const interactionType = INP_ENTRY_MAP[entry.name]; + + const cachedContext = getCachedInteractionContext(entry.interactionId); const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - const routeName = rootSpan ? spanToJSON(rootSpan).description : getCurrentScope().getScopeData().transactionName; - const name = htmlTreeAsString(entry.target); + + const spanToUse = cachedContext?.span || rootSpan; + const routeName = spanToUse ? spanToJSON(spanToUse).description : getCurrentScope().getScopeData().transactionName; + const name = cachedContext?.elementName || htmlTreeAsString(entry.target); _emitWebVitalSpan({ name, @@ -258,9 +282,10 @@ export function _sendInpSpan( unit: 'millisecond', attributes: { [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration, - transaction: routeName, + 'sentry.transaction': routeName, }, startTime, - endTime: startTime + msToSec(entry.duration), + endTime: startTime + duration, + parentSpan: spanToUse, }); } diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts index 44f91a779b64..043d3189ab3d 100644 --- a/packages/browser-utils/test/metrics/webVitalSpans.test.ts +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -1,5 +1,6 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as inpModule from '../../src/metrics/inp'; import { _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan } from '../../src/metrics/webVitalSpans'; vi.mock('@sentry/core', async () => { @@ -51,9 +52,9 @@ describe('_emitWebVitalSpan', () => { it('creates a non-standalone span with correct attributes', () => { _emitWebVitalSpan({ name: 'Test Vital', - op: 'ui.webvital.test', - origin: 'auto.http.browser.test', - metricName: 'test', + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', value: 100, unit: 'millisecond', startTime: 1.5, @@ -62,11 +63,11 @@ describe('_emitWebVitalSpan', () => { expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({ name: 'Test Vital', attributes: { - 'sentry.origin': 'auto.http.browser.test', - 'sentry.op': 'ui.webvital.test', + 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.op': 'ui.webvital.lcp', 'sentry.exclusive_time': 0, - 'browser.web_vital.test.value': 100, - transaction: 'test-transaction', + 'browser.web_vital.lcp.value': 100, + 'sentry.transaction': 'test-transaction', 'user_agent.original': 'test-user-agent', }, startTime: 1.5, @@ -77,7 +78,7 @@ describe('_emitWebVitalSpan', () => { expect.objectContaining({ experimental: expect.anything() }), ); - expect(mockSpan.addEvent).toHaveBeenCalledWith('test', { + expect(mockSpan.addEvent).toHaveBeenCalledWith('lcp', { 'sentry.measurement_unit': 'millisecond', 'sentry.measurement_value': 100, }); @@ -88,9 +89,9 @@ describe('_emitWebVitalSpan', () => { it('includes pageloadSpanId when provided', () => { _emitWebVitalSpan({ name: 'Test', - op: 'ui.webvital.test', - origin: 'auto.http.browser.test', - metricName: 'test', + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', value: 50, unit: 'millisecond', pageloadSpanId: 'abc123', @@ -106,12 +107,33 @@ describe('_emitWebVitalSpan', () => { ); }); + it('includes reportEvent when provided', () => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.cls', + origin: 'auto.http.browser.cls', + metricName: 'cls', + value: 0.1, + unit: '', + reportEvent: 'pagehide', + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'browser.web_vital.cls.report_event': 'pagehide', + }), + }), + ); + }); + it('merges additional attributes', () => { _emitWebVitalSpan({ name: 'Test', - op: 'ui.webvital.test', - origin: 'auto.http.browser.test', - metricName: 'test', + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', value: 50, unit: 'millisecond', attributes: { 'custom.attr': 'value' }, @@ -133,9 +155,9 @@ describe('_emitWebVitalSpan', () => { expect(() => { _emitWebVitalSpan({ name: 'Test', - op: 'ui.webvital.test', - origin: 'auto.http.browser.test', - metricName: 'test', + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', value: 50, unit: 'millisecond', startTime: 1.0, @@ -178,7 +200,7 @@ describe('_sendLcpSpan', () => { startTime: 200, } as LargestContentfulPaint; - _sendLcpSpan(250, mockEntry, 'pageload-123'); + _sendLcpSpan(250, mockEntry, 'pageload-123', 'pagehide'); expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ @@ -194,6 +216,8 @@ describe('_sendLcpSpan', () => { 'browser.web_vital.lcp.load_time': 100, 'browser.web_vital.lcp.render_time': 150, 'browser.web_vital.lcp.size': 50000, + 'browser.web_vital.lcp.report_event': 'pagehide', + 'sentry.transaction': 'test-route', }), startTime: 1, // timeOrigin: 1000 / 1000 }), @@ -266,7 +290,7 @@ describe('_sendClsSpan', () => { .mockReturnValueOnce('
') // for source 1 .mockReturnValueOnce(''); // for source 2 - _sendClsSpan(0.1, mockEntry, 'pageload-789'); + _sendClsSpan(0.1, mockEntry, 'pageload-789', 'navigation'); expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ @@ -277,6 +301,8 @@ describe('_sendClsSpan', () => { 'sentry.pageload.span_id': 'pageload-789', 'browser.web_vital.cls.source.1': '
', 'browser.web_vital.cls.source.2': '', + 'browser.web_vital.cls.report_event': 'navigation', + 'sentry.transaction': 'test-route', }), }), ); @@ -325,10 +351,13 @@ describe('_sendInpSpan', () => { }); it('sends a streamed INP span with duration matching interaction', () => { + vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); + const mockEntry = { name: 'pointerdown', startTime: 500, duration: 120, + interactionId: 1, target: { tagName: 'button' }, }; @@ -343,6 +372,7 @@ describe('_sendInpSpan', () => { 'sentry.origin': 'auto.http.browser.inp', 'sentry.op': 'ui.interaction.click', 'sentry.exclusive_time': 120, + 'sentry.transaction': 'test-route', }), }), ); @@ -357,10 +387,13 @@ describe('_sendInpSpan', () => { }); it('sends a streamed INP span for a keypress interaction', () => { + vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); + const mockEntry = { name: 'keydown', startTime: 600, duration: 80, + interactionId: 2, target: { tagName: 'input' }, }; @@ -374,4 +407,34 @@ describe('_sendInpSpan', () => { }), ); }); + + it('uses cached element name and span from registerInpInteractionListener', () => { + const mockRootSpan = { spanContext: vi.fn() }; + vi.mocked(SentryCore.spanToJSON).mockReturnValue({ description: 'cached-route' } as any); + vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue({ + elementName: 'body > CachedButton', + span: mockRootSpan as any, + }); + + const mockEntry = { + name: 'pointerdown', + startTime: 500, + duration: 100, + interactionId: 42, + target: { tagName: 'button' }, + }; + + _sendInpSpan(100, mockEntry); + + expect(inpModule.getCachedInteractionContext).toHaveBeenCalledWith(42); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'body > CachedButton', + attributes: expect.objectContaining({ + 'sentry.transaction': 'cached-route', + }), + }), + ); + }); }); diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index 4225b9342fca..ad6a35d1813b 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -19,7 +19,7 @@ export const spanStreamingIntegration = defineIntegration(() => { // This avoids the classic double-opt-in problem we'd otherwise have in the browser SDK. const clientOptions = client.getOptions(); if (!clientOptions.traceLifecycle) { - DEBUG_BUILD && debug.log('[SpanStreaming] set `traceLifecycle` to "stream"'); + DEBUG_BUILD && debug.log('[SpanStreaming] setting `traceLifecycle` to "stream"'); clientOptions.traceLifecycle = 'stream'; } }, diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 58b1eb95cd37..e039ad22d6b0 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -531,11 +531,9 @@ export const browserTracingIntegration = ((options: Partial Date: Wed, 15 Apr 2026 13:35:53 +0200 Subject: [PATCH 16/22] fix ttfb, fb, fco, measurements, connection attributes --- .../web-vitals-inp-streamed-spans/init.js | 5 +- .../metrics/web-vitals-ttfb-streamed/init.js | 12 ++ .../metrics/web-vitals-ttfb-streamed/test.ts | 36 ++++++ .../src/metrics/browserMetrics.ts | 95 ++++++++++++---- .../src/metrics/webVitalSpans.ts | 5 +- .../test/metrics/webVitalSpans.test.ts | 104 +++++++++++------- .../src/tracing/browserTracingIntegration.ts | 1 + 7 files changed, 190 insertions(+), 68 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js index 721f9f89fb9a..469f44076e73 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js @@ -5,9 +5,6 @@ window._testBaseTimestamp = performance.timeOrigin / 1000; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [ - Sentry.browserTracingIntegration({ idleTimeout: 4000 }), - Sentry.spanStreamingIntegration(), - ], + integrations: [Sentry.browserTracingIntegration({ idleTimeout: 4000 }), Sentry.spanStreamingIntegration()], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/init.js new file mode 100644 index 000000000000..d8da96d88a64 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/init.js @@ -0,0 +1,12 @@ +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, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/test.ts new file mode 100644 index 000000000000..73f37f07a291 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/test.ts @@ -0,0 +1,36 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest.beforeEach(async ({ page }) => { + if (shouldSkipTracingTest() || testingCdnBundle()) { + sentryTest.skip(); + } + + await page.setViewportSize({ width: 800, height: 1200 }); +}); + +sentryTest( + 'captures TTFB and TTFB request time as attributes on the streamed pageload span', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + await hidePage(page); + + const pageloadSpan = await pageloadSpanPromise; + + // If responseStart === 0, TTFB is not reported. + // This seems to happen somewhat randomly, so we handle it. + const responseStart = await page.evaluate("performance.getEntriesByType('navigation')[0].responseStart;"); + if (responseStart !== 0) { + expect(pageloadSpan.attributes?.['browser.web_vital.ttfb.value']?.type).toMatch(/^(double)|(integer)$/); + expect(pageloadSpan.attributes?.['browser.web_vital.ttfb.value']?.value).toBeGreaterThan(0); + } + + expect(pageloadSpan.attributes?.['browser.web_vital.ttfb.request_time']?.type).toMatch(/^(double)|(integer)$/); + }, +); diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 39aeeb0aa460..62f7c243f628 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -27,7 +27,7 @@ import { getBrowserPerformanceAPI, isMeasurementValue, msToSec, startAndEndSpan import { getActivationStart } from './web-vitals/lib/getActivationStart'; import { getNavigationEntry } from './web-vitals/lib/getNavigationEntry'; import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher'; - +import { debug } from '@sentry/core'; interface NavigatorNetworkInformation { readonly connection?: NetworkInformation; } @@ -336,6 +336,11 @@ interface AddPerformanceEntriesOptions { * Default: [] */ ignorePerformanceApiSpans: Array; + + /** + * Whether span streaming is enabled. + */ + spanStreamingEnabled?: boolean; } /** Add performance related spans to a transaction */ @@ -347,6 +352,14 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries return; } + const { + spanStreamingEnabled, + ignorePerformanceApiSpans, + ignoreResourceSpans, + recordClsOnPageloadSpan, + recordLcpOnPageloadSpan, + } = options; + const timeOrigin = msToSec(origin); const performanceEntries = performance.getEntries(); @@ -375,7 +388,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries case 'mark': case 'paint': case 'measure': { - _addMeasureSpans(span, entry, startTime, duration, timeOrigin, options.ignorePerformanceApiSpans); + _addMeasureSpans(span, entry, startTime, duration, timeOrigin, ignorePerformanceApiSpans); // capture web vitals const firstHidden = getVisibilityWatcher(); @@ -398,7 +411,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries startTime, duration, timeOrigin, - options.ignoreResourceSpans, + ignoreResourceSpans, ); break; } @@ -408,25 +421,51 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries _performanceCursor = Math.max(performanceEntries.length - 1, 0); - _trackNavigator(span); + _trackNavigator(span, spanStreamingEnabled); // Measurements are only available for pageload transactions if (op === 'pageload') { _addTtfbRequestTimeToMeasurements(_measurements); - // If CLS standalone spans are enabled, don't record CLS as a measurement - if (!options.recordClsOnPageloadSpan) { - delete _measurements.cls; - } + if (spanStreamingEnabled) { + const setAttr = (shortWebVitalName: string, value: number, customAttrName?: string) => { + const attrKey = customAttrName ?? `browser.web_vital.${shortWebVitalName}.value`; + span.setAttribute(attrKey, value); + debug.log('Setting web vital attribute', { [attrKey]: value }, 'on pageload span'); + }; + // for streamed pageload spans, we add the web vital measurements as attributes. + // We omit LCP, CLS and INP because they're tracked separately as spans + if (_measurements['ttfb']) { + setAttr('ttfb', _measurements['ttfb'].value); + } + if (_measurements['ttfb.requestTime']) { + setAttr('ttfb.requestTime', _measurements['ttfb.requestTime'].value, 'browser.web_vital.ttfb.request_time'); + } + if (_measurements['fp']) { + setAttr('fp', _measurements['fp'].value); + } + if (_measurements['fcp']) { + setAttr('fcp', _measurements['fcp'].value); + } + } else { + // TODO (V11): Remove this else branch once we remove v1 standalone spans and transactions - // If LCP standalone spans are enabled, don't record LCP as a measurement - if (!options.recordLcpOnPageloadSpan) { - delete _measurements.lcp; - } + // If CLS standalone spans are enabled, don't record CLS as a measurement + if (!recordClsOnPageloadSpan) { + delete _measurements.cls; + } - Object.entries(_measurements).forEach(([measurementName, measurement]) => { - setMeasurement(measurementName, measurement.value, measurement.unit); - }); + // If LCP standalone spans are enabled, don't record LCP as a measurement + if (!recordLcpOnPageloadSpan) { + delete _measurements.lcp; + } + + Object.entries(_measurements).forEach(([measurementName, measurement]) => { + setMeasurement(measurementName, measurement.value, measurement.unit); + }); + + _setWebVitalAttributes(span, options); + } // Set timeOrigin which denotes the timestamp which to base the LCP/FCP/FP/TTFB measurements on span.setAttribute('performance.timeOrigin', timeOrigin); @@ -438,8 +477,6 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries // This is user action is called "activation" and the time between navigation and activation is stored in // the `activationStart` attribute of the "navigation" PerformanceEntry. span.setAttribute('performance.activationStart', getActivationStart()); - - _setWebVitalAttributes(span, options); } _lcpEntry = undefined; @@ -734,8 +771,9 @@ export function _addResourceSpans( /** * Capture the information of the user agent. + * TODO v11: Remove non-span-streaming attributes and measurements once we removed transactions */ -function _trackNavigator(span: Span): void { +function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined): void { const navigator = WINDOW.navigator as null | (Navigator & NavigatorNetworkInformation & NavigatorDeviceMemory); if (!navigator) { return; @@ -745,24 +783,37 @@ function _trackNavigator(span: Span): void { const connection = navigator.connection; if (connection) { if (connection.effectiveType) { - span.setAttribute('effectiveConnectionType', connection.effectiveType); + span.setAttribute( + spanStreamingEnabled ? 'network.connection.effective_type' : 'effectiveConnectionType', + connection.effectiveType, + ); } if (connection.type) { - span.setAttribute('connectionType', connection.type); + span.setAttribute(spanStreamingEnabled ? 'network.connection.type' : 'connectionType', connection.type); } if (isMeasurementValue(connection.rtt)) { _measurements['connection.rtt'] = { value: connection.rtt, unit: 'millisecond' }; + if (spanStreamingEnabled) { + span.setAttribute('network.connection.rtt', connection.rtt); + } } } if (isMeasurementValue(navigator.deviceMemory)) { - span.setAttribute('deviceMemory', `${navigator.deviceMemory} GB`); + if (spanStreamingEnabled) { + span.setAttribute('device.memory.estimated_capacity', navigator.deviceMemory); + } else { + span.setAttribute('deviceMemory', `${navigator.deviceMemory} GB`); + } } if (isMeasurementValue(navigator.hardwareConcurrency)) { - span.setAttribute('hardwareConcurrency', String(navigator.hardwareConcurrency)); + span.setAttribute( + spanStreamingEnabled ? 'device.processor_count' : 'hardwareConcurrency', + String(navigator.hardwareConcurrency), + ); } } diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index 765731bb8d32..e5d9fba4cf90 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -22,13 +22,13 @@ import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstr import type { WebVitalReportEvent } from './utils'; import { getBrowserPerformanceAPI, listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils'; import type { PerformanceEventTiming } from './instrument'; + interface WebVitalSpanOptions { name: string; op: string; origin: string; metricName: 'lcp' | 'cls' | 'inp'; value: number; - unit: string; attributes?: SpanAttributes; parentSpan?: Span; reportEvent?: WebVitalReportEvent; @@ -147,7 +147,6 @@ export function _sendLcpSpan( origin: 'auto.http.browser.lcp', metricName: 'lcp', value: lcpValue, - unit: 'millisecond', attributes, parentSpan: pageloadSpan, reportEvent, @@ -210,7 +209,6 @@ export function _sendClsSpan( origin: 'auto.http.browser.cls', metricName: 'cls', value: clsValue, - unit: '', attributes, parentSpan: pageloadSpan, reportEvent, @@ -279,7 +277,6 @@ export function _sendInpSpan(inpValue: number, entry: PerformanceEventTiming): v origin: 'auto.http.browser.inp', metricName: 'inp', value: inpValue, - unit: 'millisecond', attributes: { [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration, 'sentry.transaction': routeName, diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts index 043d3189ab3d..db39f6332c89 100644 --- a/packages/browser-utils/test/metrics/webVitalSpans.test.ts +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -15,6 +15,7 @@ vi.mock('@sentry/core', async () => { getActiveSpan: vi.fn(), getRootSpan: vi.fn(), spanToJSON: vi.fn(), + spanToStreamedSpanJSON: vi.fn(), }; }); @@ -28,9 +29,15 @@ vi.mock('../../src/types', () => ({ }, })); +function createMockPageloadSpan(spanId: string) { + return { + spanContext: () => ({ spanId, traceId: 'trace-1', traceFlags: 1 }), + end: vi.fn(), + }; +} + describe('_emitWebVitalSpan', () => { const mockSpan = { - addEvent: vi.fn(), end: vi.fn(), }; @@ -43,6 +50,7 @@ describe('_emitWebVitalSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ attributes: {} } as any); }); afterEach(() => { @@ -56,7 +64,6 @@ describe('_emitWebVitalSpan', () => { origin: 'auto.http.browser.lcp', metricName: 'lcp', value: 100, - unit: 'millisecond', startTime: 1.5, }); @@ -78,23 +85,22 @@ describe('_emitWebVitalSpan', () => { expect.objectContaining({ experimental: expect.anything() }), ); - expect(mockSpan.addEvent).toHaveBeenCalledWith('lcp', { - 'sentry.measurement_unit': 'millisecond', - 'sentry.measurement_value': 100, - }); - expect(mockSpan.end).toHaveBeenCalledWith(1.5); }); - it('includes pageloadSpanId when provided', () => { + it('includes pageload span id when parentSpan is a pageload span', () => { + const mockPageloadSpan = createMockPageloadSpan('abc123'); + vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ + attributes: { 'sentry.op': 'pageload' }, + } as any); + _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', origin: 'auto.http.browser.lcp', metricName: 'lcp', value: 50, - unit: 'millisecond', - pageloadSpanId: 'abc123', + parentSpan: mockPageloadSpan as any, startTime: 1.0, }); @@ -103,6 +109,32 @@ describe('_emitWebVitalSpan', () => { attributes: expect.objectContaining({ 'sentry.pageload.span_id': 'abc123', }), + parentSpan: mockPageloadSpan, + }), + ); + }); + + it('does not include pageload span id when parentSpan is not a pageload span', () => { + const mockNonPageloadSpan = createMockPageloadSpan('xyz789'); + vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ + attributes: { 'sentry.op': 'ui.interaction.click' }, + } as any); + + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: 50, + parentSpan: mockNonPageloadSpan as any, + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'sentry.pageload.span_id': expect.anything(), + }), }), ); }); @@ -114,7 +146,6 @@ describe('_emitWebVitalSpan', () => { origin: 'auto.http.browser.cls', metricName: 'cls', value: 0.1, - unit: '', reportEvent: 'pagehide', startTime: 1.0, }); @@ -135,7 +166,6 @@ describe('_emitWebVitalSpan', () => { origin: 'auto.http.browser.lcp', metricName: 'lcp', value: 50, - unit: 'millisecond', attributes: { 'custom.attr': 'value' }, startTime: 1.0, }); @@ -159,7 +189,6 @@ describe('_emitWebVitalSpan', () => { origin: 'auto.http.browser.lcp', metricName: 'lcp', value: 50, - unit: 'millisecond', startTime: 1.0, }); }).not.toThrow(); @@ -168,7 +197,6 @@ describe('_emitWebVitalSpan', () => { describe('_sendLcpSpan', () => { const mockSpan = { - addEvent: vi.fn(), end: vi.fn(), }; @@ -183,6 +211,9 @@ describe('_sendLcpSpan', () => { vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ + attributes: { 'sentry.op': 'pageload' }, + } as any); }); afterEach(() => { @@ -200,7 +231,9 @@ describe('_sendLcpSpan', () => { startTime: 200, } as LargestContentfulPaint; - _sendLcpSpan(250, mockEntry, 'pageload-123', 'pagehide'); + const mockPageloadSpan = createMockPageloadSpan('pageload-123'); + + _sendLcpSpan(250, mockEntry, mockPageloadSpan as any, 'pagehide'); expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ @@ -220,20 +253,16 @@ describe('_sendLcpSpan', () => { 'sentry.transaction': 'test-route', }), startTime: 1, // timeOrigin: 1000 / 1000 + parentSpan: mockPageloadSpan, }), ); - 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'); + _sendLcpSpan(0, undefined); expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ @@ -246,7 +275,6 @@ describe('_sendLcpSpan', () => { describe('_sendClsSpan', () => { const mockSpan = { - addEvent: vi.fn(), end: vi.fn(), }; @@ -262,13 +290,16 @@ describe('_sendClsSpan', () => { 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); + vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ + attributes: { 'sentry.op': 'pageload' }, + } as any); }); afterEach(() => { vi.clearAllMocks(); }); - it('sends a streamedCLS span with entry data and sources', () => { + it('sends a streamed CLS span with entry data and sources', () => { const mockEntry: LayoutShift = { name: 'layout-shift', entryType: 'layout-shift', @@ -290,7 +321,9 @@ describe('_sendClsSpan', () => { .mockReturnValueOnce('
') // for source 1 .mockReturnValueOnce(''); // for source 2 - _sendClsSpan(0.1, mockEntry, 'pageload-789', 'navigation'); + const mockPageloadSpan = createMockPageloadSpan('pageload-789'); + + _sendClsSpan(0.1, mockEntry, mockPageloadSpan as any, 'navigation'); expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ @@ -304,17 +337,13 @@ describe('_sendClsSpan', () => { 'browser.web_vital.cls.report_event': 'navigation', 'sentry.transaction': 'test-route', }), + parentSpan: mockPageloadSpan, }), ); - - 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'); + it('sends a streamed CLS span without entry data', () => { + _sendClsSpan(0, undefined); expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( @@ -328,7 +357,6 @@ describe('_sendClsSpan', () => { describe('_sendInpSpan', () => { const mockSpan = { - addEvent: vi.fn(), end: vi.fn(), }; @@ -344,6 +372,7 @@ describe('_sendInpSpan', () => { vi.mocked(SentryCore.htmlTreeAsString).mockReturnValue('