Skip to content

feat(browser): Record standalone LCP spans #16591

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://[email protected]/1337',
integrations: [
Sentry.browserTracingIntegration({
idleTimeout: 9000,
_experiments: {
enableStandaloneLcpSpans: true,
},
}),
],
tracesSampleRate: 1,
debug: true,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div id="content"></div>
<img src="https://sentry-test-site.example/my/image.png" />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
import type { Page, Route } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Event as SentryEvent, EventEnvelope, SpanEnvelope } from '@sentry/core';
import { sentryTest } from '../../../../utils/fixtures';
import {
getFirstSentryEnvelopeRequest,
getMultipleSentryEnvelopeRequests,
properFullEnvelopeRequestParser,
shouldSkipTracingTest,
} from '../../../../utils/helpers';

sentryTest.beforeEach(async ({ browserName, page }) => {
if (shouldSkipTracingTest() || browserName !== 'chromium') {
sentryTest.skip();
}

await page.setViewportSize({ width: 800, height: 1200 });
});

function hidePage(page: Page): Promise<void> {
return page.evaluate(() => {
window.dispatchEvent(new Event('pagehide'));
});
}

sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl, page }) => {
const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
page,
1,
{ envelopeType: 'span' },
properFullEnvelopeRequestParser,
);

page.route('**', route => route.continue());
page.route('**/my/image.png', async (route: Route) => {
return route.fulfill({
path: `${__dirname}/assets/sentry-logo-600x179.png`,
});
});

const url = await getLocalTestUrl({ testDir: __dirname });
await page.goto(url);

// Wait for LCP to be captured
await page.waitForTimeout(1000);

await hidePage(page);

const spanEnvelope = (await spanEnvelopePromise)[0];

const spanEnvelopeHeaders = spanEnvelope[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];

expect(spanEnvelopeItem).toEqual({
data: {
'sentry.exclusive_time': 0,
'sentry.op': 'ui.webvital.lcp',
'sentry.origin': 'auto.http.browser.lcp',
transaction: expect.stringContaining('index.html'),
'user_agent.original': expect.stringContaining('Chrome'),
'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/),
'lcp.element': 'body > img',
'lcp.id': '',
'lcp.loadTime': expect.any(Number),
'lcp.renderTime': expect.any(Number),
'lcp.size': expect.any(Number),
'lcp.url': 'https://sentry-test-site.example/my/image.png',
},
description: expect.stringContaining('body > img'),
exclusive_time: 0,
measurements: {
lcp: {
unit: 'millisecond',
value: expect.any(Number),
},
},
op: 'ui.webvital.lcp',
origin: 'auto.http.browser.lcp',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
segment_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
timestamp: spanEnvelopeItem.start_timestamp, // LCP is a point-in-time metric
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
});

// LCP value should be greater than 0
expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);

expect(spanEnvelopeHeaders).toEqual({
sent_at: expect.any(String),
trace: {
environment: 'production',
public_key: 'public',
sample_rate: '1',
sampled: 'true',
trace_id: spanEnvelopeItem.trace_id,
sample_rand: expect.any(String),
// no transaction, because span source is URL
},
});
});

sentryTest('LCP span is linked to pageload transaction', async ({ getLocalTestUrl, page }) => {
page.route('**', route => route.continue());
page.route('**/my/image.png', async (route: Route) => {
return route.fulfill({
path: `${__dirname}/assets/sentry-logo-600x179.png`,
});
});

const url = await getLocalTestUrl({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);

expect(eventData.type).toBe('transaction');
expect(eventData.contexts?.trace?.op).toBe('pageload');

const pageloadSpanId = eventData.contexts?.trace?.span_id;
const pageloadTraceId = eventData.contexts?.trace?.trace_id;

expect(pageloadSpanId).toMatch(/[a-f0-9]{16}/);
expect(pageloadTraceId).toMatch(/[a-f0-9]{32}/);

const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
page,
1,
{ envelopeType: 'span' },
properFullEnvelopeRequestParser,
);

// Wait for LCP to be captured
await page.waitForTimeout(1000);

await hidePage(page);

const spanEnvelope = (await spanEnvelopePromise)[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];

// Ensure the LCP span is connected to the pageload span and trace
expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadSpanId);
expect(spanEnvelopeItem.trace_id).toEqual(pageloadTraceId);
expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
});

sentryTest('sends LCP of the initial page when soft-navigating to a new page', async ({ getLocalTestUrl, page }) => {
page.route('**', route => route.continue());
page.route('**/my/image.png', async (route: Route) => {
return route.fulfill({
path: `${__dirname}/assets/sentry-logo-600x179.png`,
});
});

const url = await getLocalTestUrl({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);

expect(eventData.type).toBe('transaction');
expect(eventData.contexts?.trace?.op).toBe('pageload');

const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
page,
1,
{ envelopeType: 'span' },
properFullEnvelopeRequestParser,
);

// Wait for LCP to be captured
await page.waitForTimeout(1000);

await page.goto(`${url}#soft-navigation`);

const spanEnvelope = (await spanEnvelopePromise)[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];

expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toMatch(/[a-f0-9]{16}/);
});

