Skip to content

Commit ce4aa43

Browse files
logaretmclaude
andcommitted
test(browser): Add integration tests for streamed web vital spans
Add Playwright integration tests verifying CLS, LCP, FCP, FP, and TTFB are emitted as streamed spans with correct attributes, value attributes, and meaningful durations when span streaming is enabled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b82e753 commit ce4aa43

File tree

11 files changed

+307
-0
lines changed

11 files changed

+307
-0
lines changed
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({ idleTimeout: 9000 }), 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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { Page } from '@playwright/test';
2+
import { expect } from '@playwright/test';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
5+
import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
6+
7+
sentryTest.beforeEach(async ({ browserName, page }) => {
8+
if (shouldSkipTracingTest() || testingCdnBundle() || 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+
function hidePage(page: Page): Promise<void> {
24+
return page.evaluate(() => {
25+
window.dispatchEvent(new Event('pagehide'));
26+
});
27+
}
28+
29+
sentryTest('captures CLS as a streamed span with source attributes', async ({ getLocalTestUrl, page }) => {
30+
const url = await getLocalTestUrl({ testDir: __dirname });
31+
32+
const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls');
33+
34+
await page.goto(`${url}#0.15`);
35+
await waitForLayoutShift(page);
36+
await hidePage(page);
37+
38+
const clsSpan = await clsSpanPromise;
39+
40+
expect(clsSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.cls' });
41+
expect(clsSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.cls' });
42+
expect(clsSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
43+
expect(clsSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));
44+
45+
// Check browser.web_vital.cls.source attributes
46+
expect(clsSpan.attributes?.['browser.web_vital.cls.source.1']?.value).toEqual(
47+
expect.stringContaining('body > div#content > p'),
48+
);
49+
50+
// Check pageload span id is present
51+
expect(clsSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/);
52+
53+
// CLS is a point-in-time metric
54+
expect(clsSpan.start_timestamp).toEqual(clsSpan.end_timestamp);
55+
56+
expect(clsSpan.span_id).toMatch(/^[\da-f]{16}$/);
57+
expect(clsSpan.trace_id).toMatch(/^[\da-f]{32}$/);
58+
});
59+
60+
sentryTest('CLS streamed span has web vital value attribute', async ({ getLocalTestUrl, page }) => {
61+
const url = await getLocalTestUrl({ testDir: __dirname });
62+
63+
const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls');
64+
65+
await page.goto(`${url}#0.1`);
66+
await waitForLayoutShift(page);
67+
await hidePage(page);
68+
69+
const clsSpan = await clsSpanPromise;
70+
71+
// The CLS value should be set as a browser.web_vital.cls.value attribute
72+
expect(clsSpan.attributes?.['browser.web_vital.cls.value']?.type).toBe('double');
73+
// Flakey value dependent on timings -> we check for a range
74+
const clsValue = clsSpan.attributes?.['browser.web_vital.cls.value']?.value as number;
75+
expect(clsValue).toBeGreaterThan(0.05);
76+
expect(clsValue).toBeLessThan(0.15);
77+
});
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({ idleTimeout: 9000 }), 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: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { Page, Route } from '@playwright/test';
2+
import { expect } from '@playwright/test';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
5+
import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
6+
7+
sentryTest.beforeEach(async ({ browserName, page }) => {
8+
if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') {
9+
sentryTest.skip();
10+
}
11+
12+
await page.setViewportSize({ width: 800, height: 1200 });
13+
});
14+
15+
function hidePage(page: Page): Promise<void> {
16+
return page.evaluate(() => {
17+
window.dispatchEvent(new Event('pagehide'));
18+
});
19+
}
20+
21+
sentryTest('captures LCP as a streamed span with element attributes', async ({ getLocalTestUrl, page }) => {
22+
page.route('**', route => route.continue());
23+
page.route('**/my/image.png', async (route: Route) => {
24+
return route.fulfill({
25+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
26+
});
27+
});
28+
29+
const url = await getLocalTestUrl({ testDir: __dirname });
30+
31+
const lcpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.lcp');
32+
33+
await page.goto(url);
34+
35+
// Wait for LCP to be captured
36+
await page.waitForTimeout(1000);
37+
38+
await hidePage(page);
39+
40+
const lcpSpan = await lcpSpanPromise;
41+
42+
expect(lcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.lcp' });
43+
expect(lcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.lcp' });
44+
expect(lcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
45+
expect(lcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));
46+
47+
// Check browser.web_vital.lcp.* attributes
48+
expect(lcpSpan.attributes?.['browser.web_vital.lcp.element']?.value).toEqual(expect.stringContaining('body > img'));
49+
expect(lcpSpan.attributes?.['browser.web_vital.lcp.url']?.value).toBe(
50+
'https://sentry-test-site.example/my/image.png',
51+
);
52+
expect(lcpSpan.attributes?.['browser.web_vital.lcp.size']?.value).toEqual(expect.any(Number));
53+
54+
// Check web vital value attribute
55+
expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.type).toBe('double');
56+
expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.value).toBeGreaterThan(0);
57+
58+
// Check pageload span id is present
59+
expect(lcpSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/);
60+
61+
// Span should have meaningful duration (navigation start -> LCP event)
62+
expect(lcpSpan.end_timestamp).toBeGreaterThan(lcpSpan.start_timestamp);
63+
64+
expect(lcpSpan.span_id).toMatch(/^[\da-f]{16}$/);
65+
expect(lcpSpan.trace_id).toMatch(/^[\da-f]{32}$/);
66+
});
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">Hello World</div>
8+
<button id="btn">Click me</button>
9+
</body>
10+
</html>

0 commit comments

Comments
 (0)