Skip to content

Commit 4dda73e

Browse files
logaretmclaude
andcommitted
ref(browser): Send LCP/CLS as v2 spans when span streaming is enabled
When span streaming (v2) is enabled, LCP and CLS are now always emitted as standalone v2 spans (like INP) instead of being recorded as measurements or attributes on the pageload span. When span streaming is disabled, they continue to be recorded as measurements on the pageload span. This behavior is no longer user-configurable; the previously removed `_experiments.enableStandaloneClsSpans` / `enableStandaloneLcpSpans` options stay removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bd0df5c commit 4dda73e

14 files changed

Lines changed: 573 additions & 39 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window._testBaseTimestamp = performance.timeOrigin / 1000;
5+
6+
Sentry.init({
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
9+
traceLifecycle: 'stream',
10+
tracesSampleRate: 1,
11+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { simulateCLS } from '../../../../utils/web-vitals/cls.ts';
2+
3+
// Simulate Layout shift right at the beginning of the page load, depending on the URL hash
4+
// don't run if expected CLS is NaN
5+
const expectedCLS = Number(location.hash.slice(1));
6+
if (expectedCLS && expectedCLS >= 0) {
7+
simulateCLS(expectedCLS).then(() => window.dispatchEvent(new Event('cls-done')));
8+
}
9+
10+
// Simulate layout shift whenever the trigger-cls event is dispatched
11+
// Cannot trigger via a button click because expected layout shift after
12+
// an interaction doesn't contribute to CLS.
13+
window.addEventListener('trigger-cls', () => {
14+
simulateCLS(0.1).then(() => {
15+
window.dispatchEvent(new Event('cls-done'));
16+
});
17+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<div id="content"></div>
8+
<p>Some content</p>
9+
</body>
10+
</html>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { Page } from '@playwright/test';
2+
import { expect } from '@playwright/test';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { hidePage, shouldSkipTracingTest } from '../../../../utils/helpers';
5+
import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
6+
7+
sentryTest.beforeEach(async ({ browserName, page }) => {
8+
if (shouldSkipTracingTest() || browserName !== 'chromium') {
9+
sentryTest.skip();
10+
}
11+
12+
await page.setViewportSize({ width: 800, height: 1200 });
13+
});
14+
15+
function waitForLayoutShift(page: Page): Promise<void> {
16+
return page.evaluate(() => {
17+
return new Promise(resolve => {
18+
window.addEventListener('cls-done', () => resolve());
19+
});
20+
});
21+
}
22+
23+
sentryTest('captures CLS as a streamed span with source attributes', async ({ getLocalTestUrl, page }) => {
24+
const url = await getLocalTestUrl({ testDir: __dirname });
25+
26+
const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls');
27+
const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
28+
29+
await page.goto(`${url}#0.15`);
30+
await waitForLayoutShift(page);
31+
await hidePage(page);
32+
33+
const clsSpan = await clsSpanPromise;
34+
const pageloadSpan = await pageloadSpanPromise;
35+
36+
expect(clsSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.cls' });
37+
expect(clsSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.cls' });
38+
expect(clsSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
39+
expect(clsSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));
40+
41+
// Check browser.web_vital.cls.source attributes
42+
expect(clsSpan.attributes?.['browser.web_vital.cls.source.1']?.value).toEqual(
43+
expect.stringContaining('body > div#content > p'),
44+
);
45+
46+
// Check pageload span id is present
47+
expect(clsSpan.attributes?.['sentry.pageload.span_id']?.value).toBe(pageloadSpan.span_id);
48+
49+
// CLS is a point-in-time metric
50+
expect(clsSpan.start_timestamp).toEqual(clsSpan.end_timestamp);
51+
52+
expect(clsSpan.span_id).toMatch(/^[\da-f]{16}$/);
53+
expect(clsSpan.trace_id).toMatch(/^[\da-f]{32}$/);
54+
55+
expect(clsSpan.parent_span_id).toBe(pageloadSpan.span_id);
56+
expect(clsSpan.trace_id).toBe(pageloadSpan.trace_id);
57+
});
58+
59+
sentryTest('CLS streamed span has web vital value attribute', async ({ getLocalTestUrl, page }) => {
60+
const url = await getLocalTestUrl({ testDir: __dirname });
61+
62+
const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls');
63+
64+
await page.goto(`${url}#0.1`);
65+
await waitForLayoutShift(page);
66+
await hidePage(page);
67+
68+
const clsSpan = await clsSpanPromise;
69+
70+
// The CLS value should be set as a browser.web_vital.cls.value attribute
71+
expect(clsSpan.attributes?.['browser.web_vital.cls.value']?.type).toBe('double');
72+
// Flakey value dependent on timings -> we check for a range
73+
const clsValue = clsSpan.attributes?.['browser.web_vital.cls.value']?.value as number;
74+
expect(clsValue).toBeGreaterThan(0.05);
75+
expect(clsValue).toBeLessThan(0.15);
76+
});
15.7 KB
Loading
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window._testBaseTimestamp = performance.timeOrigin / 1000;
5+
6+
Sentry.init({
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
9+
traceLifecycle: 'stream',
10+
tracesSampleRate: 1,
11+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<div id="content"></div>
8+
<img src="https://sentry-test-site.example/my/image.png" />
9+
</body>
10+
</html>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { Route } from '@playwright/test';
2+
import { expect } from '@playwright/test';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { hidePage, shouldSkipTracingTest } from '../../../../utils/helpers';
5+
import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
6+
7+
sentryTest.beforeEach(async ({ browserName, page }) => {
8+
if (shouldSkipTracingTest() || browserName !== 'chromium') {
9+
sentryTest.skip();
10+
}
11+
12+
await page.setViewportSize({ width: 800, height: 1200 });
13+
});
14+
15+
sentryTest('captures LCP as a streamed span with element attributes', async ({ getLocalTestUrl, page }) => {
16+
page.route('**', route => route.continue());
17+
page.route('**/my/image.png', async (route: Route) => {
18+
return route.fulfill({
19+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
20+
});
21+
});
22+
23+
const url = await getLocalTestUrl({ testDir: __dirname });
24+
25+
const lcpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.lcp');
26+
const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
27+
28+
await page.goto(url);
29+
30+
// Wait for LCP to be captured
31+
await page.waitForTimeout(1000);
32+
33+
await hidePage(page);
34+
35+
const lcpSpan = await lcpSpanPromise;
36+
const pageloadSpan = await pageloadSpanPromise;
37+
38+
expect(lcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.lcp' });
39+
expect(lcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.lcp' });
40+
expect(lcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
41+
expect(lcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));
42+
43+
// Check browser.web_vital.lcp.* attributes
44+
expect(lcpSpan.attributes?.['browser.web_vital.lcp.element']?.value).toEqual(expect.stringContaining('body > img'));
45+
expect(lcpSpan.attributes?.['browser.web_vital.lcp.url']?.value).toBe(
46+
'https://sentry-test-site.example/my/image.png',
47+
);
48+
expect(lcpSpan.attributes?.['browser.web_vital.lcp.size']?.value).toEqual(expect.any(Number));
49+
50+
// Check web vital value attribute
51+
expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.type).toMatch(/^(double)|(integer)$/);
52+
expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.value).toBeGreaterThan(0);
53+
54+
// Check pageload span id is present
55+
expect(lcpSpan.attributes?.['sentry.pageload.span_id']?.value).toBe(pageloadSpan.span_id);
56+
57+
// Span should have meaningful duration (navigation start -> LCP event)
58+
expect(lcpSpan.end_timestamp).toBeGreaterThan(lcpSpan.start_timestamp);
59+
60+
expect(lcpSpan.span_id).toMatch(/^[\da-f]{16}$/);
61+
expect(lcpSpan.trace_id).toMatch(/^[\da-f]{32}$/);
62+
63+
expect(lcpSpan.parent_span_id).toBe(pageloadSpan.span_id);
64+
expect(lcpSpan.trace_id).toBe(pageloadSpan.trace_id);
65+
});

packages/browser-utils/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export { elementTimingIntegration, startTrackingElementTiming } from './metrics/
2121

2222
export { extractNetworkProtocol } from './metrics/utils';
2323

24-
export { trackInpAsSpan } from './metrics/webVitalSpans';
24+
export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './metrics/webVitalSpans';
2525

2626
export { addClickKeypressInstrumentationHandler } from './instrument/dom';
2727

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

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -437,22 +437,22 @@ export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOption
437437
DEBUG_BUILD && debug.log('Setting web vital attribute', { [attrKey]: value }, 'on pageload span');
438438
};
439439
// for streamed pageload spans, we add the web vital measurements as attributes.
440-
// INP is tracked separately as a span.
441-
['ttfb', 'fp', 'fcp', 'cls', 'lcp'].forEach(measurementName => {
440+
// We omit LCP, CLS and INP because they're tracked separately as spans
441+
['ttfb', 'fp', 'fcp'].forEach(measurementName => {
442442
if (_measurements[measurementName]) {
443443
setAttr(measurementName, _measurements[measurementName].value);
444444
}
445445
});
446446
if (_measurements['ttfb.requestTime']) {
447447
setAttr('ttfb.requestTime', _measurements['ttfb.requestTime'].value, 'browser.web_vital.ttfb.request_time');
448448
}
449-
450-
_setStreamedWebVitalAttributes(span);
451449
} else {
450+
// If CLS is tracked as a span (span streaming), don't record CLS as a measurement
452451
if (!recordClsOnPageloadSpan) {
453452
delete _measurements.cls;
454453
}
455454

455+
// If LCP is tracked as a span (span streaming), don't record LCP as a measurement
456456
if (!recordLcpOnPageloadSpan) {
457457
delete _measurements.lcp;
458458
}
@@ -867,23 +867,6 @@ function _setWebVitalAttributes(span: Span, options: AddWebVitalsToSpanOptions):
867867
}
868868
}
869869

870-
function _setStreamedWebVitalAttributes(span: Span): void {
871-
if (_lcpEntry) {
872-
_lcpEntry.element && span.setAttribute('browser.web_vital.lcp.element', htmlTreeAsString(_lcpEntry.element));
873-
_lcpEntry.id && span.setAttribute('browser.web_vital.lcp.id', _lcpEntry.id);
874-
_lcpEntry.url && span.setAttribute('browser.web_vital.lcp.url', _lcpEntry.url.trim().slice(0, 200));
875-
_lcpEntry.loadTime != null && span.setAttribute('browser.web_vital.lcp.load_time', _lcpEntry.loadTime);
876-
_lcpEntry.renderTime != null && span.setAttribute('browser.web_vital.lcp.render_time', _lcpEntry.renderTime);
877-
_lcpEntry.size != null && span.setAttribute('browser.web_vital.lcp.size', _lcpEntry.size);
878-
}
879-
880-
if (_clsEntry?.sources) {
881-
_clsEntry.sources.forEach((source, index) =>
882-
span.setAttribute(`browser.web_vital.cls.source.${index + 1}`, htmlTreeAsString(source.node)),
883-
);
884-
}
885-
}
886-
887870
type ExperimentalResourceTimingProperty =
888871
| 'renderBlockingStatus'
889872
| 'deliveryType'

0 commit comments

Comments
 (0)