sentryTest("doesn't send further LCP after the first navigation", async ({ getLocalTestUrl, page }) => {
page.route('**', route => route.continue());
page.route('**/my/image.png', async (route: Route) => {
return route.fulfill({
path: `${__dirname}/assets/sentry-logo-600x179.png`,
});
});

const url = await getLocalTestUrl({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);

expect(eventData.type).toBe('transaction');
expect(eventData.contexts?.trace?.op).toBe('pageload');

const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
page,
1,
{ envelopeType: 'span' },
properFullEnvelopeRequestParser,
);

// Wait for LCP to be captured
await page.waitForTimeout(1000);

await page.goto(`${url}#soft-navigation`);

const spanEnvelope = (await spanEnvelopePromise)[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];
expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);

getMultipleSentryEnvelopeRequests<SpanEnvelope>(page, 1, { envelopeType: 'span' }, () => {
throw new Error('Unexpected span - This should not happen!');
});

const navigationTxnPromise = getMultipleSentryEnvelopeRequests<EventEnvelope>(
page,
1,
{ envelopeType: 'transaction' },
properFullEnvelopeRequestParser,
);

// activate both LCP emission triggers:
await page.goto(`${url}#soft-navigation-2`);
await hidePage(page);

// assumption: If we would send another LCP span on the 2nd navigation, it would be sent before the navigation
// transaction ends. This isn't 100% safe to ensure we don't send something but otherwise we'd need to wait for
// a timeout or something similar.
await navigationTxnPromise;
});

sentryTest("doesn't send further LCP after the first page hide", async ({ getLocalTestUrl, page }) => {
page.route('**', route => route.continue());
page.route('**/my/image.png', async (route: Route) => {
return route.fulfill({
path: `${__dirname}/assets/sentry-logo-600x179.png`,
});
});

const url = await getLocalTestUrl({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);

expect(eventData.type).toBe('transaction');
expect(eventData.contexts?.trace?.op).toBe('pageload');

const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
page,
1,
{ envelopeType: 'span' },
properFullEnvelopeRequestParser,
);

// Wait for LCP to be captured
await page.waitForTimeout(1000);

await hidePage(page);

const spanEnvelope = (await spanEnvelopePromise)[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];
expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);

getMultipleSentryEnvelopeRequests<SpanEnvelope>(page, 1, { envelopeType: 'span' }, () => {
throw new Error('Unexpected span - This should not happen!');
});

const navigationTxnPromise = getMultipleSentryEnvelopeRequests<EventEnvelope>(
page,
1,
{ envelopeType: 'transaction' },
properFullEnvelopeRequestParser,
);

// activate both LCP emission triggers:
await page.goto(`${url}#soft-navigation-2`);
await hidePage(page);

// assumption: If we would send another LCP span on the 2nd navigation, it would be sent before the navigation
// transaction ends. This isn't 100% safe to ensure we don't send something but otherwise we'd need to wait for
// a timeout or something similar.
await navigationTxnPromise;
});

sentryTest('LCP span timestamps are set correctly', async ({ getLocalTestUrl, page }) => {
page.route('**', route => route.continue());
page.route('**/my/image.png', async (route: Route) => {
return route.fulfill({
path: `${__dirname}/assets/sentry-logo-600x179.png`,
});
});

const url = await getLocalTestUrl({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);

expect(eventData.type).toBe('transaction');
expect(eventData.contexts?.trace?.op).toBe('pageload');
expect(eventData.timestamp).toBeDefined();

const pageloadEndTimestamp = eventData.timestamp!;

const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
page,
1,
{ envelopeType: 'span' },
properFullEnvelopeRequestParser,
);

// Wait for LCP to be captured
await page.waitForTimeout(1000);

await hidePage(page);

const spanEnvelope = (await spanEnvelopePromise)[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];

expect(spanEnvelopeItem.start_timestamp).toBeDefined();
expect(spanEnvelopeItem.timestamp).toBeDefined();

const lcpSpanStartTimestamp = spanEnvelopeItem.start_timestamp!;
const lcpSpanEndTimestamp = spanEnvelopeItem.timestamp!;

// LCP is a point-in-time metric ==> start and end timestamp should be the same
expect(lcpSpanStartTimestamp).toEqual(lcpSpanEndTimestamp);

// We don't really care that they are very close together but rather about the order of magnitude
// Previously, we had a bug where the timestamps would be significantly off (by multiple hours)
// so we only ensure that this bug is fixed. 60 seconds should be more than enough.
expect(lcpSpanStartTimestamp - pageloadEndTimestamp).toBeLessThan(60);
});

sentryTest(
'pageload transaction does not contain LCP measurement when standalone spans are enabled',
async ({ getLocalTestUrl, page }) => {
page.route('**', route => route.continue());
page.route('**/my/image.png', async (route: Route) => {
return route.fulfill({
path: `${__dirname}/assets/sentry-logo-600x179.png`,
});
});

const url = await getLocalTestUrl({ testDir: __dirname });
const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);

expect(eventData.type).toBe('transaction');
expect(eventData.contexts?.trace?.op).toBe('pageload');

// LCP measurement should NOT be present on the pageload transaction when standalone spans are enabled
expect(eventData.measurements?.lcp).toBeUndefined();

// LCP attributes should also NOT be present on the pageload transaction when standalone spans are enabled
// because the LCP data is sent as a standalone span instead
expect(eventData.contexts?.trace?.data?.['lcp.element']).toBeUndefined();
expect(eventData.contexts?.trace?.data?.['lcp.size']).toBeUndefined();
},
);
Loading
Loading