diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-no-layout-shift/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-no-layout-shift/template.html
new file mode 100644
index 000000000000..4245edf74abb
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-no-layout-shift/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ Some content
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-no-layout-shift/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-no-layout-shift/test.ts
new file mode 100644
index 000000000000..c1b83ce6e447
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-no-layout-shift/test.ts
@@ -0,0 +1,41 @@
+import { expect } from '@playwright/test';
+import type { Event } from '@sentry/types';
+
+import { sentryTest } from '../../../../utils/fixtures';
+import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
+
+sentryTest.beforeEach(async ({ page }) => {
+ await page.setViewportSize({ width: 800, height: 1200 });
+});
+
+sentryTest('captures 0 CLS if the browser supports reporting CLS', async ({ getLocalTestPath, page, browserName }) => {
+ if (shouldSkipTracingTest() || browserName !== 'chromium') {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestPath({ testDir: __dirname });
+ const transactionEvent = await getFirstSentryEnvelopeRequest(page, url);
+
+ expect(transactionEvent.measurements).toBeDefined();
+ expect(transactionEvent.measurements?.cls?.value).toBe(0);
+
+ // but no source entry (no source if there is no layout shift)
+ expect(transactionEvent.contexts?.trace?.data?.['cls.source.1']).toBeUndefined();
+});
+
+sentryTest(
+ "doesn't capture 0 CLS if the browser doesn't support reporting CLS",
+ async ({ getLocalTestPath, page, browserName }) => {
+ if (shouldSkipTracingTest() || browserName === 'chromium') {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestPath({ testDir: __dirname });
+ const transactionEvent = await getFirstSentryEnvelopeRequest(page, `${url}#no-cls`);
+
+ expect(transactionEvent.measurements).toBeDefined();
+ expect(transactionEvent.measurements?.cls).toBeUndefined();
+
+ expect(transactionEvent.contexts?.trace?.data?.['cls.source.1']).toBeUndefined();
+ },
+);
diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts
index b71f80df1ff2..68f59c79f13d 100644
--- a/packages/browser-utils/src/metrics/browserMetrics.ts
+++ b/packages/browser-utils/src/metrics/browserMetrics.ts
@@ -221,6 +221,8 @@ export { startTrackingINP, registerInpInteractionListener } from './inp';
* to the `_measurements` object which ultimately is applied to the pageload span's measurements.
*/
function _trackCLS(): () => void {
+ trySetZeroClsValue();
+
return addClsInstrumentationHandler(({ metric }) => {
const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
if (!entry) {
@@ -232,6 +234,30 @@ function _trackCLS(): () => void {
}, true);
}
+/**
+ * Why does this function exist? A very good question!
+ *
+ * The `PerformanceObserver` emits `LayoutShift` entries whenever a layout shift occurs.
+ * If none occurs (which is great!), the observer will never emit any entries. Makes sense so far!
+ *
+ * This is problematic for the Sentry product though. We can't differentiate between a CLS of 0 and not having received
+ * CLS data at all. So in both cases, we'd show users that the CLS score simply is not available. When in fact, it can
+ * be 0, which is a very good score. This function is a workaround to emit a CLS of 0 right at the start of
+ * listening to CLS events. This way, we can differentiate between a CLS of 0 and no CLS at all. If a layout shift
+ * occurs later, the real CLS value will be emitted and the 0 value will be ignored.
+ * We also only send this artificial 0 value if the browser supports reporting the `layout-shift` entry type.
+ */
+function trySetZeroClsValue(): void {
+ try {
+ if (PerformanceObserver.supportedEntryTypes.includes('layout-shift')) {
+ DEBUG_BUILD && logger.log('[Measurements] Adding CLS 0');
+ _measurements['cls'] = { value: 0, unit: '' };
+ }
+ } catch {
+ // catching and ignoring access errors for bundle size minimization.
+ }
+}
+
/** Starts tracking the Largest Contentful Paint on the current page. */
function _trackLCP(): () => void {
return addLcpInstrumentationHandler(({ metric }) => {