diff --git a/.size-limit.js b/.size-limit.js
index 80aa4c5095ea..859ce741cc3d 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -55,7 +55,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'),
gzip: true,
- limit: '90 KB',
+ limit: '91 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback, metrics)',
@@ -143,7 +143,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
- limit: '37 KB',
+ limit: '38 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
@@ -170,7 +170,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.min.js'),
gzip: false,
brotli: false,
- limit: '110 KB',
+ limit: '111 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed',
@@ -193,7 +193,7 @@ module.exports = [
import: createImport('init'),
ignore: ['next/router', 'next/constants'],
gzip: true,
- limit: '38.05 KB',
+ limit: '39 KB',
},
// SvelteKit SDK (ESM)
{
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
new file mode 100644
index 000000000000..32fbb07fbbae
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js
@@ -0,0 +1,17 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 9000,
+ _experiments: {
+ enableStandaloneClsSpans: true,
+ },
+ }),
+ ],
+ tracesSampleRate: 1,
+ debug: true,
+});
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
new file mode 100644
index 000000000000..ed1b9b790bb9
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-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 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
new file mode 100644
index 000000000000..487683893a7f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/template.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+ 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
new file mode 100644
index 000000000000..cdf1e6837ef4
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts
@@ -0,0 +1,455 @@
+import type { Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import type { Event as SentryEvent, EventEnvelope, SpanEnvelope } from '@sentry/types';
+
+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 ({ getLocalTestPath, page }) => {
+ const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
+ page,
+ 1,
+ { envelopeType: 'span' },
+ properFullEnvelopeRequestParser,
+ );
+
+ const url = await getLocalTestPath({ 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',
+ transaction: expect.stringContaining('index.html'),
+ 'user_agent.original': expect.stringContaining('Chrome'),
+ 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/),
+ },
+ 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-f0-9]{16}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ segment_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: spanEnvelopeItem.start_timestamp,
+ trace_id: expect.stringMatching(/[a-f0-9]{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,
+ // no transaction, because span source is URL
+ },
+ });
+});
+
+sentryTest('captures a "MEH" CLS vital with its source as a standalone span', async ({ getLocalTestPath, page }) => {
+ const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
+ page,
+ 1,
+ { envelopeType: 'span' },
+ properFullEnvelopeRequestParser,
+ );
+
+ const url = await getLocalTestPath({ 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',
+ transaction: expect.stringContaining('index.html'),
+ 'user_agent.original': expect.stringContaining('Chrome'),
+ 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/),
+ },
+ 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-f0-9]{16}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ segment_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: spanEnvelopeItem.start_timestamp,
+ trace_id: expect.stringMatching(/[a-f0-9]{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,
+ // no transaction, because span source is URL
+ },
+ });
+});
+
+sentryTest('captures a "POOR" CLS vital with its source as a standalone span.', async ({ getLocalTestPath, page }) => {
+ const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
+ page,
+ 1,
+ { envelopeType: 'span' },
+ properFullEnvelopeRequestParser,
+ );
+
+ const url = await getLocalTestPath({ 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',
+ transaction: expect.stringContaining('index.html'),
+ 'user_agent.original': expect.stringContaining('Chrome'),
+ 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/),
+ },
+ 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-f0-9]{16}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ segment_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: spanEnvelopeItem.start_timestamp,
+ trace_id: expect.stringMatching(/[a-f0-9]{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,
+ // no transaction, because span source is URL
+ },
+ });
+});
+
+sentryTest(
+ 'captures a 0 CLS vital as a standalone span if no layout shift occurred',
+ async ({ getLocalTestPath, page }) => {
+ const spanEnvelopePromise = getMultipleSentryEnvelopeRequests(
+ page,
+ 1,
+ { envelopeType: 'span' },
+ properFullEnvelopeRequestParser,
+ );
+
+ const url = await getLocalTestPath({ 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',
+ transaction: expect.stringContaining('index.html'),
+ 'user_agent.original': expect.stringContaining('Chrome'),
+ 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{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-f0-9]{16}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ segment_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: spanEnvelopeItem.start_timestamp,
+ trace_id: expect.stringMatching(/[a-f0-9]{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,
+ // no transaction, because span source is URL
+ },
+ });
+ },
+);
+
+sentryTest(
+ 'captures CLS increases after the pageload span ended, when page is hidden',
+ async ({ getLocalTestPath, page }) => {
+ const url = await getLocalTestPath({ 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-f0-9]{16}/);
+ expect(pageloadTraceId).toMatch(/[a-f0-9]{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);
+ },
+);
+
+sentryTest('sends CLS of the initial page when soft-navigating to a new page', async ({ getLocalTestPath, page }) => {
+ const url = await getLocalTestPath({ 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];
+ // 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']).toMatch(/[a-f0-9]{16}/);
+});
+
+sentryTest("doesn't send further CLS after the first navigation", async ({ getLocalTestPath, page }) => {
+ const url = await getLocalTestPath({ 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);
+
+ 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 ({ getLocalTestPath, page }) => {
+ const url = await getLocalTestPath({ 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);
+
+ 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;
+});
diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts
index 43ea45dd4a08..b71f80df1ff2 100644
--- a/packages/browser-utils/src/metrics/browserMetrics.ts
+++ b/packages/browser-utils/src/metrics/browserMetrics.ts
@@ -7,6 +7,7 @@ import { browserPerformanceTimeOrigin, getComponentName, htmlTreeAsString, logge
import { spanToJSON } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { WINDOW } from '../types';
+import { trackClsAsStandaloneSpan } from './cls';
import {
type PerformanceLongAnimationFrameTiming,
addClsInstrumentationHandler,
@@ -65,29 +66,33 @@ let _measurements: Measurements = {};
let _lcpEntry: LargestContentfulPaint | undefined;
let _clsEntry: LayoutShift | undefined;
+interface StartTrackingWebVitalsOptions {
+ recordClsStandaloneSpans: boolean;
+}
+
/**
* Start tracking web vitals.
* The callback returned by this function can be used to stop tracking & ensure all measurements are final & captured.
*
* @returns A function that forces web vitals collection
*/
-export function startTrackingWebVitals(): () => void {
+export function startTrackingWebVitals({ recordClsStandaloneSpans }: StartTrackingWebVitalsOptions): () => void {
const performance = getBrowserPerformanceAPI();
if (performance && browserPerformanceTimeOrigin) {
// @ts-expect-error we want to make sure all of these are available, even if TS is sure they are
if (performance.mark) {
WINDOW.performance.mark('sentry-tracing-init');
}
- const fidCallback = _trackFID();
- const clsCallback = _trackCLS();
- const lcpCallback = _trackLCP();
- const ttfbCallback = _trackTtfb();
+ const fidCleanupCallback = _trackFID();
+ const lcpCleanupCallback = _trackLCP();
+ const ttfbCleanupCallback = _trackTtfb();
+ const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan() : _trackCLS();
return (): void => {
- fidCallback();
- clsCallback();
- lcpCallback();
- ttfbCallback();
+ fidCleanupCallback();
+ lcpCleanupCallback();
+ ttfbCleanupCallback();
+ clsCleanupCallback && clsCleanupCallback();
};
}
@@ -211,17 +216,19 @@ export function startTrackingInteractions(): void {
export { startTrackingINP, registerInpInteractionListener } from './inp';
-/** Starts tracking the Cumulative Layout Shift on the current page. */
+/**
+ * Starts tracking the Cumulative Layout Shift on the current page and collects the value and last entry
+ * to the `_measurements` object which ultimately is applied to the pageload span's measurements.
+ */
function _trackCLS(): () => void {
return addClsInstrumentationHandler(({ metric }) => {
- const entry = metric.entries[metric.entries.length - 1];
+ const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
if (!entry) {
return;
}
-
- DEBUG_BUILD && logger.log('[Measurements] Adding CLS');
+ DEBUG_BUILD && logger.log(`[Measurements] Adding CLS ${metric.value}`);
_measurements['cls'] = { value: metric.value, unit: '' };
- _clsEntry = entry as LayoutShift;
+ _clsEntry = entry;
}, true);
}
@@ -267,8 +274,16 @@ function _trackTtfb(): () => void {
});
}
+interface AddPerformanceEntriesOptions {
+ /**
+ * Flag to determine if CLS should be recorded as a measurement on the span or
+ * sent as a standalone span instead.
+ */
+ recordClsOnPageloadSpan: boolean;
+}
+
/** Add performance related spans to a transaction */
-export function addPerformanceEntries(span: Span): void {
+export function addPerformanceEntries(span: Span, options: AddPerformanceEntriesOptions): void {
const performance = getBrowserPerformanceAPI();
if (!performance || !WINDOW.performance.getEntries || !browserPerformanceTimeOrigin) {
// Gatekeeper if performance API not available
@@ -286,7 +301,7 @@ export function addPerformanceEntries(span: Span): void {
performanceEntries.slice(_performanceCursor).forEach((entry: Record) => {
const startTime = msToSec(entry.startTime);
const duration = msToSec(
- // Inexplicibly, Chrome sometimes emits a negative duration. We need to work around this.
+ // Inexplicably, Chrome sometimes emits a negative duration. We need to work around this.
// There is a SO post attempting to explain this, but it leaves one with open questions: https://stackoverflow.com/questions/23191918/peformance-getentries-and-negative-duration-display
// The way we clamp the value is probably not accurate, since we have observed this happen for things that may take a while to load, like for example the replay worker.
// TODO: Investigate why this happens and how to properly mitigate. For now, this is a workaround to prevent transactions being dropped due to negative duration spans.
@@ -375,7 +390,8 @@ export function addPerformanceEntries(span: Span): void {
// If FCP is not recorded we should not record the cls value
// according to the new definition of CLS.
- if (!('fcp' in _measurements)) {
+ // TODO: Check if the first condition is still necessary: `onCLS` already only fires once `onFCP` was called.
+ if (!('fcp' in _measurements) || !options.recordClsOnPageloadSpan) {
delete _measurements.cls;
}
diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts
new file mode 100644
index 000000000000..aa25a54754a1
--- /dev/null
+++ b/packages/browser-utils/src/metrics/cls.ts
@@ -0,0 +1,122 @@
+import {
+ SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
+ SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
+ SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ getActiveSpan,
+ getClient,
+ getCurrentScope,
+ getRootSpan,
+ spanToJSON,
+} from '@sentry/core';
+import type { SpanAttributes } from '@sentry/types';
+import { browserPerformanceTimeOrigin, dropUndefinedKeys, htmlTreeAsString, logger } from '@sentry/utils';
+import { DEBUG_BUILD } from '../debug-build';
+import { addClsInstrumentationHandler } from './instrument';
+import { msToSec, startStandaloneWebVitalSpan } from './utils';
+import { onHidden } from './web-vitals/lib/onHidden';
+
+/**
+ * Starts tracking the Cumulative Layout Shift on the current page and collects the value once
+ *
+ * - the page visibility is hidden
+ * - a navigation span is started (to stop CLS measurement for SPA soft navigations)
+ *
+ * Once either of these events triggers, the CLS value is sent as a standalone span and we stop
+ * measuring CLS.
+ */
+export function trackClsAsStandaloneSpan(): void {
+ let standaloneCLsValue = 0;
+ let standaloneClsEntry: LayoutShift | undefined;
+ let pageloadSpanId: string | undefined;
+
+ if (!supportsLayoutShift()) {
+ return;
+ }
+
+ let sentSpan = false;
+ function _collectClsOnce() {
+ if (sentSpan) {
+ return;
+ }
+ sentSpan = true;
+ if (pageloadSpanId) {
+ sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId);
+ }
+ cleanupClsHandler();
+ }
+
+ const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => {
+ const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
+ if (!entry) {
+ return;
+ }
+ standaloneCLsValue = metric.value;
+ standaloneClsEntry = entry;
+ }, true);
+
+ // use pagehide event from web-vitals
+ onHidden(() => {
+ _collectClsOnce();
+ });
+
+ // Since the call chain of this function is synchronous and evaluates before the SDK client is created,
+ // we need to wait with subscribing to a client hook until the client is created. Therefore, we defer
+ // to the next tick after the SDK setup.
+ setTimeout(() => {
+ const client = getClient();
+
+ const unsubscribeStartNavigation = client?.on('startNavigationSpan', () => {
+ _collectClsOnce();
+ unsubscribeStartNavigation && unsubscribeStartNavigation();
+ });
+
+ const activeSpan = getActiveSpan();
+ const rootSpan = activeSpan && getRootSpan(activeSpan);
+ const spanJSON = rootSpan && spanToJSON(rootSpan);
+ if (spanJSON && spanJSON.op === 'pageload') {
+ pageloadSpanId = rootSpan.spanContext().spanId;
+ }
+ }, 0);
+}
+
+function sendStandaloneClsSpan(clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string) {
+ DEBUG_BUILD && logger.log(`Sending CLS span (${clsValue})`);
+
+ const startTime = msToSec(browserPerformanceTimeOrigin as number) + (entry?.startTime || 0);
+ const duration = msToSec(entry?.duration || 0);
+ const routeName = getCurrentScope().getScopeData().transactionName;
+
+ const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift';
+
+ const attributes: SpanAttributes = dropUndefinedKeys({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.cls',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.webvital.cls',
+ [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration || 0,
+ // attach the pageload span id to the CLS span so that we can link them in the UI
+ 'sentry.pageload.span_id': pageloadSpanId,
+ });
+
+ const span = startStandaloneWebVitalSpan({
+ name,
+ transaction: routeName,
+ attributes,
+ startTime,
+ });
+
+ span?.addEvent('cls', {
+ [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: '',
+ [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: clsValue,
+ });
+
+ span?.end(startTime + duration);
+}
+
+function supportsLayoutShift(): boolean {
+ try {
+ return PerformanceObserver.supportedEntryTypes?.includes('layout-shift');
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts
index c4186a20f17e..5814b139bd2d 100644
--- a/packages/browser-utils/src/metrics/inp.ts
+++ b/packages/browser-utils/src/metrics/inp.ts
@@ -2,23 +2,21 @@ import {
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
getActiveSpan,
- getClient,
getCurrentScope,
getRootSpan,
spanToJSON,
- startInactiveSpan,
} from '@sentry/core';
-import type { Integration, Span, SpanAttributes } from '@sentry/types';
+import type { Span, SpanAttributes } from '@sentry/types';
import { browserPerformanceTimeOrigin, dropUndefinedKeys, htmlTreeAsString } from '@sentry/utils';
-import { WINDOW } from '../types';
import {
addInpInstrumentationHandler,
addPerformanceInstrumentationHandler,
isPerformanceEventTiming,
} from './instrument';
-import { getBrowserPerformanceAPI, msToSec } from './utils';
+import { getBrowserPerformanceAPI, msToSec, startStandaloneWebVitalSpan } from './utils';
const LAST_INTERACTIONS: number[] = [];
const INTERACTIONS_SPAN_MAP = new Map();
@@ -71,8 +69,7 @@ const INP_ENTRY_MAP: Record = {
/** Starts tracking the Interaction to Next Paint on the current page. */
function _trackINP(): () => void {
return addInpInstrumentationHandler(({ metric }) => {
- const client = getClient();
- if (!client || metric.value == undefined) {
+ if (metric.value == undefined) {
return;
}
@@ -85,11 +82,9 @@ function _trackINP(): () => void {
const { interactionId } = entry;
const interactionType = INP_ENTRY_MAP[entry.name];
- const options = client.getOptions();
/** Build the INP span, create an envelope from the span, and then send the envelope */
const startTime = msToSec((browserPerformanceTimeOrigin as number) + entry.startTime);
const duration = msToSec(metric.value);
- const scope = getCurrentScope();
const activeSpan = getActiveSpan();
const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
@@ -101,56 +96,28 @@ function _trackINP(): () => void {
// Else, we try to use the active span.
// Finally, we fall back to look at the transactionName on the scope
- const routeName = spanToUse ? spanToJSON(spanToUse).description : scope.getScopeData().transactionName;
-
- const user = scope.getUser();
-
- // We need to get the replay, user, and activeTransaction from the current scope
- // so that we can associate replay id, profile id, and a user display to the span
- const replay = client.getIntegrationByName string }>('Replay');
-
- const replayId = replay && replay.getReplayId();
-
- const userDisplay = user !== undefined ? user.email || user.id || user.ip_address : undefined;
- let profileId: string | undefined = undefined;
- try {
- // @ts-expect-error skip optional chaining to save bundle size with try catch
- profileId = scope.getScopeData().contexts.profile.profile_id;
- } catch {
- // do nothing
- }
+ const routeName = spanToUse ? spanToJSON(spanToUse).description : getCurrentScope().getScopeData().transactionName;
const name = htmlTreeAsString(entry.target);
const attributes: SpanAttributes = dropUndefinedKeys({
- release: options.release,
- environment: options.environment,
- transaction: routeName,
- [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: metric.value,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.inp',
- user: userDisplay || undefined,
- profile_id: profileId || undefined,
- replay_id: replayId || undefined,
- // INP score calculation in the sentry backend relies on the user agent
- // to account for different INP values being reported from different browsers
- 'user_agent.original': WINDOW.navigator && WINDOW.navigator.userAgent,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.interaction.${interactionType}`,
+ [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration,
});
- const span = startInactiveSpan({
+ const span = startStandaloneWebVitalSpan({
name,
- op: `ui.interaction.${interactionType}`,
+ transaction: routeName,
attributes,
- startTime: startTime,
- experimental: {
- standalone: true,
- },
+ startTime,
});
- span.addEvent('inp', {
+ span?.addEvent('inp', {
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond',
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: metric.value,
});
- span.end(startTime + duration);
+ span?.end(startTime + duration);
});
}
diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts
index d46cb2cfe35c..5f9d0de4d4ab 100644
--- a/packages/browser-utils/src/metrics/utils.ts
+++ b/packages/browser-utils/src/metrics/utils.ts
@@ -1,6 +1,6 @@
import type { SentrySpan } from '@sentry/core';
-import { spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core';
-import type { Span, SpanTimeInput, StartSpanOptions } from '@sentry/types';
+import { getClient, getCurrentScope, spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core';
+import type { Integration, Span, SpanAttributes, SpanTimeInput, StartSpanOptions } from '@sentry/types';
import { WINDOW } from '../types';
/**
@@ -44,6 +44,84 @@ export function startAndEndSpan(
});
}
+interface StandaloneWebVitalSpanOptions {
+ name: string;
+ transaction?: string;
+ attributes: SpanAttributes;
+ startTime: number;
+}
+
+/**
+ * Starts an inactive, standalone span used to send web vital values to Sentry.
+ * DO NOT use this for arbitrary spans, as these spans require special handling
+ * during ingestion to extract metrics.
+ *
+ * This function adds a bunch of attributes and data to the span that's shared
+ * by all web vital standalone spans. However, you need to take care of adding
+ * the actual web vital value as an event to the span. Also, you need to assign
+ * a transaction name and some other values that are specific to the web vital.
+ *
+ * Ultimately, you also need to take care of ending the span to send it off.
+ *
+ * @param options
+ *
+ * @returns an inactive, standalone and NOT YET ended span
+ */
+export function startStandaloneWebVitalSpan(options: StandaloneWebVitalSpanOptions): Span | undefined {
+ const client = getClient();
+ if (!client) {
+ return;
+ }
+
+ const { name, transaction, attributes: passedAttributes, startTime } = options;
+
+ const { release, environment } = client.getOptions();
+ // We need to get the replay, user, and activeTransaction from the current scope
+ // so that we can associate replay id, profile id, and a user display to the span
+ const replay = client.getIntegrationByName string }>('Replay');
+ const replayId = replay && replay.getReplayId();
+
+ const scope = getCurrentScope();
+
+ const user = scope.getUser();
+ const userDisplay = user !== undefined ? user.email || user.id || user.ip_address : undefined;
+
+ let profileId: string | undefined = undefined;
+ try {
+ // @ts-expect-error skip optional chaining to save bundle size with try catch
+ profileId = scope.getScopeData().contexts.profile.profile_id;
+ } catch {
+ // do nothing
+ }
+
+ const attributes: SpanAttributes = {
+ release,
+ environment,
+
+ user: userDisplay || undefined,
+ profile_id: profileId || undefined,
+ replay_id: replayId || undefined,
+
+ transaction,
+
+ // Web vital score calculation relies on the user agent to account for different
+ // browsers setting different thresholds for what is considered a good/meh/bad value.
+ // For example: Chrome vs. Chrome Mobile
+ 'user_agent.original': WINDOW.navigator && WINDOW.navigator.userAgent,
+
+ ...passedAttributes,
+ };
+
+ return startInactiveSpan({
+ name,
+ attributes,
+ startTime,
+ experimental: {
+ standalone: true,
+ },
+ });
+}
+
/** Get the browser performance API. */
export function getBrowserPerformanceAPI(): Performance | undefined {
// @ts-expect-error we want to make sure all of these are available, even if TS is sure they are
diff --git a/packages/browser-utils/src/metrics/web-vitals/README.md b/packages/browser-utils/src/metrics/web-vitals/README.md
index 653ee22c7ff1..d779969dbe5d 100644
--- a/packages/browser-utils/src/metrics/web-vitals/README.md
+++ b/packages/browser-utils/src/metrics/web-vitals/README.md
@@ -17,9 +17,9 @@ Current vendored web vitals are:
## Notable Changes from web-vitals library
-This vendored web-vitals library is meant to be used in conjunction with the `@sentry/tracing` `BrowserTracing`
-integration. As such, logic around `BFCache` and multiple reports were removed from the library as our web-vitals only
-report once per pageload.
+This vendored web-vitals library is meant to be used in conjunction with the `@sentry/browser`
+`browserTracingIntegration`. As such, logic around `BFCache` and multiple reports were removed from the library as our
+web-vitals only report once per pageload.
## License
diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts
index 491c7aaae88d..ff5201878cff 100644
--- a/packages/browser/src/tracing/browserTracingIntegration.ts
+++ b/packages/browser/src/tracing/browserTracingIntegration.ts
@@ -146,6 +146,7 @@ export interface BrowserTracingOptions {
*/
_experiments: Partial<{
enableInteractions: boolean;
+ enableStandaloneClsSpans: boolean;
}>;
/**
@@ -191,7 +192,7 @@ export const browserTracingIntegration = ((_options: Partial {
_collectWebVitals();
- addPerformanceEntries(span);
+ addPerformanceEntries(span, { recordClsOnPageloadSpan: !enableStandaloneClsSpans });
},
});
@@ -298,6 +299,7 @@ export const browserTracingIntegration = ((_options: Partial