Skip to content

Commit 2c4c631

Browse files
committed
fix(browser): Set artificial zero-value CLS measurement to report no layout shift
1 parent eb23dc4 commit 2c4c631

File tree

3 files changed

+88
-1
lines changed

3 files changed

+88
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<div id="content">
8+
Some content
9+
</div>
10+
</body>
11+
</html>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
6+
7+
sentryTest.beforeEach(async ({ page }) => {
8+
await page.setViewportSize({ width: 800, height: 1200 });
9+
});
10+
11+
sentryTest('captures 0 CLS if the browser supports reporting CLS', async ({ getLocalTestPath, page, browserName }) => {
12+
if (shouldSkipTracingTest() || browserName !== 'chromium') {
13+
sentryTest.skip();
14+
}
15+
16+
const url = await getLocalTestPath({ testDir: __dirname });
17+
const transactionEvent = await getFirstSentryEnvelopeRequest<Event>(page, url);
18+
19+
expect(transactionEvent.measurements).toBeDefined();
20+
expect(transactionEvent.measurements?.cls?.value).toBe(0);
21+
22+
// but no source entry (no source if there is no layout shift)
23+
expect(transactionEvent.contexts?.trace?.data?.['cls.source.1']).toBeUndefined();
24+
});
25+
26+
sentryTest(
27+
"doesn't capture 0 CLS if the browser doesn't support reporting CLS",
28+
async ({ getLocalTestPath, page, browserName }) => {
29+
if (shouldSkipTracingTest() || browserName === 'chromium') {
30+
sentryTest.skip();
31+
}
32+
33+
const url = await getLocalTestPath({ testDir: __dirname });
34+
const transactionEvent = await getFirstSentryEnvelopeRequest<Event>(page, `${url}#no-cls`);
35+
36+
expect(transactionEvent.measurements).toBeDefined();
37+
expect(transactionEvent.measurements?.cls).toBeUndefined();
38+
39+
expect(transactionEvent.contexts?.trace?.data?.['cls.source.1']).toBeUndefined();
40+
},
41+
);

packages/browser-utils/src/metrics/browserMetrics.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, getActiveSpan, startInactiveSpan } from '@sentry/core';
33
import { setMeasurement } from '@sentry/core';
44
import type { Measurements, Span, SpanAttributes, StartSpanOptions } from '@sentry/types';
5-
import { browserPerformanceTimeOrigin, getComponentName, htmlTreeAsString, logger, parseUrl } from '@sentry/utils';
5+
import {
6+
browserPerformanceTimeOrigin,
7+
consoleSandbox,
8+
getComponentName,
9+
htmlTreeAsString,
10+
logger,
11+
parseUrl,
12+
} from '@sentry/utils';
613

714
import { spanToJSON } from '@sentry/core';
815
import { DEBUG_BUILD } from '../debug-build';
@@ -215,6 +222,8 @@ export { startTrackingINP, registerInpInteractionListener } from './inp';
215222

216223
/** Starts tracking the Cumulative Layout Shift on the current page. */
217224
function _trackCLS(): () => void {
225+
trySetZeroClsValue();
226+
218227
return addClsInstrumentationHandler(({ metric }) => {
219228
const entry = metric.entries[metric.entries.length - 1];
220229
if (!entry) {
@@ -227,6 +236,32 @@ function _trackCLS(): () => void {
227236
}, true);
228237
}
229238

239+
/**
240+
* Why does this function exist? A very good question!
241+
*
242+
* The `PerformanceObserver` emits `LayoutShift` entries whenever a layout shift occurs.
243+
* If none occurs (which is great!), the observer will never emit any entries. Makes sense so far!
244+
*
245+
* This is problematic for the Sentry product though. We can't differentiate between a CLS of 0 and not having received
246+
* 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
247+
* be 0, which is a very good score. This function is a workaround to emit a CLS of 0 right at the start of
248+
* listening to CLS events. This way, we can differentiate between a CLS of 0 and no CLS at all. If a layout shift
249+
* occurs later, the real CLS value will be emitted and the 0 value will be ignored.
250+
* We also only send this artificial 0 value if the browser supports reporting the `layout-shift` entry type.
251+
*/
252+
function trySetZeroClsValue(): void {
253+
try {
254+
const canReportLayoutShift = PerformanceObserver.supportedEntryTypes.includes('layout-shift');
255+
if (canReportLayoutShift) {
256+
DEBUG_BUILD && logger.log('[Measurements] Adding CLS 0');
257+
_measurements['cls'] = { value: 0, unit: '' };
258+
// TODO: Do we have to set _clsEntry here as well? If so, what attribution should we give it?
259+
}
260+
} catch {
261+
// catching and ignoring access errors for bundle size reduction
262+
}
263+
}
264+
230265
/** Starts tracking the Largest Contentful Paint on the current page. */
231266
function _trackLCP(): () => void {
232267
return addLcpInstrumentationHandler(({ metric }) => {

0 commit comments

Comments
 (0)