Skip to content

Commit 07c9744

Browse files
committed
feat(browser): Record standalone LCP spans
1 parent b615f61 commit 07c9744

File tree

7 files changed

+564
-11
lines changed

7 files changed

+564
-11
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
integrations: [
8+
Sentry.browserTracingIntegration({
9+
idleTimeout: 9000,
10+
_experiments: {
11+
enableStandaloneLcpSpans: true,
12+
},
13+
}),
14+
],
15+
tracesSampleRate: 1,
16+
debug: true,
17+
});
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: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
import type { Page, Route } from '@playwright/test';
2+
import { expect } from '@playwright/test';
3+
import type { Event as SentryEvent, EventEnvelope, SpanEnvelope } from '@sentry/core';
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import {
6+
getFirstSentryEnvelopeRequest,
7+
getMultipleSentryEnvelopeRequests,
8+
properFullEnvelopeRequestParser,
9+
shouldSkipTracingTest,
10+
} from '../../../../utils/helpers';
11+
12+
sentryTest.beforeEach(async ({ browserName, page }) => {
13+
if (shouldSkipTracingTest() || browserName !== 'chromium') {
14+
sentryTest.skip();
15+
}
16+
17+
await page.setViewportSize({ width: 800, height: 1200 });
18+
});
19+
20+
function hidePage(page: Page): Promise<void> {
21+
return page.evaluate(() => {
22+
window.dispatchEvent(new Event('pagehide'));
23+
});
24+
}
25+
26+
sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl, page }) => {
27+
const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
28+
page,
29+
1,
30+
{ envelopeType: 'span' },
31+
properFullEnvelopeRequestParser,
32+
);
33+
34+
page.route('**', route => route.continue());
35+
page.route('**/my/image.png', async (route: Route) => {
36+
return route.fulfill({
37+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
38+
});
39+
});
40+
41+
const url = await getLocalTestUrl({ testDir: __dirname });
42+
await page.goto(url);
43+
44+
// Wait for LCP to be captured
45+
await page.waitForTimeout(1000);
46+
47+
await hidePage(page);
48+
49+
const spanEnvelope = (await spanEnvelopePromise)[0];
50+
51+
const spanEnvelopeHeaders = spanEnvelope[0];
52+
const spanEnvelopeItem = spanEnvelope[1][0][1];
53+
54+
expect(spanEnvelopeItem).toEqual({
55+
data: {
56+
'sentry.exclusive_time': 0,
57+
'sentry.op': 'ui.webvital.lcp',
58+
'sentry.origin': 'auto.http.browser.lcp',
59+
transaction: expect.stringContaining('index.html'),
60+
'user_agent.original': expect.stringContaining('Chrome'),
61+
'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/),
62+
'lcp.element': 'body > img',
63+
'lcp.id': '',
64+
'lcp.loadTime': expect.any(Number),
65+
'lcp.renderTime': expect.any(Number),
66+
'lcp.size': expect.any(Number),
67+
'lcp.url': 'https://sentry-test-site.example/my/image.png',
68+
},
69+
description: expect.stringContaining('body > img'),
70+
exclusive_time: 0,
71+
measurements: {
72+
lcp: {
73+
unit: 'millisecond',
74+
value: expect.any(Number),
75+
},
76+
},
77+
op: 'ui.webvital.lcp',
78+
origin: 'auto.http.browser.lcp',
79+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
80+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
81+
segment_id: expect.stringMatching(/[a-f0-9]{16}/),
82+
start_timestamp: expect.any(Number),
83+
timestamp: spanEnvelopeItem.start_timestamp, // LCP is a point-in-time metric
84+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
85+
});
86+
87+
// LCP value should be greater than 0
88+
expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
89+
90+
expect(spanEnvelopeHeaders).toEqual({
91+
sent_at: expect.any(String),
92+
trace: {
93+
environment: 'production',
94+
public_key: 'public',
95+
sample_rate: '1',
96+
sampled: 'true',
97+
trace_id: spanEnvelopeItem.trace_id,
98+
sample_rand: expect.any(String),
99+
// no transaction, because span source is URL
100+
},
101+
});
102+
});
103+
104+
sentryTest('LCP span is linked to pageload transaction', async ({ getLocalTestUrl, page }) => {
105+
page.route('**', route => route.continue());
106+
page.route('**/my/image.png', async (route: Route) => {
107+
return route.fulfill({
108+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
109+
});
110+
});
111+
112+
const url = await getLocalTestUrl({ testDir: __dirname });
113+
114+
const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);
115+
116+
expect(eventData.type).toBe('transaction');
117+
expect(eventData.contexts?.trace?.op).toBe('pageload');
118+
119+
const pageloadSpanId = eventData.contexts?.trace?.span_id;
120+
const pageloadTraceId = eventData.contexts?.trace?.trace_id;
121+
122+
expect(pageloadSpanId).toMatch(/[a-f0-9]{16}/);
123+
expect(pageloadTraceId).toMatch(/[a-f0-9]{32}/);
124+
125+
const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
126+
page,
127+
1,
128+
{ envelopeType: 'span' },
129+
properFullEnvelopeRequestParser,
130+
);
131+
132+
// Wait for LCP to be captured
133+
await page.waitForTimeout(1000);
134+
135+
await hidePage(page);
136+
137+
const spanEnvelope = (await spanEnvelopePromise)[0];
138+
const spanEnvelopeItem = spanEnvelope[1][0][1];
139+
140+
// Ensure the LCP span is connected to the pageload span and trace
141+
expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadSpanId);
142+
expect(spanEnvelopeItem.trace_id).toEqual(pageloadTraceId);
143+
expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
144+
});
145+
146+
sentryTest('sends LCP of the initial page when soft-navigating to a new page', async ({ getLocalTestUrl, page }) => {
147+
page.route('**', route => route.continue());
148+
page.route('**/my/image.png', async (route: Route) => {
149+
return route.fulfill({
150+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
151+
});
152+
});
153+
154+
const url = await getLocalTestUrl({ testDir: __dirname });
155+
156+
const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);
157+
158+
expect(eventData.type).toBe('transaction');
159+
expect(eventData.contexts?.trace?.op).toBe('pageload');
160+
161+
const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
162+
page,
163+
1,
164+
{ envelopeType: 'span' },
165+
properFullEnvelopeRequestParser,
166+
);
167+
168+
// Wait for LCP to be captured
169+
await page.waitForTimeout(1000);
170+
171+
await page.goto(`${url}#soft-navigation`);
172+
173+
const spanEnvelope = (await spanEnvelopePromise)[0];
174+
const spanEnvelopeItem = spanEnvelope[1][0][1];
175+
176+
expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
177+
expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toMatch(/[a-f0-9]{16}/);
178+
});
179+
180+
sentryTest("doesn't send further LCP after the first navigation", async ({ getLocalTestUrl, page }) => {
181+
page.route('**', route => route.continue());
182+
page.route('**/my/image.png', async (route: Route) => {
183+
return route.fulfill({
184+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
185+
});
186+
});
187+
188+
const url = await getLocalTestUrl({ testDir: __dirname });
189+
190+
const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);
191+
192+
expect(eventData.type).toBe('transaction');
193+
expect(eventData.contexts?.trace?.op).toBe('pageload');
194+
195+
const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
196+
page,
197+
1,
198+
{ envelopeType: 'span' },
199+
properFullEnvelopeRequestParser,
200+
);
201+
202+
// Wait for LCP to be captured
203+
await page.waitForTimeout(1000);
204+
205+
await page.goto(`${url}#soft-navigation`);
206+
207+
const spanEnvelope = (await spanEnvelopePromise)[0];
208+
const spanEnvelopeItem = spanEnvelope[1][0][1];
209+
expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
210+
211+
getMultipleSentryEnvelopeRequests<SpanEnvelope>(page, 1, { envelopeType: 'span' }, () => {
212+
throw new Error('Unexpected span - This should not happen!');
213+
});
214+
215+
const navigationTxnPromise = getMultipleSentryEnvelopeRequests<EventEnvelope>(
216+
page,
217+
1,
218+
{ envelopeType: 'transaction' },
219+
properFullEnvelopeRequestParser,
220+
);
221+
222+
// activate both LCP emission triggers:
223+
await page.goto(`${url}#soft-navigation-2`);
224+
await hidePage(page);
225+
226+
// assumption: If we would send another LCP span on the 2nd navigation, it would be sent before the navigation
227+
// transaction ends. This isn't 100% safe to ensure we don't send something but otherwise we'd need to wait for
228+
// a timeout or something similar.
229+
await navigationTxnPromise;
230+
});
231+
232+
sentryTest("doesn't send further LCP after the first page hide", async ({ getLocalTestUrl, page }) => {
233+
page.route('**', route => route.continue());
234+
page.route('**/my/image.png', async (route: Route) => {
235+
return route.fulfill({
236+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
237+
});
238+
});
239+
240+
const url = await getLocalTestUrl({ testDir: __dirname });
241+
242+
const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);
243+
244+
expect(eventData.type).toBe('transaction');
245+
expect(eventData.contexts?.trace?.op).toBe('pageload');
246+
247+
const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
248+
page,
249+
1,
250+
{ envelopeType: 'span' },
251+
properFullEnvelopeRequestParser,
252+
);
253+
254+
// Wait for LCP to be captured
255+
await page.waitForTimeout(1000);
256+
257+
await hidePage(page);
258+
259+
const spanEnvelope = (await spanEnvelopePromise)[0];
260+
const spanEnvelopeItem = spanEnvelope[1][0][1];
261+
expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
262+
263+
getMultipleSentryEnvelopeRequests<SpanEnvelope>(page, 1, { envelopeType: 'span' }, () => {
264+
throw new Error('Unexpected span - This should not happen!');
265+
});
266+
267+
const navigationTxnPromise = getMultipleSentryEnvelopeRequests<EventEnvelope>(
268+
page,
269+
1,
270+
{ envelopeType: 'transaction' },
271+
properFullEnvelopeRequestParser,
272+
);
273+
274+
// activate both LCP emission triggers:
275+
await page.goto(`${url}#soft-navigation-2`);
276+
await hidePage(page);
277+
278+
// assumption: If we would send another LCP span on the 2nd navigation, it would be sent before the navigation
279+
// transaction ends. This isn't 100% safe to ensure we don't send something but otherwise we'd need to wait for
280+
// a timeout or something similar.
281+
await navigationTxnPromise;
282+
});
283+
284+
sentryTest('LCP span timestamps are set correctly', async ({ getLocalTestUrl, page }) => {
285+
page.route('**', route => route.continue());
286+
page.route('**/my/image.png', async (route: Route) => {
287+
return route.fulfill({
288+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
289+
});
290+
});
291+
292+
const url = await getLocalTestUrl({ testDir: __dirname });
293+
294+
const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);
295+
296+
expect(eventData.type).toBe('transaction');
297+
expect(eventData.contexts?.trace?.op).toBe('pageload');
298+
expect(eventData.timestamp).toBeDefined();
299+
300+
const pageloadEndTimestamp = eventData.timestamp!;
301+
302+
const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
303+
page,
304+
1,
305+
{ envelopeType: 'span' },
306+
properFullEnvelopeRequestParser,
307+
);
308+
309+
// Wait for LCP to be captured
310+
await page.waitForTimeout(1000);
311+
312+
await hidePage(page);
313+
314+
const spanEnvelope = (await spanEnvelopePromise)[0];
315+
const spanEnvelopeItem = spanEnvelope[1][0][1];
316+
317+
expect(spanEnvelopeItem.start_timestamp).toBeDefined();
318+
expect(spanEnvelopeItem.timestamp).toBeDefined();
319+
320+
const lcpSpanStartTimestamp = spanEnvelopeItem.start_timestamp!;
321+
const lcpSpanEndTimestamp = spanEnvelopeItem.timestamp!;
322+
323+
// LCP is a point-in-time metric ==> start and end timestamp should be the same
324+
expect(lcpSpanStartTimestamp).toEqual(lcpSpanEndTimestamp);
325+
326+
// We don't really care that they are very close together but rather about the order of magnitude
327+
// Previously, we had a bug where the timestamps would be significantly off (by multiple hours)
328+
// so we only ensure that this bug is fixed. 60 seconds should be more than enough.
329+
expect(lcpSpanStartTimestamp - pageloadEndTimestamp).toBeLessThan(60);
330+
});
331+
332+
sentryTest(
333+
'pageload transaction does not contain LCP measurement when standalone spans are enabled',
334+
async ({ getLocalTestUrl, page }) => {
335+
page.route('**', route => route.continue());
336+
page.route('**/my/image.png', async (route: Route) => {
337+
return route.fulfill({
338+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
339+
});
340+
});
341+
342+
const url = await getLocalTestUrl({ testDir: __dirname });
343+
const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);
344+
345+
expect(eventData.type).toBe('transaction');
346+
expect(eventData.contexts?.trace?.op).toBe('pageload');
347+
348+
// LCP measurement should NOT be present on the pageload transaction when standalone spans are enabled
349+
expect(eventData.measurements?.lcp).toBeUndefined();
350+
351+
// LCP attributes should also NOT be present on the pageload transaction when standalone spans are enabled
352+
// because the LCP data is sent as a standalone span instead
353+
expect(eventData.contexts?.trace?.data?.['lcp.element']).toBeUndefined();
354+
expect(eventData.contexts?.trace?.data?.['lcp.size']).toBeUndefined();
355+
},
356+
);

0 commit comments

Comments
 (0)