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({