diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/init.js new file mode 100644 index 000000000000..3e7a3d67bff0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/template.html new file mode 100644 index 000000000000..b81f11e967e3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/template.html @@ -0,0 +1,9 @@ + + + + + + +
Rendered
+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/test.ts new file mode 100644 index 000000000000..02a9329f04d8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt-navigation/test.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest.beforeEach(({ browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + sentryTest.skip(); + } +}); + +// `connection.rtt` is recorded as a measurement, which is only flushed on the pageload +// transaction. It must not leak onto navigation transactions. +sentryTest( + 'records `connection.rtt` as a measurement on pageload but not on navigation transactions', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadRequest = await getFirstSentryEnvelopeRequest(page, url); + const navigationRequest = await getFirstSentryEnvelopeRequest(page, `${url}#foo`); + + expect(pageloadRequest.contexts?.trace?.op).toBe('pageload'); + expect(navigationRequest.contexts?.trace?.op).toBe('navigation'); + + expect(pageloadRequest.measurements?.['connection.rtt']?.value).toBeDefined(); + expect(navigationRequest.measurements?.['connection.rtt']).toBeUndefined(); + }, +); diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index d960109c42c8..fa2752fe38de 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -8,6 +8,7 @@ export { export { addPerformanceEntries, + addWebVitalsToSpan, startTrackingInteractions, startTrackingLongTasks, startTrackingLongAnimationFrames, diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 7fa40ee101cb..75f81e3e7d45 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -123,9 +123,11 @@ export function startTrackingWebVitals({ : undefined; const ttfbCleanupCallback = _trackTtfb(); + const fpFcpCleanupCallback = _trackFpFcp(); return (): void => { ttfbCleanupCallback(); + fpFcpCleanupCallback(); lcpCleanupCallback?.(); clsCleanupCallback?.(); }; @@ -303,7 +305,47 @@ function _trackTtfb(): () => void { }); } +/** Starts tracking First Paint and First Contentful Paint on the current page. */ +function _trackFpFcp(): () => void { + return addPerformanceInstrumentationHandler('paint', ({ entries }) => { + const firstHidden = getVisibilityWatcher(); + for (const entry of entries) { + // Only report if the page wasn't hidden prior to the web vital. + const shouldRecord = entry.startTime < firstHidden.firstHiddenTime; + if (entry.name === 'first-paint' && shouldRecord) { + _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; + } + if (entry.name === 'first-contentful-paint' && shouldRecord) { + _measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' }; + } + } + }); +} + interface AddPerformanceEntriesOptions { + /** + * Resource spans with `op`s matching strings in the array will not be emitted. + * + * Default: [] + */ + ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>; + + /** + * Performance spans created from browser Performance APIs, + * `performance.mark(...)` nand `performance.measure(...)` + * with `name`s matching strings in the array will not be emitted. + * + * Default: [] + */ + ignorePerformanceApiSpans: Array; + + /** + * Whether span streaming is enabled. + */ + spanStreamingEnabled?: boolean; +} + +interface AddWebVitalsToSpanOptions { /** * Flag to determine if CLS should be recorded as a measurement on the pageload span or * sent as a standalone span instead. @@ -322,22 +364,6 @@ interface AddPerformanceEntriesOptions { */ recordLcpOnPageloadSpan: boolean; - /** - * Resource spans with `op`s matching strings in the array will not be emitted. - * - * Default: [] - */ - ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>; - - /** - * Performance spans created from browser Performance APIs, - * `performance.mark(...)` nand `performance.measure(...)` - * with `name`s matching strings in the array will not be emitted. - * - * Default: [] - */ - ignorePerformanceApiSpans: Array; - /** * Whether span streaming is enabled. */ @@ -353,13 +379,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries return; } - const { - spanStreamingEnabled, - ignorePerformanceApiSpans, - ignoreResourceSpans, - recordClsOnPageloadSpan, - recordLcpOnPageloadSpan, - } = options; + const { spanStreamingEnabled, ignorePerformanceApiSpans, ignoreResourceSpans } = options; const timeOrigin = msToSec(origin); @@ -390,18 +410,6 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries case 'paint': case 'measure': { _addMeasureSpans(span, entry, startTime, duration, timeOrigin, ignorePerformanceApiSpans); - - // capture web vitals - const firstHidden = getVisibilityWatcher(); - // Only report if the page wasn't hidden prior to the web vital. - const shouldRecord = entry.startTime < firstHidden.firstHiddenTime; - - if (entry.name === 'first-paint' && shouldRecord) { - _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; - } - if (entry.name === 'first-contentful-paint' && shouldRecord) { - _measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' }; - } break; } case 'resource': { @@ -423,9 +431,28 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries _performanceCursor = Math.max(performanceEntries.length - 1, 0); _trackNavigator(span, spanStreamingEnabled); +} + +/** + * Writes the collected web vitals (LCP, CLS, INP, TTFB, FP, FCP) onto the pageload span, + * either as measurements/attributes (v1) or as web vital attributes (span streaming). + * + * This should be called when the pageload span ends, after the web vitals have been finalized. + * It is a no-op for non-pageload spans, but always resets the collected web vital state so it + * doesn't leak into a subsequent navigation. + */ +export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOptions): void { + const origin = browserPerformanceTimeOrigin(); + if (!getBrowserPerformanceAPI()?.getEntries || !origin) { + // Gatekeeper if performance API not available + return; + } + + const { spanStreamingEnabled, recordClsOnPageloadSpan, recordLcpOnPageloadSpan } = options; + const timeOrigin = msToSec(origin); // Measurements are only available for pageload transactions - if (op === 'pageload') { + if (spanToJSON(span).op === 'pageload') { _addTtfbRequestTimeToMeasurements(_measurements); if (spanStreamingEnabled) { @@ -794,9 +821,12 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined): } if (isMeasurementValue(connection.rtt)) { - _measurements['connection.rtt'] = { value: connection.rtt, unit: 'millisecond' }; if (spanStreamingEnabled) { span.setAttribute('network.connection.rtt', connection.rtt); + } else if (spanToJSON(span).op === 'pageload') { + // Measurements are only recorded on the pageload span, matching the historical + // behavior where `connection.rtt` was only flushed for pageload transactions. + setMeasurement('connection.rtt', connection.rtt, 'millisecond'); } } } @@ -819,7 +849,7 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined): } /** Add LCP / CLS data to span to allow debugging */ -function _setWebVitalAttributes(span: Span, options: AddPerformanceEntriesOptions): void { +function _setWebVitalAttributes(span: Span, options: AddWebVitalsToSpanOptions): void { // Only add LCP attributes if LCP is being recorded on the pageload span if (_lcpEntry && options.recordLcpOnPageloadSpan) { // Capture Properties of the LCP element that contributes to the LCP. diff --git a/packages/browser/src/index.bundle.tracing.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.logs.metrics.ts index 720d27eefded..6a0f1e48106b 100644 --- a/packages/browser/src/index.bundle.tracing.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.logs.metrics.ts @@ -32,6 +32,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { feedbackIntegrationShim as feedbackAsyncIntegration, diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts index c500c10ba87a..d99814d380a3 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts @@ -32,6 +32,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index 6dd2608a392b..e3f5dd324dc4 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -38,6 +38,7 @@ export { reportPageLoaded } from './tracing/reportPageLoaded'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; diff --git a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts index 027425ae2144..1d486e809f61 100644 --- a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts @@ -32,6 +32,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration }; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index 6d4a0635c314..ed6082d59bc4 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -37,6 +37,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration }; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index a3813f34a0c9..aac6825ecc65 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -39,6 +39,7 @@ export { reportPageLoaded } from './tracing/reportPageLoaded'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export { feedbackIntegrationShim as feedbackAsyncIntegration, diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index e206a7a012fc..46097d4d8f56 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -47,6 +47,7 @@ export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; +export { webVitalsIntegration } from './integrations/webVitals'; export type { RequestInstrumentationOptions } from './tracing/request'; export { diff --git a/packages/browser/src/integrations/webVitals.ts b/packages/browser/src/integrations/webVitals.ts new file mode 100644 index 000000000000..3d1b02006816 --- /dev/null +++ b/packages/browser/src/integrations/webVitals.ts @@ -0,0 +1,100 @@ +import type { Client, IntegrationFn, Span } from '@sentry/core/browser'; +import { defineIntegration, hasSpanStreamingEnabled } from '@sentry/core/browser'; +import { + addWebVitalsToSpan, + registerInpInteractionListener, + startTrackingINP, + startTrackingWebVitals, + trackClsAsSpan, + trackInpAsSpan, + trackLcpAsSpan, +} from '@sentry-internal/browser-utils'; + +export const WEB_VITALS_INTEGRATION_NAME = 'WebVitals'; + +export type WebVitalName = 'cls' | 'inp' | 'lcp'; + +export interface WebVitalsOptions { + /** + * Web vitals to skip. + */ + ignore?: WebVitalName[]; + + /** + * @experimental + */ + _experiments?: Partial<{ + enableStandaloneClsSpans: boolean; + enableStandaloneLcpSpans: boolean; + }>; +} + +const collectWebVitalsCallbacks = new WeakMap void>(); + +/** + * Finalizes the collected web vitals and writes them onto the given (pageload) span. + * Called by `browserTracingIntegration` when the pageload/navigation span ends. + */ +export function collectWebVitalsForClient(client: Client, span: Span): void { + collectWebVitalsCallbacks.get(client)?.(span); +} + +/** + * Captures Core Web Vitals (LCP, CLS, INP) and related pageload vitals. + * + * `browserTracingIntegration` auto-registers this integration if no + * `webVitalsIntegration` is already present, so explicit registration is only + * needed to customize options or to use it without `browserTracingIntegration`. + */ +export const webVitalsIntegration = defineIntegration((options: WebVitalsOptions = {}) => { + const ignored = new Set(options.ignore ?? []); + + return { + name: WEB_VITALS_INTEGRATION_NAME, + setup(client) { + const spanStreamingEnabled = hasSpanStreamingEnabled(client); + const { enableStandaloneClsSpans, enableStandaloneLcpSpans } = options._experiments ?? {}; + + const recordClsStandaloneSpans = + spanStreamingEnabled || ignored.has('cls') ? undefined : enableStandaloneClsSpans || false; + const recordLcpStandaloneSpans = + spanStreamingEnabled || ignored.has('lcp') ? undefined : enableStandaloneLcpSpans || false; + + const finalizeWebVitals = startTrackingWebVitals({ + recordClsStandaloneSpans, + recordLcpStandaloneSpans, + client, + }); + + collectWebVitalsCallbacks.set(client, span => { + finalizeWebVitals(); + addWebVitalsToSpan(span, { + // CLS/LCP are recorded as pageload span measurements only when they're neither + // tracked as standalone spans nor handled by span streaming (and not ignored). + recordClsOnPageloadSpan: recordClsStandaloneSpans === false, + recordLcpOnPageloadSpan: recordLcpStandaloneSpans === false, + spanStreamingEnabled, + }); + }); + + if (spanStreamingEnabled) { + if (!ignored.has('lcp')) { + trackLcpAsSpan(client); + } + if (!ignored.has('cls')) { + trackClsAsSpan(client); + } + if (!ignored.has('inp')) { + trackInpAsSpan(); + } + } else if (!ignored.has('inp')) { + startTrackingINP(); + } + }, + afterAllSetup() { + if (!ignored.has('inp')) { + registerInpInteractionListener(); + } + }, + }; +}) satisfies IntegrationFn; diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 8984e9e98726..e63ade9b575f 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -40,19 +40,18 @@ import { import { addHistoryInstrumentationHandler, addPerformanceEntries, - registerInpInteractionListener, - startTrackingINP, startTrackingInteractions, startTrackingLongAnimationFrames, startTrackingLongTasks, - startTrackingWebVitals, - trackClsAsSpan, - trackInpAsSpan, - trackLcpAsSpan, } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; import { getHttpRequestData, WINDOW } from '../helpers'; import { fetchStreamPerformanceIntegration } from '../integrations/fetchStreamPerformance'; +import { + collectWebVitalsForClient, + WEB_VITALS_INTEGRATION_NAME, + webVitalsIntegration, +} from '../integrations/webVitals'; import { registerBackgroundTabDetection } from './backgroundtab'; import { linkTraces } from './linkedTraces'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request'; @@ -415,7 +414,6 @@ export const browserTracingIntegration = ((options: Partial void); let lastInteractionTimestamp: number | undefined; let _pageloadSpan: Span | undefined; @@ -458,16 +456,13 @@ export const browserTracingIntegration = ((options: Partial { - // This will generally always be defined here, because it is set in `setup()` of the integration - // but technically, it is optional, so we guard here to be extra safe - _collectWebVitals?.(); - const spanStreamingEnabled = hasSpanStreamingEnabled(client); + // The webVitalsIntegration owns all web vital logic; it finalizes the collected + // web vitals and writes them onto the pageload span. + collectWebVitalsForClient(client, span); addPerformanceEntries(span, { - recordClsOnPageloadSpan: !spanStreamingEnabled && !enableStandaloneClsSpans, - recordLcpOnPageloadSpan: !spanStreamingEnabled && !enableStandaloneLcpSpans, ignoreResourceSpans, ignorePerformanceApiSpans, - spanStreamingEnabled, + spanStreamingEnabled: hasSpanStreamingEnabled(client), }); setActiveIdleSpan(client, undefined); @@ -524,24 +519,6 @@ export const browserTracingIntegration = ((options: Partial { @@ -11,6 +11,7 @@ describe('index.bundle.tracing.logs.metrics', () => { expect(TracingLogsMetricsBundle.feedbackIntegration).toBe(feedbackIntegrationShim); expect(TracingLogsMetricsBundle.replayIntegration).toBe(replayIntegrationShim); expect(TracingLogsMetricsBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingLogsMetricsBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingLogsMetricsBundle.logger).toBe(coreLogger); expect(TracingLogsMetricsBundle.metrics).toBe(coreMetrics); diff --git a/packages/browser/test/index.bundle.tracing.replay.feedback.logs.metrics.test.ts b/packages/browser/test/index.bundle.tracing.replay.feedback.logs.metrics.test.ts index e4b88fab24d7..dcb7b869aa4d 100644 --- a/packages/browser/test/index.bundle.tracing.replay.feedback.logs.metrics.test.ts +++ b/packages/browser/test/index.bundle.tracing.replay.feedback.logs.metrics.test.ts @@ -5,6 +5,7 @@ import { feedbackAsyncIntegration, replayIntegration, spanStreamingIntegration, + webVitalsIntegration, } from '../src'; import * as TracingReplayFeedbackLogsMetricsBundle from '../src/index.bundle.tracing.replay.feedback.logs.metrics'; @@ -15,6 +16,7 @@ describe('index.bundle.tracing.replay.feedback.logs.metrics', () => { expect(TracingReplayFeedbackLogsMetricsBundle.feedbackIntegration).toBe(feedbackAsyncIntegration); expect(TracingReplayFeedbackLogsMetricsBundle.replayIntegration).toBe(replayIntegration); expect(TracingReplayFeedbackLogsMetricsBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingReplayFeedbackLogsMetricsBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingReplayFeedbackLogsMetricsBundle.logger).toBe(coreLogger); expect(TracingReplayFeedbackLogsMetricsBundle.metrics).toBe(coreMetrics); diff --git a/packages/browser/test/index.bundle.tracing.replay.feedback.test.ts b/packages/browser/test/index.bundle.tracing.replay.feedback.test.ts index fe60d079dc41..1bb8fffbeef9 100644 --- a/packages/browser/test/index.bundle.tracing.replay.feedback.test.ts +++ b/packages/browser/test/index.bundle.tracing.replay.feedback.test.ts @@ -5,6 +5,7 @@ import { feedbackAsyncIntegration, replayIntegration, spanStreamingIntegration, + webVitalsIntegration, } from '../src'; import * as TracingReplayFeedbackBundle from '../src/index.bundle.tracing.replay.feedback'; @@ -15,6 +16,7 @@ describe('index.bundle.tracing.replay.feedback', () => { expect(TracingReplayFeedbackBundle.feedbackIntegration).toBe(feedbackAsyncIntegration); expect(TracingReplayFeedbackBundle.replayIntegration).toBe(replayIntegration); expect(TracingReplayFeedbackBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingReplayFeedbackBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingReplayFeedbackBundle.logger).toBe(loggerShim); expect(TracingReplayFeedbackBundle.consoleLoggingIntegration).toBe(consoleLoggingIntegrationShim); diff --git a/packages/browser/test/index.bundle.tracing.replay.logs.metrics.test.ts b/packages/browser/test/index.bundle.tracing.replay.logs.metrics.test.ts index f8571872ba95..aecd1c995dda 100644 --- a/packages/browser/test/index.bundle.tracing.replay.logs.metrics.test.ts +++ b/packages/browser/test/index.bundle.tracing.replay.logs.metrics.test.ts @@ -1,7 +1,7 @@ import { logger as coreLogger, metrics as coreMetrics } from '@sentry/core/browser'; import { feedbackIntegrationShim } from '@sentry-internal/integration-shims'; import { describe, expect, it } from 'vitest'; -import { browserTracingIntegration, replayIntegration, spanStreamingIntegration } from '../src'; +import { browserTracingIntegration, replayIntegration, spanStreamingIntegration, webVitalsIntegration } from '../src'; import * as TracingReplayLogsMetricsBundle from '../src/index.bundle.tracing.replay.logs.metrics'; describe('index.bundle.tracing.replay.logs.metrics', () => { @@ -11,6 +11,7 @@ describe('index.bundle.tracing.replay.logs.metrics', () => { expect(TracingReplayLogsMetricsBundle.feedbackIntegration).toBe(feedbackIntegrationShim); expect(TracingReplayLogsMetricsBundle.replayIntegration).toBe(replayIntegration); expect(TracingReplayLogsMetricsBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingReplayLogsMetricsBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingReplayLogsMetricsBundle.logger).toBe(coreLogger); expect(TracingReplayLogsMetricsBundle.metrics).toBe(coreMetrics); diff --git a/packages/browser/test/index.bundle.tracing.replay.test.ts b/packages/browser/test/index.bundle.tracing.replay.test.ts index 1cdae8214a20..847e572be009 100644 --- a/packages/browser/test/index.bundle.tracing.replay.test.ts +++ b/packages/browser/test/index.bundle.tracing.replay.test.ts @@ -1,6 +1,6 @@ import { consoleLoggingIntegrationShim, feedbackIntegrationShim, loggerShim } from '@sentry-internal/integration-shims'; import { describe, expect, it } from 'vitest'; -import { browserTracingIntegration, replayIntegration, spanStreamingIntegration } from '../src'; +import { browserTracingIntegration, replayIntegration, spanStreamingIntegration, webVitalsIntegration } from '../src'; import * as TracingReplayBundle from '../src/index.bundle.tracing.replay'; describe('index.bundle.tracing.replay', () => { @@ -10,6 +10,7 @@ describe('index.bundle.tracing.replay', () => { expect(TracingReplayBundle.feedbackIntegration).toBe(feedbackIntegrationShim); expect(TracingReplayBundle.replayIntegration).toBe(replayIntegration); expect(TracingReplayBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingReplayBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingReplayBundle.logger).toBe(loggerShim); expect(TracingReplayBundle.consoleLoggingIntegration).toBe(consoleLoggingIntegrationShim); diff --git a/packages/browser/test/index.bundle.tracing.test.ts b/packages/browser/test/index.bundle.tracing.test.ts index 75fb658c860d..9d41fcd10e42 100644 --- a/packages/browser/test/index.bundle.tracing.test.ts +++ b/packages/browser/test/index.bundle.tracing.test.ts @@ -5,7 +5,7 @@ import { replayIntegrationShim, } from '@sentry-internal/integration-shims'; import { describe, expect, it } from 'vitest'; -import { browserTracingIntegration, spanStreamingIntegration } from '../src'; +import { browserTracingIntegration, spanStreamingIntegration, webVitalsIntegration } from '../src'; import * as TracingBundle from '../src/index.bundle.tracing'; describe('index.bundle.tracing', () => { @@ -15,6 +15,7 @@ describe('index.bundle.tracing', () => { expect(TracingBundle.feedbackIntegration).toBe(feedbackIntegrationShim); expect(TracingBundle.replayIntegration).toBe(replayIntegrationShim); expect(TracingBundle.spanStreamingIntegration).toBe(spanStreamingIntegration); + expect(TracingBundle.webVitalsIntegration).toBe(webVitalsIntegration); expect(TracingBundle.logger).toBe(loggerShim); expect(TracingBundle.consoleLoggingIntegration).toBe(consoleLoggingIntegrationShim); diff --git a/packages/browser/test/integrations/webVitals.test.ts b/packages/browser/test/integrations/webVitals.test.ts new file mode 100644 index 000000000000..0c2bb196d27f --- /dev/null +++ b/packages/browser/test/integrations/webVitals.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { collectWebVitalsForClient, webVitalsIntegration } from '../../src/integrations/webVitals'; + +const mockAddWebVitalsToSpan = vi.hoisted(() => vi.fn()); +const mockRegisterInpInteractionListener = vi.hoisted(() => vi.fn()); +const mockStartTrackingINP = vi.hoisted(() => vi.fn()); +const mockStartTrackingWebVitals = vi.hoisted(() => vi.fn()); +const mockTrackClsAsSpan = vi.hoisted(() => vi.fn()); +const mockTrackInpAsSpan = vi.hoisted(() => vi.fn()); +const mockTrackLcpAsSpan = vi.hoisted(() => vi.fn()); + +vi.mock('@sentry-internal/browser-utils', () => ({ + addWebVitalsToSpan: mockAddWebVitalsToSpan, + registerInpInteractionListener: mockRegisterInpInteractionListener, + startTrackingINP: mockStartTrackingINP, + startTrackingWebVitals: mockStartTrackingWebVitals, + trackClsAsSpan: mockTrackClsAsSpan, + trackInpAsSpan: mockTrackInpAsSpan, + trackLcpAsSpan: mockTrackLcpAsSpan, +})); + +describe('webVitalsIntegration', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStartTrackingWebVitals.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('tracks web vitals with the existing non-streaming behavior by default', () => { + const client = { getOptions: () => ({}) }; + const integration = webVitalsIntegration(); + + integration.setup?.(client as never); + integration.afterAllSetup?.(client as never); + + expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ + recordClsStandaloneSpans: false, + recordLcpStandaloneSpans: false, + client, + }); + expect(mockStartTrackingINP).toHaveBeenCalledTimes(1); + expect(mockRegisterInpInteractionListener).toHaveBeenCalledTimes(1); + expect(mockTrackLcpAsSpan).not.toHaveBeenCalled(); + expect(mockTrackClsAsSpan).not.toHaveBeenCalled(); + expect(mockTrackInpAsSpan).not.toHaveBeenCalled(); + }); + + it('keeps standalone LCP and CLS experiments working', () => { + const client = { getOptions: () => ({}) }; + const integration = webVitalsIntegration({ + _experiments: { + enableStandaloneClsSpans: true, + enableStandaloneLcpSpans: true, + }, + }); + + integration.setup?.(client as never); + + expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ + recordClsStandaloneSpans: true, + recordLcpStandaloneSpans: true, + client, + }); + }); + + it('tracks LCP, CLS and INP as streamed spans when span streaming is enabled', () => { + const client = { getOptions: () => ({ traceLifecycle: 'stream' }) }; + const integration = webVitalsIntegration(); + + integration.setup?.(client as never); + integration.afterAllSetup?.(client as never); + + expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ + recordClsStandaloneSpans: undefined, + recordLcpStandaloneSpans: undefined, + client, + }); + expect(mockTrackLcpAsSpan).toHaveBeenCalledWith(client); + expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client); + expect(mockTrackInpAsSpan).toHaveBeenCalledTimes(1); + expect(mockStartTrackingINP).not.toHaveBeenCalled(); + expect(mockRegisterInpInteractionListener).toHaveBeenCalledTimes(1); + }); + + it('supports ignoring selected web vitals for browserTracingIntegration compatibility', () => { + const client = { getOptions: () => ({}) }; + const integration = webVitalsIntegration({ ignore: ['cls', 'inp', 'lcp'] }); + + integration.setup?.(client as never); + integration.afterAllSetup?.(client as never); + + expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ + recordClsStandaloneSpans: undefined, + recordLcpStandaloneSpans: undefined, + client, + }); + expect(mockStartTrackingINP).not.toHaveBeenCalled(); + expect(mockTrackInpAsSpan).not.toHaveBeenCalled(); + expect(mockRegisterInpInteractionListener).not.toHaveBeenCalled(); + }); + + it('finalizes web vitals and writes them onto the span when collected for the client', () => { + const finalizeWebVitals = vi.fn(); + const client = { getOptions: () => ({}) }; + const span = {}; + mockStartTrackingWebVitals.mockReturnValue(finalizeWebVitals); + + webVitalsIntegration().setup?.(client as never); + collectWebVitalsForClient(client as never, span as never); + + expect(finalizeWebVitals).toHaveBeenCalledTimes(1); + expect(mockAddWebVitalsToSpan).toHaveBeenCalledWith(span, { + recordClsOnPageloadSpan: true, + recordLcpOnPageloadSpan: true, + spanStreamingEnabled: false, + }); + }); + + it('does not record CLS/LCP on the pageload span when span streaming is enabled', () => { + const client = { getOptions: () => ({ traceLifecycle: 'stream' }) }; + const span = {}; + + webVitalsIntegration().setup?.(client as never); + collectWebVitalsForClient(client as never, span as never); + + expect(mockAddWebVitalsToSpan).toHaveBeenCalledWith(span, { + recordClsOnPageloadSpan: false, + recordLcpOnPageloadSpan: false, + spanStreamingEnabled: true, + }); + }); + + it('does not record CLS/LCP on the pageload span when standalone spans are enabled', () => { + const client = { getOptions: () => ({}) }; + const span = {}; + const integration = webVitalsIntegration({ + _experiments: { enableStandaloneClsSpans: true, enableStandaloneLcpSpans: true }, + }); + + integration.setup?.(client as never); + collectWebVitalsForClient(client as never, span as never); + + expect(mockAddWebVitalsToSpan).toHaveBeenCalledWith(span, { + recordClsOnPageloadSpan: false, + recordLcpOnPageloadSpan: false, + spanStreamingEnabled: false, + }); + }); +}); diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 59c79d98bb73..aedacafb8b82 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -181,6 +181,19 @@ describe('browserTracingIntegration', () => { }); }); + it('auto-registers webVitalsIntegration', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration()], + }), + ); + setCurrentClient(client); + client.init(); + + expect(client.getIntegrationByName('WebVitals')).toBeDefined(); + }); + it('works with tracing disabled', () => { const client = new BrowserClient( getDefaultBrowserClientOptions({