diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js
deleted file mode 100644
index dce8cd2508fd..000000000000
--- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import * as Sentry from '@sentry/browser';
-
-window.Sentry = Sentry;
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- integrations: [
- Sentry.browserTracingIntegration({
- idleTimeout: 5000,
- _experiments: {
- enableStandaloneClsSpans: true,
- },
- }),
- ],
- tracesSampleRate: 1,
-});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/subject.js
deleted file mode 100644
index ed1b9b790bb9..000000000000
--- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/subject.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { simulateCLS } from '../../../../utils/web-vitals/cls.ts';
-
-// Simulate Layout shift right at the beginning of the page load, depending on the URL hash
-// don't run if expected CLS is NaN
-const expectedCLS = Number(location.hash.slice(1));
-if (expectedCLS && expectedCLS >= 0) {
- simulateCLS(expectedCLS).then(() => window.dispatchEvent(new Event('cls-done')));
-}
-
-// Simulate layout shift whenever the trigger-cls event is dispatched
-// Cannot trigger cia a button click because expected layout shift after
-// an interaction doesn't contribute to CLS.
-window.addEventListener('trigger-cls', () => {
- simulateCLS(0.1).then(() => {
- window.dispatchEvent(new Event('cls-done'));
- });
-});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/template.html
deleted file mode 100644
index 10e2e22f7d6a..000000000000
--- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/template.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
- Some content
-
-
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts
deleted file mode 100644
index fd4b3b8fa06b..000000000000
--- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts
+++ /dev/null
@@ -1,516 +0,0 @@
-import type { Page } from '@playwright/test';
-import { expect } from '@playwright/test';
-import type { Event as SentryEvent, EventEnvelope, SpanEnvelope } from '@sentry/core';
-import { sentryTest } from '../../../../utils/fixtures';
-import {
- getFirstSentryEnvelopeRequest,
- getMultipleSentryEnvelopeRequests,
- properFullEnvelopeRequestParser,
- shouldSkipTracingTest,
-} from '../../../../utils/helpers';
-
-sentryTest.beforeEach(async ({ browserName, page }) => {
- if (shouldSkipTracingTest() || browserName !== 'chromium') {
- sentryTest.skip();
- }
-
- await page.setViewportSize({ width: 800, height: 1200 });
-});
-
-function waitForLayoutShift(page: Page): Promise {
- return page.evaluate(() => {
- return new Promise(resolve => {
- window.addEventListener('cls-done', () => resolve());
- });
- });
-}
-
-function triggerAndWaitForLayoutShift(page: Page): Promise {
- return page.evaluate(() => {
- window.dispatchEvent(new CustomEvent('trigger-cls'));
- return new Promise(resolve => {
- window.addEventListener('cls-done', () => resolve());
- });
- });
-}
-
-function hidePage(page: Page): Promise {
- return page.evaluate(() => {
- window.dispatchEvent(new Event('pagehide'));
- });
-}
-
-sentryTest('captures a "GOOD" CLS vital with its source as a standalone span', async ({ getLocalTestUrl, page }) => {
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- const url = await getLocalTestUrl({ testDir: __dirname });
- await page.goto(`${url}#0.05`);
-
- await waitForLayoutShift(page);
-
- await hidePage(page);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
-
- const spanEnvelopeHeaders = spanEnvelope[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
-
- expect(spanEnvelopeItem).toEqual({
- data: {
- 'sentry.exclusive_time': 0,
- 'sentry.op': 'ui.webvital.cls',
- 'sentry.origin': 'auto.http.browser.cls',
- 'sentry.report_event': 'pagehide',
- transaction: expect.stringContaining('index.html'),
- 'user_agent.original': expect.stringContaining('Chrome'),
- 'sentry.pageload.span_id': expect.stringMatching(/[a-f\d]{16}/),
- 'cls.source.1': expect.stringContaining('body > div#content > p'),
- },
- description: expect.stringContaining('body > div#content > p'),
- exclusive_time: 0,
- measurements: {
- cls: {
- unit: '',
- value: expect.any(Number), // better check below,
- },
- },
- op: 'ui.webvital.cls',
- origin: 'auto.http.browser.cls',
- parent_span_id: expect.stringMatching(/[a-f\d]{16}/),
- span_id: expect.stringMatching(/[a-f\d]{16}/),
- segment_id: expect.stringMatching(/[a-f\d]{16}/),
- start_timestamp: expect.any(Number),
- timestamp: spanEnvelopeItem.start_timestamp,
- trace_id: expect.stringMatching(/[a-f\d]{32}/),
- });
-
- // Flakey value dependent on timings -> we check for a range
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.03);
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.07);
-
- expect(spanEnvelopeHeaders).toEqual({
- sent_at: expect.any(String),
- trace: {
- environment: 'production',
- public_key: 'public',
- sample_rate: '1',
- sampled: 'true',
- trace_id: spanEnvelopeItem.trace_id,
- sample_rand: expect.any(String),
- // no transaction, because span source is URL
- },
- });
-});
-
-sentryTest('captures a "MEH" CLS vital with its source as a standalone span', async ({ getLocalTestUrl, page }) => {
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- const url = await getLocalTestUrl({ testDir: __dirname });
- await page.goto(`${url}#0.21`);
-
- await waitForLayoutShift(page);
-
- // Page hide to trigger CLS emission
- await page.evaluate(() => {
- window.dispatchEvent(new Event('pagehide'));
- });
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
-
- const spanEnvelopeHeaders = spanEnvelope[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
-
- expect(spanEnvelopeItem).toEqual({
- data: {
- 'sentry.exclusive_time': 0,
- 'sentry.op': 'ui.webvital.cls',
- 'sentry.origin': 'auto.http.browser.cls',
- 'sentry.report_event': 'pagehide',
- transaction: expect.stringContaining('index.html'),
- 'user_agent.original': expect.stringContaining('Chrome'),
- 'sentry.pageload.span_id': expect.stringMatching(/[a-f\d]{16}/),
- 'cls.source.1': expect.stringContaining('body > div#content > p'),
- },
- description: expect.stringContaining('body > div#content > p'),
- exclusive_time: 0,
- measurements: {
- cls: {
- unit: '',
- value: expect.any(Number), // better check below,
- },
- },
- op: 'ui.webvital.cls',
- origin: 'auto.http.browser.cls',
- parent_span_id: expect.stringMatching(/[a-f\d]{16}/),
- span_id: expect.stringMatching(/[a-f\d]{16}/),
- segment_id: expect.stringMatching(/[a-f\d]{16}/),
- start_timestamp: expect.any(Number),
- timestamp: spanEnvelopeItem.start_timestamp,
- trace_id: expect.stringMatching(/[a-f\d]{32}/),
- });
-
- // Flakey value dependent on timings -> we check for a range
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.18);
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.23);
-
- expect(spanEnvelopeHeaders).toEqual({
- sent_at: expect.any(String),
- trace: {
- environment: 'production',
- public_key: 'public',
- sample_rate: '1',
- sampled: 'true',
- trace_id: spanEnvelopeItem.trace_id,
- sample_rand: expect.any(String),
- // no transaction, because span source is URL
- },
- });
-});
-
-sentryTest('captures a "POOR" CLS vital with its source as a standalone span.', async ({ getLocalTestUrl, page }) => {
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- const url = await getLocalTestUrl({ testDir: __dirname });
- await page.goto(`${url}#0.35`);
-
- await waitForLayoutShift(page);
-
- // Page hide to trigger CLS emission
- await hidePage(page);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
-
- const spanEnvelopeHeaders = spanEnvelope[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
-
- expect(spanEnvelopeItem).toEqual({
- data: {
- 'sentry.exclusive_time': 0,
- 'sentry.op': 'ui.webvital.cls',
- 'sentry.origin': 'auto.http.browser.cls',
- 'sentry.report_event': 'pagehide',
- transaction: expect.stringContaining('index.html'),
- 'user_agent.original': expect.stringContaining('Chrome'),
- 'sentry.pageload.span_id': expect.stringMatching(/[a-f\d]{16}/),
- 'cls.source.1': expect.stringContaining('body > div#content > p'),
- },
- description: expect.stringContaining('body > div#content > p'),
- exclusive_time: 0,
- measurements: {
- cls: {
- unit: '',
- value: expect.any(Number), // better check below,
- },
- },
- op: 'ui.webvital.cls',
- origin: 'auto.http.browser.cls',
- parent_span_id: expect.stringMatching(/[a-f\d]{16}/),
- span_id: expect.stringMatching(/[a-f\d]{16}/),
- segment_id: expect.stringMatching(/[a-f\d]{16}/),
- start_timestamp: expect.any(Number),
- timestamp: spanEnvelopeItem.start_timestamp,
- trace_id: expect.stringMatching(/[a-f\d]{32}/),
- });
-
- // Flakey value dependent on timings -> we check for a range
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.33);
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.38);
-
- expect(spanEnvelopeHeaders).toEqual({
- sent_at: expect.any(String),
- trace: {
- environment: 'production',
- public_key: 'public',
- sample_rate: '1',
- sampled: 'true',
- trace_id: spanEnvelopeItem.trace_id,
- sample_rand: expect.any(String),
- // no transaction, because span source is URL
- },
- });
-});
-
-sentryTest(
- 'captures a 0 CLS vital as a standalone span if no layout shift occurred',
- async ({ getLocalTestUrl, page }) => {
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- const url = await getLocalTestUrl({ testDir: __dirname });
- await page.goto(url);
-
- await page.waitForTimeout(1000);
-
- await hidePage(page);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
-
- const spanEnvelopeHeaders = spanEnvelope[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
-
- expect(spanEnvelopeItem).toEqual({
- data: {
- 'sentry.exclusive_time': 0,
- 'sentry.op': 'ui.webvital.cls',
- 'sentry.origin': 'auto.http.browser.cls',
- 'sentry.report_event': 'pagehide',
- transaction: expect.stringContaining('index.html'),
- 'user_agent.original': expect.stringContaining('Chrome'),
- 'sentry.pageload.span_id': expect.stringMatching(/[a-f\d]{16}/),
- },
- description: 'Layout shift',
- exclusive_time: 0,
- measurements: {
- cls: {
- unit: '',
- value: 0,
- },
- },
- op: 'ui.webvital.cls',
- origin: 'auto.http.browser.cls',
- parent_span_id: expect.stringMatching(/[a-f\d]{16}/),
- span_id: expect.stringMatching(/[a-f\d]{16}/),
- segment_id: expect.stringMatching(/[a-f\d]{16}/),
- start_timestamp: expect.any(Number),
- timestamp: spanEnvelopeItem.start_timestamp,
- trace_id: expect.stringMatching(/[a-f\d]{32}/),
- });
-
- expect(spanEnvelopeHeaders).toEqual({
- sent_at: expect.any(String),
- trace: {
- environment: 'production',
- public_key: 'public',
- sample_rate: '1',
- sampled: 'true',
- trace_id: spanEnvelopeItem.trace_id,
- sample_rand: expect.any(String),
- // no transaction, because span source is URL
- },
- });
- },
-);
-
-sentryTest(
- 'captures CLS increases after the pageload span ended, when page is hidden',
- async ({ getLocalTestUrl, page }) => {
- const url = await getLocalTestUrl({ testDir: __dirname });
-
- const eventData = await getFirstSentryEnvelopeRequest(page, url);
-
- expect(eventData.type).toBe('transaction');
- expect(eventData.contexts?.trace?.op).toBe('pageload');
-
- const pageloadSpanId = eventData.contexts?.trace?.span_id;
- const pageloadTraceId = eventData.contexts?.trace?.trace_id;
-
- expect(pageloadSpanId).toMatch(/[a-f\d]{16}/);
- expect(pageloadTraceId).toMatch(/[a-f\d]{32}/);
-
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- await triggerAndWaitForLayoutShift(page);
-
- await hidePage(page);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
- // Flakey value dependent on timings -> we check for a range
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.05);
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.15);
-
- // Ensure the CLS span is connected to the pageload span and trace
- expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadSpanId);
- expect(spanEnvelopeItem.trace_id).toEqual(pageloadTraceId);
-
- expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('pagehide');
- },
-);
-
-sentryTest('sends CLS of the initial page when soft-navigating to a new page', async ({ getLocalTestUrl, page }) => {
- const url = await getLocalTestUrl({ testDir: __dirname });
-
- const pageloadEventData = await getFirstSentryEnvelopeRequest(page, url);
-
- expect(pageloadEventData.type).toBe('transaction');
- expect(pageloadEventData.contexts?.trace?.op).toBe('pageload');
-
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- await triggerAndWaitForLayoutShift(page);
-
- await page.goto(`${url}#soft-navigation`);
-
- const pageloadTraceId = pageloadEventData.contexts?.trace?.trace_id;
- expect(pageloadTraceId).toMatch(/[a-f\d]{32}/);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
- // Flakey value dependent on timings -> we check for a range
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.05);
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.15);
- expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadEventData.contexts?.trace?.span_id);
- expect(spanEnvelopeItem.trace_id).toEqual(pageloadTraceId);
-
- expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation');
-});
-
-sentryTest("doesn't send further CLS after the first navigation", async ({ getLocalTestUrl, page }) => {
- const url = await getLocalTestUrl({ testDir: __dirname });
-
- const eventData = await getFirstSentryEnvelopeRequest(page, url);
-
- expect(eventData.type).toBe('transaction');
- expect(eventData.contexts?.trace?.op).toBe('pageload');
-
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- await triggerAndWaitForLayoutShift(page);
-
- await page.goto(`${url}#soft-navigation`);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0);
- expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation');
-
- getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'span' }, () => {
- throw new Error('Unexpected span - This should not happen!');
- });
-
- const navigationTxnPromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'transaction' },
- properFullEnvelopeRequestParser,
- );
-
- // activate both CLS emission triggers:
- await page.goto(`${url}#soft-navigation-2`);
- await hidePage(page);
-
- // assumption: If we would send another CLS span on the 2nd navigation, it would be sent before the navigation
- // transaction ends. This isn't 100% safe to ensure we don't send something but otherwise we'd need to wait for
- // a timeout or something similar.
- await navigationTxnPromise;
-});
-
-sentryTest("doesn't send further CLS after the first page hide", async ({ getLocalTestUrl, page }) => {
- const url = await getLocalTestUrl({ testDir: __dirname });
-
- const eventData = await getFirstSentryEnvelopeRequest(page, url);
-
- expect(eventData.type).toBe('transaction');
- expect(eventData.contexts?.trace?.op).toBe('pageload');
-
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- await triggerAndWaitForLayoutShift(page);
-
- await hidePage(page);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
- expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0);
- expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('pagehide');
-
- getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'span' }, () => {
- throw new Error('Unexpected span - This should not happen!');
- });
-
- const navigationTxnPromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'transaction' },
- properFullEnvelopeRequestParser,
- );
-
- // activate both CLS emission triggers:
- await page.goto(`${url}#soft-navigation-2`);
- await hidePage(page);
-
- // assumption: If we would send another CLS span on the 2nd navigation, it would be sent before the navigation
- // transaction ends. This isn't 100% safe to ensure we don't send something but otherwise we'd need to wait for
- // a timeout or something similar.
- await navigationTxnPromise;
-});
-
-sentryTest('CLS span timestamps are set correctly', async ({ getLocalTestUrl, page }) => {
- const url = await getLocalTestUrl({ testDir: __dirname });
-
- const eventData = await getFirstSentryEnvelopeRequest(page, url);
-
- expect(eventData.type).toBe('transaction');
- expect(eventData.contexts?.trace?.op).toBe('pageload');
- expect(eventData.timestamp).toBeDefined();
-
- const pageloadEndTimestamp = eventData.timestamp!;
-
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- await triggerAndWaitForLayoutShift(page);
-
- await hidePage(page);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
-
- expect(spanEnvelopeItem.start_timestamp).toBeDefined();
- expect(spanEnvelopeItem.timestamp).toBeDefined();
-
- const clsSpanStartTimestamp = spanEnvelopeItem.start_timestamp!;
- const clsSpanEndTimestamp = spanEnvelopeItem.timestamp!;
-
- // CLS performance entries have no duration ==> start and end timestamp should be the same
- expect(clsSpanStartTimestamp).toEqual(clsSpanEndTimestamp);
-
- // We don't really care that they are very close together but rather about the order of magnitude
- // Previously, we had a bug where the timestamps would be significantly off (by multiple hours)
- // so we only ensure that this bug is fixed. 60 seconds should be more than enough.
- expect(clsSpanStartTimestamp - pageloadEndTimestamp).toBeLessThan(60);
- expect(clsSpanStartTimestamp).toBeGreaterThan(pageloadEndTimestamp);
-});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/assets/sentry-logo-600x179.png b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/assets/sentry-logo-600x179.png
deleted file mode 100644
index 353b7233d6bf..000000000000
Binary files a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/assets/sentry-logo-600x179.png and /dev/null differ
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js
deleted file mode 100644
index d09eeab5f565..000000000000
--- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import * as Sentry from '@sentry/browser';
-
-window.Sentry = Sentry;
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- integrations: [
- Sentry.browserTracingIntegration({
- idleTimeout: 5000,
- _experiments: {
- enableStandaloneLcpSpans: true,
- },
- }),
- ],
- tracesSampleRate: 1,
- debug: true,
-});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/template.html
deleted file mode 100644
index b613a556aca4..000000000000
--- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/template.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts
deleted file mode 100644
index e2b8a3e66e44..000000000000
--- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts
+++ /dev/null
@@ -1,369 +0,0 @@
-import type { Page, Route } from '@playwright/test';
-import { expect } from '@playwright/test';
-import type { Event as SentryEvent, EventEnvelope, SpanEnvelope } from '@sentry/core';
-import { sentryTest } from '../../../../utils/fixtures';
-import {
- envelopeRequestParser,
- getFirstSentryEnvelopeRequest,
- getMultipleSentryEnvelopeRequests,
- properFullEnvelopeRequestParser,
- shouldSkipTracingTest,
- waitForTransactionRequest,
-} from '../../../../utils/helpers';
-
-sentryTest.beforeEach(async ({ browserName, page }) => {
- if (shouldSkipTracingTest() || browserName !== 'chromium') {
- sentryTest.skip();
- }
-
- await page.setViewportSize({ width: 800, height: 1200 });
-});
-
-function hidePage(page: Page): Promise {
- return page.evaluate(() => {
- window.dispatchEvent(new Event('pagehide'));
- });
-}
-
-sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl, page }) => {
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- const pageloadEnvelopePromise = waitForTransactionRequest(page, e => e.contexts?.trace?.op === 'pageload');
-
- page.route('**', route => route.continue());
- page.route('**/my/image.png', async (route: Route) => {
- return route.fulfill({
- path: `${__dirname}/assets/sentry-logo-600x179.png`,
- });
- });
-
- const url = await getLocalTestUrl({ testDir: __dirname });
- await page.goto(url);
-
- // Wait for LCP to be captured
- await page.waitForTimeout(1000);
-
- await hidePage(page);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
- const pageloadTransactionEvent = envelopeRequestParser(await pageloadEnvelopePromise);
-
- const spanEnvelopeHeaders = spanEnvelope[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
-
- const pageloadTraceId = pageloadTransactionEvent.contexts?.trace?.trace_id;
- expect(pageloadTraceId).toMatch(/[a-f\d]{32}/);
-
- expect(spanEnvelopeItem).toEqual({
- data: {
- 'sentry.exclusive_time': 0,
- 'sentry.op': 'ui.webvital.lcp',
- 'sentry.origin': 'auto.http.browser.lcp',
- 'sentry.report_event': 'pagehide',
- transaction: expect.stringContaining('index.html'),
- 'user_agent.original': expect.stringContaining('Chrome'),
- 'sentry.pageload.span_id': expect.stringMatching(/[a-f\d]{16}/),
- 'lcp.element': 'body > img',
- 'lcp.loadTime': expect.any(Number),
- 'lcp.renderTime': expect.any(Number),
- 'lcp.size': expect.any(Number),
- 'lcp.url': 'https://sentry-test-site.example/my/image.png',
- },
- description: expect.stringContaining('body > img'),
- exclusive_time: 0,
- measurements: {
- lcp: {
- unit: 'millisecond',
- value: expect.any(Number),
- },
- },
- op: 'ui.webvital.lcp',
- origin: 'auto.http.browser.lcp',
- parent_span_id: expect.stringMatching(/[a-f\d]{16}/),
- span_id: expect.stringMatching(/[a-f\d]{16}/),
- segment_id: expect.stringMatching(/[a-f\d]{16}/),
- start_timestamp: expect.any(Number),
- timestamp: spanEnvelopeItem.start_timestamp, // LCP is a point-in-time metric
- trace_id: pageloadTraceId,
- });
-
- // LCP value should be greater than 0
- expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
-
- expect(spanEnvelopeHeaders).toEqual({
- sent_at: expect.any(String),
- trace: {
- environment: 'production',
- public_key: 'public',
- sample_rate: '1',
- sampled: 'true',
- trace_id: spanEnvelopeItem.trace_id,
- sample_rand: expect.any(String),
- },
- });
-});
-
-sentryTest('LCP span is linked to pageload transaction', async ({ getLocalTestUrl, page }) => {
- page.route('**', route => route.continue());
- page.route('**/my/image.png', async (route: Route) => {
- return route.fulfill({
- path: `${__dirname}/assets/sentry-logo-600x179.png`,
- });
- });
-
- const url = await getLocalTestUrl({ testDir: __dirname });
-
- const eventData = await getFirstSentryEnvelopeRequest(page, url);
-
- expect(eventData.type).toBe('transaction');
- expect(eventData.contexts?.trace?.op).toBe('pageload');
-
- const pageloadSpanId = eventData.contexts?.trace?.span_id;
- const pageloadTraceId = eventData.contexts?.trace?.trace_id;
-
- expect(pageloadSpanId).toMatch(/[a-f\d]{16}/);
- expect(pageloadTraceId).toMatch(/[a-f\d]{32}/);
-
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- // Wait for LCP to be captured
- await page.waitForTimeout(1000);
-
- await hidePage(page);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
-
- // Ensure the LCP span is connected to the pageload span and trace
- expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadSpanId);
- expect(spanEnvelopeItem.trace_id).toEqual(pageloadTraceId);
- expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
-});
-
-sentryTest('sends LCP of the initial page when soft-navigating to a new page', async ({ getLocalTestUrl, page }) => {
- page.route('**', route => route.continue());
- page.route('**/my/image.png', async (route: Route) => {
- return route.fulfill({
- path: `${__dirname}/assets/sentry-logo-600x179.png`,
- });
- });
-
- const url = await getLocalTestUrl({ testDir: __dirname });
-
- const pageloadEventData = await getFirstSentryEnvelopeRequest(page, url);
-
- expect(pageloadEventData.type).toBe('transaction');
- expect(pageloadEventData.contexts?.trace?.op).toBe('pageload');
-
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- // Wait for LCP to be captured
- await page.waitForTimeout(1000);
-
- await page.goto(`${url}#soft-navigation`);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
-
- expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
- expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadEventData.contexts?.trace?.span_id);
- expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation');
- expect(spanEnvelopeItem.trace_id).toBe(pageloadEventData.contexts?.trace?.trace_id);
-});
-
-sentryTest("doesn't send further LCP after the first navigation", async ({ getLocalTestUrl, page }) => {
- page.route('**', route => route.continue());
- page.route('**/my/image.png', async (route: Route) => {
- return route.fulfill({
- path: `${__dirname}/assets/sentry-logo-600x179.png`,
- });
- });
-
- const url = await getLocalTestUrl({ testDir: __dirname });
-
- const pageloadEventData = await getFirstSentryEnvelopeRequest(page, url);
-
- expect(pageloadEventData.type).toBe('transaction');
- expect(pageloadEventData.contexts?.trace?.op).toBe('pageload');
-
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- // Wait for LCP to be captured
- await page.waitForTimeout(1000);
-
- await page.goto(`${url}#soft-navigation`);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
- expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
- expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation');
- expect(spanEnvelopeItem.trace_id).toBe(pageloadEventData.contexts?.trace?.trace_id);
-
- getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'span' }, () => {
- throw new Error('Unexpected span - This should not happen!');
- });
-
- const navigationTxnPromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'transaction' },
- properFullEnvelopeRequestParser,
- );
-
- // activate both LCP emission triggers:
- await page.goto(`${url}#soft-navigation-2`);
- await hidePage(page);
-
- // assumption: If we would send another LCP span on the 2nd navigation, it would be sent before the navigation
- // transaction ends. This isn't 100% safe to ensure we don't send something but otherwise we'd need to wait for
- // a timeout or something similar.
- await navigationTxnPromise;
-});
-
-sentryTest("doesn't send further LCP after the first page hide", async ({ getLocalTestUrl, page }) => {
- page.route('**', route => route.continue());
- page.route('**/my/image.png', async (route: Route) => {
- return route.fulfill({
- path: `${__dirname}/assets/sentry-logo-600x179.png`,
- });
- });
-
- const url = await getLocalTestUrl({ testDir: __dirname });
-
- const pageloadEventData = await getFirstSentryEnvelopeRequest(page, url);
-
- expect(pageloadEventData.type).toBe('transaction');
- expect(pageloadEventData.contexts?.trace?.op).toBe('pageload');
-
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- // Wait for LCP to be captured
- await page.waitForTimeout(1000);
-
- await hidePage(page);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
- expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
- expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('pagehide');
- expect(spanEnvelopeItem.trace_id).toBe(pageloadEventData.contexts?.trace?.trace_id);
-
- getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'span' }, () => {
- throw new Error('Unexpected span - This should not happen!');
- });
-
- const navigationTxnPromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'transaction' },
- properFullEnvelopeRequestParser,
- );
-
- // activate both LCP emission triggers:
- await page.goto(`${url}#soft-navigation-2`);
- await hidePage(page);
-
- // assumption: If we would send another LCP span on the 2nd navigation, it would be sent before the navigation
- // transaction ends. This isn't 100% safe to ensure we don't send something but otherwise we'd need to wait for
- // a timeout or something similar.
- await navigationTxnPromise;
-});
-
-sentryTest('LCP span timestamps are set correctly', async ({ getLocalTestUrl, page }) => {
- page.route('**', route => route.continue());
- page.route('**/my/image.png', async (route: Route) => {
- return route.fulfill({
- path: `${__dirname}/assets/sentry-logo-600x179.png`,
- });
- });
-
- const url = await getLocalTestUrl({ testDir: __dirname });
-
- const eventData = await getFirstSentryEnvelopeRequest(page, url);
-
- expect(eventData.type).toBe('transaction');
- expect(eventData.contexts?.trace?.op).toBe('pageload');
- expect(eventData.timestamp).toBeDefined();
-
- const pageloadEndTimestamp = eventData.timestamp!;
-
- const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
- page,
- 1,
- { envelopeType: 'span' },
- properFullEnvelopeRequestParser,
- );
-
- // Wait for LCP to be captured
- await page.waitForTimeout(1000);
-
- await hidePage(page);
-
- const spanEnvelope = (await spanEnvelopePromise)[0];
- const spanEnvelopeItem = spanEnvelope[1][0][1];
-
- expect(spanEnvelopeItem.start_timestamp).toBeDefined();
- expect(spanEnvelopeItem.timestamp).toBeDefined();
-
- const lcpSpanStartTimestamp = spanEnvelopeItem.start_timestamp!;
- const lcpSpanEndTimestamp = spanEnvelopeItem.timestamp!;
-
- // LCP is a point-in-time metric ==> start and end timestamp should be the same
- expect(lcpSpanStartTimestamp).toEqual(lcpSpanEndTimestamp);
-
- // We don't really care that they are very close together but rather about the order of magnitude
- // Previously, we had a bug where the timestamps would be significantly off (by multiple hours)
- // so we only ensure that this bug is fixed. 60 seconds should be more than enough.
- expect(lcpSpanStartTimestamp - pageloadEndTimestamp).toBeLessThan(60);
-});
-
-sentryTest(
- 'pageload transaction does not contain LCP measurement when standalone spans are enabled',
- async ({ getLocalTestUrl, page }) => {
- page.route('**', route => route.continue());
- page.route('**/my/image.png', async (route: Route) => {
- return route.fulfill({
- path: `${__dirname}/assets/sentry-logo-600x179.png`,
- });
- });
-
- const url = await getLocalTestUrl({ testDir: __dirname });
- const eventData = await getFirstSentryEnvelopeRequest(page, url);
-
- expect(eventData.type).toBe('transaction');
- expect(eventData.contexts?.trace?.op).toBe('pageload');
-
- // LCP measurement should NOT be present on the pageload transaction when standalone spans are enabled
- expect(eventData.measurements?.lcp).toBeUndefined();
-
- // LCP attributes should also NOT be present on the pageload transaction when standalone spans are enabled
- // because the LCP data is sent as a standalone span instead
- expect(eventData.contexts?.trace?.data?.['lcp.element']).toBeUndefined();
- expect(eventData.contexts?.trace?.data?.['lcp.size']).toBeUndefined();
- },
-);
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..8f4d98affb13
--- /dev/null
+++ b/packages/browser/src/integrations/webVitals.ts
@@ -0,0 +1,73 @@
+import type { Client, IntegrationFn } from '@sentry/core/browser';
+import { defineIntegration, hasSpanStreamingEnabled } from '@sentry/core/browser';
+import {
+ 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.
+ */
+ disable?: WebVitalName[];
+}
+
+const collectWebVitalsCallbacks = new WeakMap void>();
+
+export function collectWebVitalsForClient(client: Client): void {
+ collectWebVitalsCallbacks.get(client)?.();
+}
+
+/**
+ * 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 disabled = new Set(options.disable ?? []);
+
+ return {
+ name: WEB_VITALS_INTEGRATION_NAME,
+ setup(client) {
+ const spanStreamingEnabled = hasSpanStreamingEnabled(client);
+
+ collectWebVitalsCallbacks.set(
+ client,
+ startTrackingWebVitals({
+ recordClsStandaloneSpans: spanStreamingEnabled || disabled.has('cls') ? undefined : false,
+ recordLcpStandaloneSpans: spanStreamingEnabled || disabled.has('lcp') ? undefined : false,
+ client,
+ }),
+ );
+
+ if (spanStreamingEnabled) {
+ if (!disabled.has('lcp')) {
+ trackLcpAsSpan(client);
+ }
+ if (!disabled.has('cls')) {
+ trackClsAsSpan(client);
+ }
+ if (!disabled.has('inp')) {
+ trackInpAsSpan();
+ }
+ } else if (!disabled.has('inp')) {
+ startTrackingINP();
+ }
+ },
+ afterAllSetup() {
+ if (!disabled.has('inp')) {
+ registerInpInteractionListener();
+ }
+ },
+ };
+}) satisfies IntegrationFn;
diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts
index 8984e9e98726..debf2bac6626 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';
@@ -304,8 +303,6 @@ export interface BrowserTracingOptions {
*/
_experiments: Partial<{
enableInteractions: boolean;
- enableStandaloneClsSpans: boolean;
- enableStandaloneLcpSpans: boolean;
}>;
/**
@@ -387,7 +384,7 @@ export const browserTracingIntegration = ((options: Partial void);
let lastInteractionTimestamp: number | undefined;
let _pageloadSpan: Span | undefined;
@@ -458,13 +454,11 @@ 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?.();
+ collectWebVitalsForClient(client);
const spanStreamingEnabled = hasSpanStreamingEnabled(client);
addPerformanceEntries(span, {
- recordClsOnPageloadSpan: !spanStreamingEnabled && !enableStandaloneClsSpans,
- recordLcpOnPageloadSpan: !spanStreamingEnabled && !enableStandaloneLcpSpans,
+ recordClsOnPageloadSpan: !spanStreamingEnabled,
+ recordLcpOnPageloadSpan: !spanStreamingEnabled,
ignoreResourceSpans,
ignorePerformanceApiSpans,
spanStreamingEnabled,
@@ -524,24 +518,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..a8d95305ddaf
--- /dev/null
+++ b/packages/browser/test/integrations/webVitals.test.ts
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { collectWebVitalsForClient, webVitalsIntegration } from '../../src/integrations/webVitals';
+
+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', () => ({
+ 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('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 disabling selected web vitals for browserTracingIntegration compatibility', () => {
+ const client = { getOptions: () => ({}) };
+ const integration = webVitalsIntegration({ disable: ['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('exposes the web vital collection callback for browserTracingIntegration finalization', () => {
+ const collectWebVitals = vi.fn();
+ const client = { getOptions: () => ({}) };
+ mockStartTrackingWebVitals.mockReturnValue(collectWebVitals);
+
+ webVitalsIntegration().setup?.(client as never);
+ collectWebVitalsForClient(client as never);
+
+ expect(collectWebVitals).toHaveBeenCalledTimes(1);
+ });
+});
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({