Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
597d120
feat(browser-utils): Add FCP instrumentation handler and export INP_E…
logaretm Mar 16, 2026
ff68317
feat(browser): Emit web vitals as streamed spans when span streaming …
logaretm Mar 16, 2026
03f912b
test(browser): Add integration tests for streamed web vital spans
logaretm Mar 16, 2026
bf8a48f
fix(browser): Only emit LCP, CLS, INP as streamed spans; disable stan…
logaretm Mar 23, 2026
e9a7881
fix(browser): Add MAX_PLAUSIBLE_INP_DURATION check to streamed INP sp…
logaretm Mar 23, 2026
3028afc
fix(browser): Prevent duplicate INP spans when span streaming is enabled
logaretm Mar 23, 2026
0009ee7
fix(browser-utils): Remove dead FCP instrumentation code
logaretm Mar 23, 2026
69a8add
fix(browser-utils): Add fallback for browserPerformanceTimeOrigin in …
logaretm Mar 23, 2026
dad3db4
fix(browser-utils): Cache browserPerformanceTimeOrigin call in _sendL…
logaretm Mar 23, 2026
b4ffde3
fix(browser): Skip INP interaction listeners when span streaming is e…
logaretm Mar 24, 2026
a52c543
fix(browser): Skip CLS/LCP measurements on pageload span when streaming
logaretm Mar 24, 2026
fa7b1d4
refactor(browser-utils): Share MAX_PLAUSIBLE_INP_DURATION between INP…
logaretm Mar 24, 2026
aa40c14
fix(browser): Fix ReferenceError for spanStreamingEnabled in afterAll…
logaretm Mar 24, 2026
43438d6
fix(browser): Skip redundant CLS/LCP handlers when span streaming is …
logaretm Mar 24, 2026
1695674
send wv spans as child spans of pageload/inp root span
Lms24 Apr 14, 2026
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
7 changes: 7 additions & 0 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ module.exports = [
gzip: true,
limit: '43 KB',
},
{
name: '@sentry/browser (incl. Tracing + Span Streaming)',
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'),
gzip: true,
limit: '48 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
path: 'packages/browser/build/npm/esm/prod/index.js',
Expand Down
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(), 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,76 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
import { hidePage, 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());
});
});
}

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');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

l: can we also wait on the pageload span and assert that both have the same traceId? same for the LCP test

const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');

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

const clsSpan = await clsSpanPromise;
const pageloadSpan = await pageloadSpanPromise;

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).toBe(pageloadSpan.span_id);

// 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}$/);

expect(clsSpan.parent_span_id).toBe(pageloadSpan.span_id);
expect(clsSpan.trace_id).toBe(pageloadSpan.trace_id);
});

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);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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: 4000 }),
Sentry.spanStreamingIntegration(),
],
tracesSampleRate: 1,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const blockUI =
(delay = 70) =>
e => {
const startTime = Date.now();

function getElapsed() {
const time = Date.now();
return time - startTime;
}

while (getElapsed() < delay) {
//
}

e.target.classList.add('clicked');
};

document.querySelector('[data-test-id=slow-button]').addEventListener('click', blockUI(450));
document.querySelector('[data-test-id=normal-button]').addEventListener('click', blockUI());
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button data-test-id="slow-button" data-sentry-element="SlowButton">Slow</button>
<button data-test-id="normal-button" data-sentry-element="NormalButton">Click Me</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';

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

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

const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
const inpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.interaction.click');

await page.goto(url);

await page.locator('[data-test-id=normal-button]').click();
await page.locator('.clicked[data-test-id=normal-button]').isVisible();

await page.waitForTimeout(500);

await hidePage(page);

const inpSpan = await inpSpanPromise;
const pageloadSpan = await pageloadSpanPromise;

expect(inpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.interaction.click' });
expect(inpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.inp' });
expect(inpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));

const inpValue = inpSpan.attributes?.['browser.web_vital.inp.value']?.value as number;
expect(inpValue).toBeGreaterThan(0);

expect(inpSpan.attributes?.['sentry.exclusive_time']?.value).toBeGreaterThan(0);

expect(inpSpan.name).toBe('body > NormalButton');

expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp);

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

expect(inpSpan.parent_span_id).toBe(pageloadSpan.span_id);
expect(inpSpan.trace_id).toBe(pageloadSpan.trace_id);
});

sentryTest('captures the slowest interaction as streamed INP span', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

await page.goto(url);

await page.locator('[data-test-id=normal-button]').click();
await page.locator('.clicked[data-test-id=normal-button]').isVisible();

await page.waitForTimeout(500);

const inpSpanPromise = waitForStreamedSpan(page, span => {
const op = getSpanOp(span);
return op === 'ui.interaction.click';
});

await page.locator('[data-test-id=slow-button]').click();
await page.locator('.clicked[data-test-id=slow-button]').isVisible();

await page.waitForTimeout(500);

await hidePage(page);

const inpSpan = await inpSpanPromise;

expect(inpSpan.name).toBe('body > SlowButton');
expect(inpSpan.attributes?.['sentry.exclusive_time']?.value).toBeGreaterThan(400);

const inpValue = inpSpan.attributes?.['browser.web_vital.inp.value']?.value as number;
expect(inpValue).toBeGreaterThan(400);
});
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(), 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,65 @@
import type { Route } from '@playwright/test';
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
import { hidePage, 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 });
});

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');
const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');

await page.goto(url);

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

await hidePage(page);

const lcpSpan = await lcpSpanPromise;
const pageloadSpan = await pageloadSpanPromise;

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).toMatch(/^(double)|(integer)$/);
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).toBe(pageloadSpan.span_id);

// 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}$/);

expect(lcpSpan.parent_span_id).toBe(pageloadSpan.span_id);
expect(lcpSpan.trace_id).toBe(pageloadSpan.trace_id);
});
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 { elementTimingIntegration, startTrackingElementTiming } from './metrics/

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

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

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

export { addHistoryInstrumentationHandler } from './instrument/history';
Expand Down
Loading
Loading