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 }) => {