diff --git a/.size-limit.js b/.size-limit.js
index 351c85ccca79..86f3ef5ed87d 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -38,21 +38,28 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
- limit: '43 KB',
+ limit: '44 KB',
+ },
+ {
+ name: '@sentry/browser (incl. Tracing + Span Streaming)',
+ path: 'packages/browser/build/npm/esm/prod/index.js',
+ import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'),
+ gzip: true,
+ limit: '48 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'),
gzip: true,
- limit: '48 KB',
+ limit: '49 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay)',
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
- limit: '82 KB',
+ limit: '83 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
@@ -82,14 +89,14 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
- limit: '87 KB',
+ limit: '88 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'),
gzip: true,
- limit: '99 KB',
+ limit: '100 KB',
},
{
name: '@sentry/browser (incl. Feedback)',
@@ -163,7 +170,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
- limit: '45 KB',
+ limit: '46 KB',
},
// Svelte SDK (ESM)
{
@@ -184,7 +191,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
- limit: '44 KB',
+ limit: '45 KB',
},
{
name: 'CDN Bundle (incl. Logs, Metrics)',
@@ -196,7 +203,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing, Logs, Metrics)',
path: createCDNPath('bundle.tracing.logs.metrics.min.js'),
gzip: true,
- limit: '45 KB',
+ limit: '46 KB',
},
{
name: 'CDN Bundle (incl. Replay, Logs, Metrics)',
@@ -208,25 +215,25 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing, Replay)',
path: createCDNPath('bundle.tracing.replay.min.js'),
gzip: true,
- limit: '81 KB',
+ limit: '82 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)',
path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'),
gzip: true,
- limit: '82 KB',
+ limit: '83 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Feedback)',
path: createCDNPath('bundle.tracing.replay.feedback.min.js'),
gzip: true,
- limit: '87 KB',
+ limit: '88 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)',
path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'),
gzip: true,
- limit: '88 KB',
+ limit: '89 KB',
},
// browser CDN bundles (non-gzipped)
{
@@ -241,7 +248,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.min.js'),
gzip: false,
brotli: false,
- limit: '130 KB',
+ limit: '134 KB',
},
{
name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed',
@@ -255,7 +262,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.logs.metrics.min.js'),
gzip: false,
brotli: false,
- limit: '134 KB',
+ limit: '138 KB',
},
{
name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed',
@@ -269,14 +276,14 @@ module.exports = [
path: createCDNPath('bundle.tracing.replay.min.js'),
gzip: false,
brotli: false,
- limit: '248 KB',
+ limit: '251 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed',
path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'),
gzip: false,
brotli: false,
- limit: '251 KB',
+ limit: '255 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed',
@@ -290,7 +297,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'),
gzip: false,
brotli: false,
- limit: '264 KB',
+ limit: '268 KB',
},
// Next.js SDK (ESM)
{
@@ -299,7 +306,7 @@ module.exports = [
import: createImport('init'),
ignore: ['next/router', 'next/constants'],
gzip: true,
- limit: '48 KB',
+ limit: '49 KB',
},
// SvelteKit SDK (ESM)
{
@@ -308,7 +315,7 @@ module.exports = [
import: createImport('init'),
ignore: ['$app/stores'],
gzip: true,
- limit: '44 KB',
+ limit: '45 KB',
},
// Node-Core SDK (ESM)
{
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts
index 7128d2d5ecce..403fdd4fdc0a 100644
--- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts
@@ -69,13 +69,17 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ getL
expect(navigationSpan).toEqual({
attributes: {
- effectiveConnectionType: {
+ 'network.connection.effective_type': {
type: 'string',
value: expect.any(String),
},
- hardwareConcurrency: {
- type: 'string',
- value: expect.any(String),
+ 'device.processor_count': {
+ type: expect.stringMatching(/^(integer)|(double)$/),
+ value: expect.any(Number),
+ },
+ 'network.connection.rtt': {
+ type: expect.stringMatching(/^(integer)|(double)$/),
+ value: expect.any(Number),
},
'sentry.idle_span_finish_reason': {
type: 'string',
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts
index 47d9e00d4307..86882134cab4 100644
--- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts
@@ -62,20 +62,34 @@ sentryTest(
expect(pageloadSpan).toEqual({
attributes: {
- effectiveConnectionType: {
+ // formerly known as 'effectiveConnectionType'
+ 'network.connection.effective_type': {
type: 'string',
value: expect.any(String),
},
- hardwareConcurrency: {
- type: 'string',
- value: expect.any(String),
+ // formerly known as 'hardwareConcurrency'
+ 'device.processor_count': {
+ type: expect.stringMatching(/^(integer)|(double)$/),
+ value: expect.any(Number),
},
- 'performance.activationStart': {
- type: 'integer',
+ 'browser.performance.navigation.activation_start': {
+ type: expect.stringMatching(/^(integer)|(double)$/),
+ value: expect.any(Number),
+ },
+ 'browser.performance.time_origin': {
+ type: expect.stringMatching(/^(integer)|(double)$/),
+ value: expect.any(Number),
+ },
+ 'network.connection.rtt': {
+ type: expect.stringMatching(/^(integer)|(double)$/),
+ value: expect.any(Number),
+ },
+ 'browser.web_vital.ttfb.request_time': {
+ type: expect.stringMatching(/^(integer)|(double)$/),
value: expect.any(Number),
},
- 'performance.timeOrigin': {
- type: 'double',
+ 'browser.web_vital.ttfb.value': {
+ type: expect.stringMatching(/^(integer)|(double)$/),
value: expect.any(Number),
},
'sentry.idle_span_finish_reason': {
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js
new file mode 100644
index 000000000000..bd3b6ed17872
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js
@@ -0,0 +1,11 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js
new file mode 100644
index 000000000000..9742a4a5cc29
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js
@@ -0,0 +1,17 @@
+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 via 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-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html
new file mode 100644
index 000000000000..10e2e22f7d6a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+ Some content
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts
new file mode 100644
index 000000000000..31ddd09977cb
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts
@@ -0,0 +1,76 @@
+import type { Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
+
+sentryTest.beforeEach(async ({ browserName, page }) => {
+ if (shouldSkipTracingTest() || testingCdnBundle() || 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());
+ });
+ });
+}
+
+sentryTest('captures CLS as a streamed span with source attributes', async ({ getLocalTestUrl, page }) => {
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls');
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(`${url}#0.15`);
+ await waitForLayoutShift(page);
+ await hidePage(page);
+
+ const clsSpan = await clsSpanPromise;
+ const pageloadSpan = await pageloadSpanPromise;
+
+ expect(clsSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.cls' });
+ expect(clsSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.cls' });
+ expect(clsSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
+ expect(clsSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));
+
+ // Check browser.web_vital.cls.source attributes
+ expect(clsSpan.attributes?.['browser.web_vital.cls.source.1']?.value).toEqual(
+ expect.stringContaining('body > div#content > p'),
+ );
+
+ // Check pageload span id is present
+ expect(clsSpan.attributes?.['sentry.pageload.span_id']?.value).toBe(pageloadSpan.span_id);
+
+ // CLS is a point-in-time metric
+ expect(clsSpan.start_timestamp).toEqual(clsSpan.end_timestamp);
+
+ expect(clsSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(clsSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+
+ expect(clsSpan.parent_span_id).toBe(pageloadSpan.span_id);
+ expect(clsSpan.trace_id).toBe(pageloadSpan.trace_id);
+});
+
+sentryTest('CLS streamed span has web vital value attribute', async ({ getLocalTestUrl, page }) => {
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls');
+
+ await page.goto(`${url}#0.1`);
+ await waitForLayoutShift(page);
+ await hidePage(page);
+
+ const clsSpan = await clsSpanPromise;
+
+ // The CLS value should be set as a browser.web_vital.cls.value attribute
+ expect(clsSpan.attributes?.['browser.web_vital.cls.value']?.type).toBe('double');
+ // Flakey value dependent on timings -> we check for a range
+ const clsValue = clsSpan.attributes?.['browser.web_vital.cls.value']?.value as number;
+ expect(clsValue).toBeGreaterThan(0.05);
+ expect(clsValue).toBeLessThan(0.15);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js
new file mode 100644
index 000000000000..469f44076e73
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration({ idleTimeout: 4000 }), Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/subject.js
new file mode 100644
index 000000000000..f4ea1cd46d67
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/subject.js
@@ -0,0 +1,19 @@
+const blockUI =
+ (delay = 70) =>
+ e => {
+ const startTime = Date.now();
+
+ function getElapsed() {
+ const time = Date.now();
+ return time - startTime;
+ }
+
+ while (getElapsed() < delay) {
+ //
+ }
+
+ e.target.classList.add('clicked');
+ };
+
+document.querySelector('[data-test-id=slow-button]').addEventListener('click', blockUI(450));
+document.querySelector('[data-test-id=normal-button]').addEventListener('click', blockUI());
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/template.html
new file mode 100644
index 000000000000..d5f28c7c8847
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts
new file mode 100644
index 000000000000..30dd4f92dbfc
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts
@@ -0,0 +1,79 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
+
+sentryTest.beforeEach(async ({ browserName }) => {
+ if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') {
+ sentryTest.skip();
+ }
+});
+
+sentryTest('captures INP click as a streamed span', async ({ getLocalTestUrl, page }) => {
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ const inpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.interaction.click');
+
+ await page.goto(url);
+
+ await page.locator('[data-test-id=normal-button]').click();
+ await page.locator('.clicked[data-test-id=normal-button]').isVisible();
+
+ await page.waitForTimeout(500);
+
+ await hidePage(page);
+
+ const inpSpan = await inpSpanPromise;
+ const pageloadSpan = await pageloadSpanPromise;
+
+ expect(inpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.interaction.click' });
+ expect(inpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.inp' });
+ expect(inpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));
+
+ const inpValue = inpSpan.attributes?.['browser.web_vital.inp.value']?.value as number;
+ expect(inpValue).toBeGreaterThan(0);
+
+ expect(inpSpan.attributes?.['sentry.exclusive_time']?.value).toBeGreaterThan(0);
+
+ expect(inpSpan.name).toBe('body > NormalButton');
+
+ expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp);
+
+ expect(inpSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(inpSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+
+ expect(inpSpan.parent_span_id).toBe(pageloadSpan.span_id);
+ expect(inpSpan.trace_id).toBe(pageloadSpan.trace_id);
+});
+
+sentryTest('captures the slowest interaction as streamed INP span', async ({ getLocalTestUrl, page }) => {
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.goto(url);
+
+ await page.locator('[data-test-id=normal-button]').click();
+ await page.locator('.clicked[data-test-id=normal-button]').isVisible();
+
+ await page.waitForTimeout(500);
+
+ const inpSpanPromise = waitForStreamedSpan(page, span => {
+ const op = getSpanOp(span);
+ return op === 'ui.interaction.click';
+ });
+
+ await page.locator('[data-test-id=slow-button]').click();
+ await page.locator('.clicked[data-test-id=slow-button]').isVisible();
+
+ await page.waitForTimeout(500);
+
+ await hidePage(page);
+
+ const inpSpan = await inpSpanPromise;
+
+ expect(inpSpan.name).toBe('body > SlowButton');
+ expect(inpSpan.attributes?.['sentry.exclusive_time']?.value).toBeGreaterThan(400);
+
+ const inpValue = inpSpan.attributes?.['browser.web_vital.inp.value']?.value as number;
+ expect(inpValue).toBeGreaterThan(400);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png
new file mode 100644
index 000000000000..353b7233d6bf
Binary files /dev/null and b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png differ
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js
new file mode 100644
index 000000000000..bd3b6ed17872
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js
@@ -0,0 +1,11 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html
new file mode 100644
index 000000000000..b613a556aca4
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts
new file mode 100644
index 000000000000..1f71cb8d76a7
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts
@@ -0,0 +1,65 @@
+import type { Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
+
+sentryTest.beforeEach(async ({ browserName, page }) => {
+ if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') {
+ sentryTest.skip();
+ }
+
+ await page.setViewportSize({ width: 800, height: 1200 });
+});
+
+sentryTest('captures LCP as a streamed span with element attributes', async ({ getLocalTestUrl, page }) => {
+ page.route('**', route => route.continue());
+ page.route('**/my/image.png', async (route: Route) => {
+ return route.fulfill({
+ path: `${__dirname}/assets/sentry-logo-600x179.png`,
+ });
+ });
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const lcpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.lcp');
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(url);
+
+ // Wait for LCP to be captured
+ await page.waitForTimeout(1000);
+
+ await hidePage(page);
+
+ const lcpSpan = await lcpSpanPromise;
+ const pageloadSpan = await pageloadSpanPromise;
+
+ expect(lcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.lcp' });
+ expect(lcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.lcp' });
+ expect(lcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
+ expect(lcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));
+
+ // Check browser.web_vital.lcp.* attributes
+ expect(lcpSpan.attributes?.['browser.web_vital.lcp.element']?.value).toEqual(expect.stringContaining('body > img'));
+ expect(lcpSpan.attributes?.['browser.web_vital.lcp.url']?.value).toBe(
+ 'https://sentry-test-site.example/my/image.png',
+ );
+ expect(lcpSpan.attributes?.['browser.web_vital.lcp.size']?.value).toEqual(expect.any(Number));
+
+ // Check web vital value attribute
+ expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.type).toMatch(/^(double)|(integer)$/);
+ expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.value).toBeGreaterThan(0);
+
+ // Check pageload span id is present
+ expect(lcpSpan.attributes?.['sentry.pageload.span_id']?.value).toBe(pageloadSpan.span_id);
+
+ // Span should have meaningful duration (navigation start -> LCP event)
+ expect(lcpSpan.end_timestamp).toBeGreaterThan(lcpSpan.start_timestamp);
+
+ expect(lcpSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(lcpSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+
+ expect(lcpSpan.parent_span_id).toBe(pageloadSpan.span_id);
+ expect(lcpSpan.trace_id).toBe(pageloadSpan.trace_id);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/init.js
new file mode 100644
index 000000000000..d8da96d88a64
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ tracesSampleRate: 1,
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/test.ts
new file mode 100644
index 000000000000..73f37f07a291
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/test.ts
@@ -0,0 +1,36 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
+
+sentryTest.beforeEach(async ({ page }) => {
+ if (shouldSkipTracingTest() || testingCdnBundle()) {
+ sentryTest.skip();
+ }
+
+ await page.setViewportSize({ width: 800, height: 1200 });
+});
+
+sentryTest(
+ 'captures TTFB and TTFB request time as attributes on the streamed pageload span',
+ async ({ getLocalTestUrl, page }) => {
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(url);
+ await hidePage(page);
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ // If responseStart === 0, TTFB is not reported.
+ // This seems to happen somewhat randomly, so we handle it.
+ const responseStart = await page.evaluate("performance.getEntriesByType('navigation')[0].responseStart;");
+ if (responseStart !== 0) {
+ expect(pageloadSpan.attributes?.['browser.web_vital.ttfb.value']?.type).toMatch(/^(double)|(integer)$/);
+ expect(pageloadSpan.attributes?.['browser.web_vital.ttfb.value']?.value).toBeGreaterThan(0);
+ }
+
+ expect(pageloadSpan.attributes?.['browser.web_vital.ttfb.request_time']?.type).toMatch(/^(double)|(integer)$/);
+ },
+);
diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts
index 2b2d4b7f9397..888524ed7c21 100644
--- a/packages/browser-utils/src/index.ts
+++ b/packages/browser-utils/src/index.ts
@@ -20,6 +20,8 @@ export { elementTimingIntegration, startTrackingElementTiming } from './metrics/
export { extractNetworkProtocol } from './metrics/utils';
+export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './metrics/webVitalSpans';
+
export { addClickKeypressInstrumentationHandler } from './instrument/dom';
export { addHistoryInstrumentationHandler } from './instrument/history';
diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts
index 28d1f2bfaec8..9a00ab322e16 100644
--- a/packages/browser-utils/src/metrics/browserMetrics.ts
+++ b/packages/browser-utils/src/metrics/browserMetrics.ts
@@ -2,6 +2,7 @@
import type { Client, Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core';
import {
browserPerformanceTimeOrigin,
+ debug,
getActiveSpan,
getComponentName,
htmlTreeAsString,
@@ -27,7 +28,7 @@ import { getBrowserPerformanceAPI, isMeasurementValue, msToSec, startAndEndSpan
import { getActivationStart } from './web-vitals/lib/getActivationStart';
import { getNavigationEntry } from './web-vitals/lib/getNavigationEntry';
import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher';
-
+import { DEBUG_BUILD } from '../debug-build';
interface NavigatorNetworkInformation {
readonly connection?: NetworkInformation;
}
@@ -75,8 +76,18 @@ let _lcpEntry: LargestContentfulPaint | undefined;
let _clsEntry: LayoutShift | undefined;
interface StartTrackingWebVitalsOptions {
- recordClsStandaloneSpans: boolean;
- recordLcpStandaloneSpans: boolean;
+ /**
+ * When `true`, CLS is tracked as a standalone span. When `false`, CLS is
+ * recorded as a measurement on the pageload span. When `undefined`, CLS
+ * tracking is skipped entirely (e.g. because span streaming handles it).
+ */
+ recordClsStandaloneSpans: boolean | undefined;
+ /**
+ * When `true`, LCP is tracked as a standalone span. When `false`, LCP is
+ * recorded as a measurement on the pageload span. When `undefined`, LCP
+ * tracking is skipped entirely (e.g. because span streaming handles it).
+ */
+ recordLcpStandaloneSpans: boolean | undefined;
client: Client;
}
@@ -84,6 +95,7 @@ interface StartTrackingWebVitalsOptions {
* Start tracking web vitals.
* The callback returned by this function can be used to stop tracking & ensure all measurements are final & captured.
*
+ * @deprecated this function will be removed and streamlined once we stop supporting standalone v1
* @returns A function that forces web vitals collection
*/
export function startTrackingWebVitals({
@@ -97,13 +109,24 @@ export function startTrackingWebVitals({
if (performance.mark) {
WINDOW.performance.mark('sentry-tracing-init');
}
- const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : _trackLCP();
+
+ const lcpCleanupCallback = recordLcpStandaloneSpans
+ ? trackLcpAsStandaloneSpan(client)
+ : recordLcpStandaloneSpans === false
+ ? _trackLCP()
+ : undefined;
+
+ const clsCleanupCallback = recordClsStandaloneSpans
+ ? trackClsAsStandaloneSpan(client)
+ : recordClsStandaloneSpans === false
+ ? _trackCLS()
+ : undefined;
+
const ttfbCleanupCallback = _trackTtfb();
- const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : _trackCLS();
return (): void => {
- lcpCleanupCallback?.();
ttfbCleanupCallback();
+ lcpCleanupCallback?.();
clsCleanupCallback?.();
};
}
@@ -314,6 +337,11 @@ interface AddPerformanceEntriesOptions {
* Default: []
*/
ignorePerformanceApiSpans: Array;
+
+ /**
+ * Whether span streaming is enabled.
+ */
+ spanStreamingEnabled?: boolean;
}
/** Add performance related spans to a transaction */
@@ -325,6 +353,14 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
return;
}
+ const {
+ spanStreamingEnabled,
+ ignorePerformanceApiSpans,
+ ignoreResourceSpans,
+ recordClsOnPageloadSpan,
+ recordLcpOnPageloadSpan,
+ } = options;
+
const timeOrigin = msToSec(origin);
const performanceEntries = performance.getEntries();
@@ -353,7 +389,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
case 'mark':
case 'paint':
case 'measure': {
- _addMeasureSpans(span, entry, startTime, duration, timeOrigin, options.ignorePerformanceApiSpans);
+ _addMeasureSpans(span, entry, startTime, duration, timeOrigin, ignorePerformanceApiSpans);
// capture web vitals
const firstHidden = getVisibilityWatcher();
@@ -376,7 +412,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
startTime,
duration,
timeOrigin,
- options.ignoreResourceSpans,
+ ignoreResourceSpans,
);
break;
}
@@ -386,28 +422,50 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
_performanceCursor = Math.max(performanceEntries.length - 1, 0);
- _trackNavigator(span);
+ _trackNavigator(span, spanStreamingEnabled);
// Measurements are only available for pageload transactions
if (op === 'pageload') {
_addTtfbRequestTimeToMeasurements(_measurements);
- // If CLS standalone spans are enabled, don't record CLS as a measurement
- if (!options.recordClsOnPageloadSpan) {
- delete _measurements.cls;
- }
+ if (spanStreamingEnabled) {
+ const setAttr = (shortWebVitalName: string, value: number, customAttrName?: string) => {
+ const attrKey = customAttrName ?? `browser.web_vital.${shortWebVitalName}.value`;
+ span.setAttribute(attrKey, value);
+ DEBUG_BUILD && debug.log('Setting web vital attribute', { [attrKey]: value }, 'on pageload span');
+ };
+ // for streamed pageload spans, we add the web vital measurements as attributes.
+ // We omit LCP, CLS and INP because they're tracked separately as spans
+ ['ttfb', 'fp', 'fcp'].forEach(measurementName => {
+ if (_measurements[measurementName]) {
+ setAttr(measurementName, _measurements[measurementName].value);
+ }
+ });
+ if (_measurements['ttfb.requestTime']) {
+ setAttr('ttfb.requestTime', _measurements['ttfb.requestTime'].value, 'browser.web_vital.ttfb.request_time');
+ }
+ } else {
+ // TODO (V11): Remove this else branch once we remove v1 standalone spans and transactions
- // If LCP standalone spans are enabled, don't record LCP as a measurement
- if (!options.recordLcpOnPageloadSpan) {
- delete _measurements.lcp;
- }
+ // If CLS standalone spans are enabled, don't record CLS as a measurement
+ if (!recordClsOnPageloadSpan) {
+ delete _measurements.cls;
+ }
- Object.entries(_measurements).forEach(([measurementName, measurement]) => {
- setMeasurement(measurementName, measurement.value, measurement.unit);
- });
+ // If LCP standalone spans are enabled, don't record LCP as a measurement
+ if (!recordLcpOnPageloadSpan) {
+ delete _measurements.lcp;
+ }
+
+ Object.entries(_measurements).forEach(([measurementName, measurement]) => {
+ setMeasurement(measurementName, measurement.value, measurement.unit);
+ });
+
+ _setWebVitalAttributes(span, options);
+ }
// Set timeOrigin which denotes the timestamp which to base the LCP/FCP/FP/TTFB measurements on
- span.setAttribute('performance.timeOrigin', timeOrigin);
+ span.setAttribute(spanStreamingEnabled ? 'browser.performance.time_origin' : 'performance.timeOrigin', timeOrigin);
// In prerendering scenarios, where a page might be prefetched and pre-rendered before the user clicks the link,
// the navigation starts earlier than when the user clicks it. Web Vitals should always be based on the
@@ -415,9 +473,10 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
// time where the user actively started the navigation, for example by clicking a link.
// This is user action is called "activation" and the time between navigation and activation is stored in
// the `activationStart` attribute of the "navigation" PerformanceEntry.
- span.setAttribute('performance.activationStart', getActivationStart());
-
- _setWebVitalAttributes(span, options);
+ span.setAttribute(
+ spanStreamingEnabled ? 'browser.performance.navigation.activation_start' : 'performance.activationStart',
+ getActivationStart(),
+ );
}
_lcpEntry = undefined;
@@ -712,8 +771,9 @@ export function _addResourceSpans(
/**
* Capture the information of the user agent.
+ * TODO v11: Remove non-span-streaming attributes and measurements once we removed transactions
*/
-function _trackNavigator(span: Span): void {
+function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined): void {
const navigator = WINDOW.navigator as null | (Navigator & NavigatorNetworkInformation & NavigatorDeviceMemory);
if (!navigator) {
return;
@@ -723,24 +783,38 @@ function _trackNavigator(span: Span): void {
const connection = navigator.connection;
if (connection) {
if (connection.effectiveType) {
- span.setAttribute('effectiveConnectionType', connection.effectiveType);
+ span.setAttribute(
+ spanStreamingEnabled ? 'network.connection.effective_type' : 'effectiveConnectionType',
+ connection.effectiveType,
+ );
}
if (connection.type) {
- span.setAttribute('connectionType', connection.type);
+ span.setAttribute(spanStreamingEnabled ? 'network.connection.type' : 'connectionType', connection.type);
}
if (isMeasurementValue(connection.rtt)) {
_measurements['connection.rtt'] = { value: connection.rtt, unit: 'millisecond' };
+ if (spanStreamingEnabled) {
+ span.setAttribute('network.connection.rtt', connection.rtt);
+ }
}
}
if (isMeasurementValue(navigator.deviceMemory)) {
- span.setAttribute('deviceMemory', `${navigator.deviceMemory} GB`);
+ if (spanStreamingEnabled) {
+ span.setAttribute('device.memory.estimated_capacity', navigator.deviceMemory);
+ } else {
+ span.setAttribute('deviceMemory', `${navigator.deviceMemory} GB`);
+ }
}
if (isMeasurementValue(navigator.hardwareConcurrency)) {
- span.setAttribute('hardwareConcurrency', String(navigator.hardwareConcurrency));
+ if (spanStreamingEnabled) {
+ span.setAttribute('device.processor_count', navigator.hardwareConcurrency);
+ } else {
+ span.setAttribute('hardwareConcurrency', String(navigator.hardwareConcurrency));
+ }
}
}
diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts
index 831565f07408..3eb0b2920a75 100644
--- a/packages/browser-utils/src/metrics/inp.ts
+++ b/packages/browser-utils/src/metrics/inp.ts
@@ -37,7 +37,7 @@ const ELEMENT_NAME_TIMESTAMP_MAP = new Map();
* 60 seconds is the maximum for a plausible INP value
* (source: Me)
*/
-const MAX_PLAUSIBLE_INP_DURATION = 60;
+export const MAX_PLAUSIBLE_INP_DURATION = 60;
/**
* Start tracking INP webvital events.
*/
@@ -54,7 +54,7 @@ export function startTrackingINP(): () => void {
return () => undefined;
}
-const INP_ENTRY_MAP: Record = {
+export const INP_ENTRY_MAP: Record = {
click: 'click',
pointerdown: 'click',
pointerup: 'click',
@@ -155,6 +155,14 @@ export const _onInp: InstrumentationHandlerCallback = ({ metric }) => {
}
};
+/**
+ * Look up a cached interaction context (element name + root span) by interactionId.
+ * Returns undefined if no context was cached for this interaction.
+ */
+export function getCachedInteractionContext(interactionId: number | undefined): InteractionContext | undefined {
+ return interactionId != null ? INTERACTIONS_SPAN_MAP.get(interactionId) : undefined;
+}
+
/**
* Register a listener to cache route information for INP interactions.
*/
diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts
index 4c461ec6776c..608a5fd11511 100644
--- a/packages/browser-utils/src/metrics/instrument.ts
+++ b/packages/browser-utils/src/metrics/instrument.ts
@@ -27,7 +27,7 @@ interface PerformanceEntry {
readonly startTime: number;
toJSON(): Record;
}
-interface PerformanceEventTiming extends PerformanceEntry {
+export interface PerformanceEventTiming extends PerformanceEntry {
processingStart: number;
processingEnd: number;
duration: number;
diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts
index 084d17becb8d..a3f3ea0e2cf8 100644
--- a/packages/browser-utils/src/metrics/utils.ts
+++ b/packages/browser-utils/src/metrics/utils.ts
@@ -203,17 +203,18 @@ export function supportsWebVital(entryType: 'layout-shift' | 'largest-contentful
* @param collectorCallback the callback to be called when the first of these events is triggered. Parameters:
* - event: the event that triggered the reporting of the web vital value.
* - pageloadSpanId: the span id of the pageload span. This is used to link the web vital span to the pageload span.
+ * - pageloadSpan: the pageload span instance. This is used for full access to the pageload span for span streaming.
*/
export function listenForWebVitalReportEvents(
client: Client,
- collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string) => void,
+ collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string, pageloadSpan?: Span) => void,
) {
- let pageloadSpanId: string | undefined;
+ let pageloadSpan: Span | undefined;
let collected = false;
function _runCollectorCallbackOnce(event: WebVitalReportEvent) {
- if (!collected && pageloadSpanId) {
- collectorCallback(event, pageloadSpanId);
+ if (!collected && pageloadSpan) {
+ collectorCallback(event, pageloadSpan.spanContext().spanId, pageloadSpan);
}
collected = true;
}
@@ -233,7 +234,7 @@ export function listenForWebVitalReportEvents(
});
const unsubscribeAfterStartPageLoadSpan = client.on('afterStartPageLoadSpan', span => {
- pageloadSpanId = span.spanContext().spanId;
+ pageloadSpan = span;
unsubscribeAfterStartPageLoadSpan();
});
}
diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts
new file mode 100644
index 000000000000..b342b653df97
--- /dev/null
+++ b/packages/browser-utils/src/metrics/webVitalSpans.ts
@@ -0,0 +1,308 @@
+import type { Client, Span, SpanAttributes } from '@sentry/core';
+import {
+ browserPerformanceTimeOrigin,
+ debug,
+ getActiveSpan,
+ getCurrentScope,
+ getRootSpan,
+ htmlTreeAsString,
+ SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ spanToStreamedSpanJSON,
+ startInactiveSpan,
+ timestampInSeconds,
+} from '@sentry/core';
+import { DEBUG_BUILD } from '../debug-build';
+import { WINDOW } from '../types';
+import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp';
+import type { InstrumentationHandlerCallback } from './instrument';
+import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler } from './instrument';
+import type { WebVitalReportEvent } from './utils';
+import { getBrowserPerformanceAPI, listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils';
+import type { PerformanceEventTiming } from './instrument';
+
+// Locally-defined interfaces to avoid leaking bare global type references into the
+// generated .d.ts. The `declare global` augmentations in web-vitals/types.ts make these
+// available during this package's compilation but are NOT carried to consumers.
+// This mirrors the pattern used for PerformanceEventTiming in instrument.ts.
+export interface LayoutShift extends PerformanceEntry {
+ value: number;
+ sources: Array<{ node: Node | null }>;
+ hadRecentInput: boolean;
+}
+
+export interface LargestContentfulPaint extends PerformanceEntry {
+ readonly renderTime: DOMHighResTimeStamp;
+ readonly loadTime: DOMHighResTimeStamp;
+ readonly size: number;
+ readonly id: string;
+ readonly url: string;
+ readonly element: Element | null;
+}
+
+interface WebVitalSpanOptions {
+ name: string;
+ op: string;
+ origin: string;
+ metricName: 'lcp' | 'cls' | 'inp';
+ value: number;
+ attributes?: SpanAttributes;
+ parentSpan?: Span;
+ reportEvent?: WebVitalReportEvent;
+ startTime: number;
+ endTime?: number;
+}
+
+/**
+ * Emits a web vital span that flows through the span streaming pipeline.
+ */
+export function _emitWebVitalSpan(options: WebVitalSpanOptions): void {
+ const {
+ name,
+ op,
+ origin,
+ metricName,
+ value,
+ attributes: passedAttributes,
+ parentSpan,
+ reportEvent,
+ startTime,
+ endTime,
+ } = options;
+
+ const routeName = getCurrentScope().getScopeData().transactionName;
+
+ const attributes: SpanAttributes = {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
+ [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0,
+ [`browser.web_vital.${metricName}.value`]: value,
+ 'sentry.transaction': routeName,
+ // Web vital score calculation relies on the user agent
+ 'user_agent.original': WINDOW.navigator?.userAgent,
+ ...passedAttributes,
+ };
+
+ if (parentSpan && spanToStreamedSpanJSON(parentSpan).attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'pageload') {
+ // for LCP and CLS, we collect the pageload span id as an attribute
+ attributes['sentry.pageload.span_id'] = parentSpan.spanContext().spanId;
+ }
+
+ if (reportEvent) {
+ attributes[`browser.web_vital.${metricName}.report_event`] = reportEvent;
+ }
+
+ const span = startInactiveSpan({
+ name,
+ attributes,
+ startTime,
+ // if we have a pageload span, we let the web vital span start as its parent. This ensures that
+ // it is not started as a segment span, without having to manually set it to a "standalone" v2 span
+ // that has `segment: false` but no actual parent span.
+ parentSpan: parentSpan,
+ });
+
+ if (span) {
+ span.end(endTime ?? startTime);
+ }
+}
+
+/**
+ * Tracks LCP as a streamed span.
+ */
+export function trackLcpAsSpan(client: Client): void {
+ let lcpValue = 0;
+ let lcpEntry: LargestContentfulPaint | undefined;
+
+ if (!supportsWebVital('largest-contentful-paint')) {
+ return;
+ }
+
+ const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => {
+ const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined;
+ if (!entry) {
+ return;
+ }
+ lcpValue = metric.value;
+ lcpEntry = entry;
+ }, true);
+
+ listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => {
+ _sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, reportEvent);
+ cleanupLcpHandler();
+ });
+}
+
+/**
+ * Exported only for testing.
+ */
+export function _sendLcpSpan(
+ lcpValue: number,
+ entry: LargestContentfulPaint | undefined,
+ pageloadSpan?: Span,
+ reportEvent?: WebVitalReportEvent,
+): void {
+ DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`);
+
+ const performanceTimeOrigin = browserPerformanceTimeOrigin() || 0;
+ const timeOrigin = msToSec(performanceTimeOrigin);
+ const endTime = msToSec(performanceTimeOrigin + (entry?.startTime || 0));
+ const name = entry ? htmlTreeAsString(entry.element) : 'Largest contentful paint';
+
+ const attributes: SpanAttributes = {};
+
+ entry?.element && (attributes['browser.web_vital.lcp.element'] = htmlTreeAsString(entry.element));
+ entry?.id && (attributes['browser.web_vital.lcp.id'] = entry.id);
+ entry?.url && (attributes['browser.web_vital.lcp.url'] = entry.url);
+ entry?.loadTime != null && (attributes['browser.web_vital.lcp.load_time'] = entry.loadTime);
+ entry?.renderTime != null && (attributes['browser.web_vital.lcp.render_time'] = entry.renderTime);
+ entry?.size != null && (attributes['browser.web_vital.lcp.size'] = entry.size);
+
+ _emitWebVitalSpan({
+ name,
+ op: 'ui.webvital.lcp',
+ origin: 'auto.http.browser.lcp',
+ metricName: 'lcp',
+ value: lcpValue,
+ attributes,
+ parentSpan: pageloadSpan,
+ reportEvent,
+ startTime: timeOrigin,
+ endTime,
+ });
+}
+
+/**
+ * Tracks CLS as a streamed span.
+ */
+export function trackClsAsSpan(client: Client): void {
+ let clsValue = 0;
+ let clsEntry: LayoutShift | undefined;
+
+ if (!supportsWebVital('layout-shift')) {
+ return;
+ }
+
+ const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => {
+ const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
+ if (!entry) {
+ return;
+ }
+ clsValue = metric.value;
+ clsEntry = entry;
+ }, true);
+
+ listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => {
+ _sendClsSpan(clsValue, clsEntry, pageloadSpan, reportEvent);
+ cleanupClsHandler();
+ });
+}
+
+/**
+ * Exported only for testing.
+ */
+export function _sendClsSpan(
+ clsValue: number,
+ entry: LayoutShift | undefined,
+ pageloadSpan?: Span,
+ reportEvent?: WebVitalReportEvent,
+): void {
+ DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`);
+
+ const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds();
+ const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift';
+
+ const attributes: SpanAttributes = {};
+
+ if (entry?.sources) {
+ entry.sources.forEach((source, index) => {
+ attributes[`browser.web_vital.cls.source.${index + 1}`] = htmlTreeAsString(source.node);
+ });
+ }
+
+ _emitWebVitalSpan({
+ name,
+ op: 'ui.webvital.cls',
+ origin: 'auto.http.browser.cls',
+ metricName: 'cls',
+ value: clsValue,
+ attributes,
+ parentSpan: pageloadSpan,
+ reportEvent,
+ startTime,
+ });
+}
+
+/**
+ * Tracks INP as a streamed span.
+ *
+ * This mirrors the standalone INP tracking logic (`startTrackingINP`) but emits
+ * spans through the streaming pipeline instead of as standalone spans.
+ * Requires `registerInpInteractionListener()` to be called separately for
+ * cached element names and root spans per interaction.
+ */
+export function trackInpAsSpan(): void {
+ const performance = getBrowserPerformanceAPI();
+ if (!performance || !browserPerformanceTimeOrigin()) {
+ return;
+ }
+
+ const onInp: InstrumentationHandlerCallback = ({ metric }) => {
+ if (metric.value == null) {
+ return;
+ }
+
+ const duration = msToSec(metric.value);
+
+ if (duration > MAX_PLAUSIBLE_INP_DURATION) {
+ return;
+ }
+
+ const entry = metric.entries.find(e => e.duration === metric.value && INP_ENTRY_MAP[e.name]);
+
+ if (!entry) {
+ return;
+ }
+
+ _sendInpSpan(metric.value, entry);
+ };
+
+ addInpInstrumentationHandler(onInp);
+}
+
+/**
+ * Exported only for testing.
+ */
+export function _sendInpSpan(inpValue: number, entry: PerformanceEventTiming): void {
+ DEBUG_BUILD && debug.log(`Sending INP span (${inpValue})`);
+
+ const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime);
+ const duration = msToSec(inpValue);
+ const interactionType = INP_ENTRY_MAP[entry.name];
+
+ const cachedContext = getCachedInteractionContext(entry.interactionId);
+ const activeSpan = getActiveSpan();
+ const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
+
+ const spanToUse = cachedContext?.span || rootSpan;
+ const routeName = spanToUse
+ ? spanToStreamedSpanJSON(spanToUse).name
+ : getCurrentScope().getScopeData().transactionName;
+ const name = cachedContext?.elementName || htmlTreeAsString(entry.target);
+
+ _emitWebVitalSpan({
+ name,
+ op: `ui.interaction.${interactionType}`,
+ origin: 'auto.http.browser.inp',
+ metricName: 'inp',
+ value: inpValue,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration,
+ 'sentry.transaction': routeName,
+ },
+ startTime,
+ endTime: startTime + duration,
+ parentSpan: spanToUse,
+ });
+}
diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts
new file mode 100644
index 000000000000..733891370fda
--- /dev/null
+++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts
@@ -0,0 +1,468 @@
+import * as SentryCore from '@sentry/core';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as inpModule from '../../src/metrics/inp';
+import { _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan } from '../../src/metrics/webVitalSpans';
+
+vi.mock('@sentry/core', async () => {
+ const actual = await vi.importActual('@sentry/core');
+ return {
+ ...actual,
+ browserPerformanceTimeOrigin: vi.fn(),
+ timestampInSeconds: vi.fn(),
+ getCurrentScope: vi.fn(),
+ htmlTreeAsString: vi.fn(),
+ startInactiveSpan: vi.fn(),
+ getActiveSpan: vi.fn(),
+ getRootSpan: vi.fn(),
+ spanToJSON: vi.fn(),
+ spanToStreamedSpanJSON: vi.fn(),
+ };
+});
+
+// Mock WINDOW
+vi.mock('../../src/types', () => ({
+ WINDOW: {
+ navigator: { userAgent: 'test-user-agent' },
+ performance: {
+ getEntriesByType: vi.fn().mockReturnValue([]),
+ },
+ },
+}));
+
+function createMockPageloadSpan(spanId: string) {
+ return {
+ spanContext: () => ({ spanId, traceId: 'trace-1', traceFlags: 1 }),
+ end: vi.fn(),
+ };
+}
+
+describe('_emitWebVitalSpan', () => {
+ const mockSpan = {
+ end: vi.fn(),
+ };
+
+ const mockScope = {
+ getScopeData: vi.fn().mockReturnValue({
+ transactionName: 'test-transaction',
+ }),
+ };
+
+ beforeEach(() => {
+ vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
+ vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ attributes: {} } as any);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('creates a non-standalone span with correct attributes', () => {
+ _emitWebVitalSpan({
+ name: 'Test Vital',
+ op: 'ui.webvital.lcp',
+ origin: 'auto.http.browser.lcp',
+ metricName: 'lcp',
+ value: 100,
+ startTime: 1.5,
+ });
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({
+ name: 'Test Vital',
+ attributes: {
+ 'sentry.origin': 'auto.http.browser.lcp',
+ 'sentry.op': 'ui.webvital.lcp',
+ 'sentry.exclusive_time': 0,
+ 'browser.web_vital.lcp.value': 100,
+ 'sentry.transaction': 'test-transaction',
+ 'user_agent.original': 'test-user-agent',
+ },
+ startTime: 1.5,
+ });
+
+ // No standalone flag
+ expect(SentryCore.startInactiveSpan).not.toHaveBeenCalledWith(
+ expect.objectContaining({ experimental: expect.anything() }),
+ );
+
+ expect(mockSpan.end).toHaveBeenCalledWith(1.5);
+ });
+
+ it('includes pageload span id when parentSpan is a pageload span', () => {
+ const mockPageloadSpan = createMockPageloadSpan('abc123');
+ vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({
+ attributes: { 'sentry.op': 'pageload' },
+ } as any);
+
+ _emitWebVitalSpan({
+ name: 'Test',
+ op: 'ui.webvital.lcp',
+ origin: 'auto.http.browser.lcp',
+ metricName: 'lcp',
+ value: 50,
+ parentSpan: mockPageloadSpan as any,
+ startTime: 1.0,
+ });
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'sentry.pageload.span_id': 'abc123',
+ }),
+ parentSpan: mockPageloadSpan,
+ }),
+ );
+ });
+
+ it('does not include pageload span id when parentSpan is not a pageload span', () => {
+ const mockNonPageloadSpan = createMockPageloadSpan('xyz789');
+ vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({
+ attributes: { 'sentry.op': 'ui.interaction.click' },
+ } as any);
+
+ _emitWebVitalSpan({
+ name: 'Test',
+ op: 'ui.interaction.click',
+ origin: 'auto.http.browser.inp',
+ metricName: 'inp',
+ value: 50,
+ parentSpan: mockNonPageloadSpan as any,
+ startTime: 1.0,
+ });
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.not.objectContaining({
+ 'sentry.pageload.span_id': expect.anything(),
+ }),
+ }),
+ );
+ });
+
+ it('includes reportEvent when provided', () => {
+ _emitWebVitalSpan({
+ name: 'Test',
+ op: 'ui.webvital.cls',
+ origin: 'auto.http.browser.cls',
+ metricName: 'cls',
+ value: 0.1,
+ reportEvent: 'pagehide',
+ startTime: 1.0,
+ });
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'browser.web_vital.cls.report_event': 'pagehide',
+ }),
+ }),
+ );
+ });
+
+ it('merges additional attributes', () => {
+ _emitWebVitalSpan({
+ name: 'Test',
+ op: 'ui.webvital.lcp',
+ origin: 'auto.http.browser.lcp',
+ metricName: 'lcp',
+ value: 50,
+ attributes: { 'custom.attr': 'value' },
+ startTime: 1.0,
+ });
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'custom.attr': 'value',
+ }),
+ }),
+ );
+ });
+
+ it('handles when startInactiveSpan returns undefined', () => {
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(undefined as any);
+
+ expect(() => {
+ _emitWebVitalSpan({
+ name: 'Test',
+ op: 'ui.webvital.lcp',
+ origin: 'auto.http.browser.lcp',
+ metricName: 'lcp',
+ value: 50,
+ startTime: 1.0,
+ });
+ }).not.toThrow();
+ });
+});
+
+describe('_sendLcpSpan', () => {
+ const mockSpan = {
+ end: vi.fn(),
+ };
+
+ const mockScope = {
+ getScopeData: vi.fn().mockReturnValue({
+ transactionName: 'test-route',
+ }),
+ };
+
+ beforeEach(() => {
+ vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
+ vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
+ vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`);
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
+ vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({
+ attributes: { 'sentry.op': 'pageload' },
+ } as any);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('sends a streamed LCP span with entry data', () => {
+ const mockEntry = {
+ element: { tagName: 'img' } as Element,
+ id: 'hero',
+ url: 'https://example.com/hero.jpg',
+ loadTime: 100,
+ renderTime: 150,
+ size: 50000,
+ startTime: 200,
+ } as LargestContentfulPaint;
+
+ const mockPageloadSpan = createMockPageloadSpan('pageload-123');
+
+ _sendLcpSpan(250, mockEntry, mockPageloadSpan as any, 'pagehide');
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: '
',
+ attributes: expect.objectContaining({
+ 'sentry.origin': 'auto.http.browser.lcp',
+ 'sentry.op': 'ui.webvital.lcp',
+ 'sentry.exclusive_time': 0,
+ 'sentry.pageload.span_id': 'pageload-123',
+ 'browser.web_vital.lcp.element': '
',
+ 'browser.web_vital.lcp.id': 'hero',
+ 'browser.web_vital.lcp.url': 'https://example.com/hero.jpg',
+ 'browser.web_vital.lcp.load_time': 100,
+ 'browser.web_vital.lcp.render_time': 150,
+ 'browser.web_vital.lcp.size': 50000,
+ 'browser.web_vital.lcp.report_event': 'pagehide',
+ 'sentry.transaction': 'test-route',
+ }),
+ startTime: 1, // timeOrigin: 1000 / 1000
+ parentSpan: mockPageloadSpan,
+ }),
+ );
+
+ // endTime = timeOrigin + entry.startTime = (1000 + 200) / 1000 = 1.2
+ expect(mockSpan.end).toHaveBeenCalledWith(1.2);
+ });
+
+ it('sends a streamed LCP span without entry data', () => {
+ _sendLcpSpan(0, undefined);
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Largest contentful paint',
+ startTime: 1, // timeOrigin: 1000 / 1000
+ }),
+ );
+ });
+});
+
+describe('_sendClsSpan', () => {
+ const mockSpan = {
+ end: vi.fn(),
+ };
+
+ const mockScope = {
+ getScopeData: vi.fn().mockReturnValue({
+ transactionName: 'test-route',
+ }),
+ };
+
+ beforeEach(() => {
+ vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
+ vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
+ vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5);
+ vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`);
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
+ vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({
+ attributes: { 'sentry.op': 'pageload' },
+ } as any);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('sends a streamed CLS span with entry data and sources', () => {
+ const mockEntry: LayoutShift = {
+ name: 'layout-shift',
+ entryType: 'layout-shift',
+ startTime: 100,
+ duration: 0,
+ value: 0.1,
+ hadRecentInput: false,
+ sources: [
+ // @ts-expect-error - other properties are irrelevant
+ { node: { tagName: 'div' } as Element },
+ // @ts-expect-error - other properties are irrelevant
+ { node: { tagName: 'span' } as Element },
+ ],
+ toJSON: vi.fn(),
+ };
+
+ vi.mocked(SentryCore.htmlTreeAsString)
+ .mockReturnValueOnce('') // for the name
+ .mockReturnValueOnce('
') // for source 1
+ .mockReturnValueOnce('
'); // for source 2
+
+ const mockPageloadSpan = createMockPageloadSpan('pageload-789');
+
+ _sendClsSpan(0.1, mockEntry, mockPageloadSpan as any, 'navigation');
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: '',
+ attributes: expect.objectContaining({
+ 'sentry.origin': 'auto.http.browser.cls',
+ 'sentry.op': 'ui.webvital.cls',
+ 'sentry.pageload.span_id': 'pageload-789',
+ 'browser.web_vital.cls.source.1': '
',
+ 'browser.web_vital.cls.source.2': '',
+ 'browser.web_vital.cls.report_event': 'navigation',
+ 'sentry.transaction': 'test-route',
+ }),
+ parentSpan: mockPageloadSpan,
+ }),
+ );
+ });
+
+ it('sends a streamed CLS span without entry data', () => {
+ _sendClsSpan(0, undefined);
+
+ expect(SentryCore.timestampInSeconds).toHaveBeenCalled();
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Layout shift',
+ startTime: 1.5,
+ }),
+ );
+ });
+});
+
+describe('_sendInpSpan', () => {
+ const mockSpan = {
+ end: vi.fn(),
+ };
+
+ const mockScope = {
+ getScopeData: vi.fn().mockReturnValue({
+ transactionName: 'test-route',
+ }),
+ };
+
+ beforeEach(() => {
+ vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
+ vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
+ vi.mocked(SentryCore.htmlTreeAsString).mockReturnValue('