Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window._testBaseTimestamp = performance.timeOrigin / 1000;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [Sentry.browserTracingIntegration({ idleTimeout: 9000 }), Sentry.spanStreamingIntegration()],
traceLifecycle: 'stream',
tracesSampleRate: 1,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { simulateCLS } from '../../../../utils/web-vitals/cls.ts';

// Simulate Layout shift right at the beginning of the page load, depending on the URL hash
// don't run if expected CLS is NaN
const expectedCLS = Number(location.hash.slice(1));
if (expectedCLS && expectedCLS >= 0) {
simulateCLS(expectedCLS).then(() => window.dispatchEvent(new Event('cls-done')));
}

// Simulate layout shift whenever the trigger-cls event is dispatched
// Cannot trigger via a button click because expected layout shift after
// an interaction doesn't contribute to CLS.
window.addEventListener('trigger-cls', () => {
simulateCLS(0.1).then(() => {
window.dispatchEvent(new Event('cls-done'));
});
});
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>
<p>Some content</p>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';

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

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

function waitForLayoutShift(page: Page): Promise<void> {
return page.evaluate(() => {
return new Promise(resolve => {
window.addEventListener('cls-done', () => resolve());
});
});
}

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

sentryTest('captures CLS as a streamed span with source attributes', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls');

await page.goto(`${url}#0.15`);
await waitForLayoutShift(page);
await hidePage(page);

const clsSpan = await clsSpanPromise;

expect(clsSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.cls' });
expect(clsSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.cls' });
expect(clsSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
expect(clsSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));

// Check browser.web_vital.cls.source attributes
expect(clsSpan.attributes?.['browser.web_vital.cls.source.1']?.value).toEqual(
expect.stringContaining('body > div#content > p'),
);

// Check pageload span id is present
expect(clsSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/);

// CLS is a point-in-time metric
expect(clsSpan.start_timestamp).toEqual(clsSpan.end_timestamp);

expect(clsSpan.span_id).toMatch(/^[\da-f]{16}$/);
expect(clsSpan.trace_id).toMatch(/^[\da-f]{32}$/);
});

sentryTest('CLS streamed span has web vital value attribute', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls');

await page.goto(`${url}#0.1`);
await waitForLayoutShift(page);
await hidePage(page);

const clsSpan = await clsSpanPromise;

// The CLS value should be set as a browser.web_vital.cls.value attribute
expect(clsSpan.attributes?.['browser.web_vital.cls.value']?.type).toBe('double');
// Flakey value dependent on timings -> we check for a range
const clsValue = clsSpan.attributes?.['browser.web_vital.cls.value']?.value as number;
expect(clsValue).toBeGreaterThan(0.05);
expect(clsValue).toBeLessThan(0.15);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing INP streamed span E2E test coverage

Low Severity

This feat PR adds three new streamed span code paths (LCP, CLS, INP), but only LCP and CLS have E2E/integration tests. The INP streaming path in trackInpAsSpan / _sendInpSpan — which has its own distinct logic (no listenForWebVitalReportEvents, no pageloadSpanId, no registerInpInteractionListener fallback for element names) — only has unit test coverage. Adding an E2E test for the INP streaming path would help catch integration issues between addInpInstrumentationHandler and the _emitWebVitalSpan pipeline.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

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,11 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window._testBaseTimestamp = performance.timeOrigin / 1000;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [Sentry.browserTracingIntegration({ idleTimeout: 9000 }), Sentry.spanStreamingIntegration()],
traceLifecycle: 'stream',
tracesSampleRate: 1,
});
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,66 @@
import type { Page, Route } from '@playwright/test';
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';

