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