sentryTest.beforeEach(async ({ browserName, page }) => {
if (shouldSkipTracingTest() || testingCdnBundle() || 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 as a streamed span with element attributes', 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 lcpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.lcp');

await page.goto(url);

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

await hidePage(page);

const lcpSpan = await lcpSpanPromise;

expect(lcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.lcp' });
expect(lcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.lcp' });
expect(lcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
expect(lcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));

// Check browser.web_vital.lcp.* attributes
expect(lcpSpan.attributes?.['browser.web_vital.lcp.element']?.value).toEqual(expect.stringContaining('body > img'));
expect(lcpSpan.attributes?.['browser.web_vital.lcp.url']?.value).toBe(
'https://sentry-test-site.example/my/image.png',
);
expect(lcpSpan.attributes?.['browser.web_vital.lcp.size']?.value).toEqual(expect.any(Number));

// Check web vital value attribute
expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.type).toBe('double');
expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.value).toBeGreaterThan(0);

// Check pageload span id is present
expect(lcpSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/);

// Span should have meaningful duration (navigation start -> LCP event)
expect(lcpSpan.end_timestamp).toBeGreaterThan(lcpSpan.start_timestamp);

expect(lcpSpan.span_id).toMatch(/^[\da-f]{16}$/);
expect(lcpSpan.trace_id).toMatch(/^[\da-f]{32}$/);
});
2 changes: 2 additions & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export { startTrackingElementTiming } from './metrics/elementTiming';

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

export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './metrics/webVitalSpans';

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

export { addHistoryInstrumentationHandler } from './instrument/history';
Expand Down
31 changes: 27 additions & 4 deletions packages/browser-utils/src/metrics/browserMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,18 @@ let _lcpEntry: LargestContentfulPaint | undefined;
let _clsEntry: LayoutShift | undefined;

interface StartTrackingWebVitalsOptions {
recordClsStandaloneSpans: boolean;
recordLcpStandaloneSpans: boolean;
/**
* When `true`, CLS is tracked as a standalone span. When `false`, CLS is
* recorded as a measurement on the pageload span. When `undefined`, CLS
* tracking is skipped entirely (e.g. because span streaming handles it).
*/
recordClsStandaloneSpans: boolean | undefined;
/**
* When `true`, LCP is tracked as a standalone span. When `false`, LCP is
* recorded as a measurement on the pageload span. When `undefined`, LCP
* tracking is skipped entirely (e.g. because span streaming handles it).
*/
recordLcpStandaloneSpans: boolean | undefined;
client: Client;
}

Expand All @@ -97,9 +107,22 @@ export function startTrackingWebVitals({
if (performance.mark) {
WINDOW.performance.mark('sentry-tracing-init');
}
const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : _trackLCP();

const lcpCleanupCallback =
recordLcpStandaloneSpans === true
? trackLcpAsStandaloneSpan(client)
: recordLcpStandaloneSpans === false
? _trackLCP()
: undefined;

const clsCleanupCallback =
recordClsStandaloneSpans === true
? trackClsAsStandaloneSpan(client)
: recordClsStandaloneSpans === false
? _trackCLS()
: undefined;

const ttfbCleanupCallback = _trackTtfb();
const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : _trackCLS();

return (): void => {
lcpCleanupCallback?.();
Expand Down
4 changes: 2 additions & 2 deletions packages/browser-utils/src/metrics/inp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const ELEMENT_NAME_TIMESTAMP_MAP = new Map<number, string>();
* 60 seconds is the maximum for a plausible INP value
* (source: Me)
*/
const MAX_PLAUSIBLE_INP_DURATION = 60;
export const MAX_PLAUSIBLE_INP_DURATION = 60;
/**
* Start tracking INP webvital events.
*/
Expand All @@ -54,7 +54,7 @@ export function startTrackingINP(): () => void {
return () => undefined;
}

const INP_ENTRY_MAP: Record<string, 'click' | 'hover' | 'drag' | 'press'> = {
export const INP_ENTRY_MAP: Record<string, 'click' | 'hover' | 'drag' | 'press'> = {
click: 'click',
pointerdown: 'click',
pointerup: 'click',
Expand Down
1 change: 0 additions & 1 deletion packages/browser-utils/src/metrics/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ let _previousCls: Metric | undefined;
let _previousLcp: Metric | undefined;
let _previousTtfb: Metric | undefined;
let _previousInp: Metric | undefined;

/**
* Add a callback that will be triggered when a CLS metric is available.
* Returns a cleanup callback which can be called to remove the instrumentation handler.
Expand Down
Loading
Loading