Skip to content
Draft
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);
});
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}$/);
});
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">Hello World</div>
<button id="btn">Click me</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils';

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

sentryTest('captures FCP as a streamed span with duration from navigation start', async ({ getLocalTestUrl, page }) => {
const fcpSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => {
const spans = getSpansFromEnvelope(env);
return spans.some(s => getSpanOp(s) === 'ui.webvital.fcp');
});

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

const fcpEnvelope = await fcpSpanEnvelopePromise;
const fcpSpan = getSpansFromEnvelope(fcpEnvelope).find(s => getSpanOp(s) === 'ui.webvital.fcp')!;

expect(fcpSpan).toBeDefined();
expect(fcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.fcp' });
expect(fcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.fcp' });
expect(fcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
expect(fcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));
expect(fcpSpan.name).toBe('FCP');
expect(fcpSpan.span_id).toMatch(/^[\da-f]{16}$/);
expect(fcpSpan.trace_id).toMatch(/^[\da-f]{32}$/);

// Span should have meaningful duration (navigation start -> FCP event)
expect(fcpSpan.end_timestamp).toBeGreaterThan(fcpSpan.start_timestamp);
});

sentryTest('captures FP as a streamed span with duration from navigation start', async ({ getLocalTestUrl, page }) => {
const fpSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => {
const spans = getSpansFromEnvelope(env);
return spans.some(s => getSpanOp(s) === 'ui.webvital.fp');
});

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

const fpEnvelope = await fpSpanEnvelopePromise;
const fpSpan = getSpansFromEnvelope(fpEnvelope).find(s => getSpanOp(s) === 'ui.webvital.fp')!;

expect(fpSpan).toBeDefined();
expect(fpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.fp' });
expect(fpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.fp' });
expect(fpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
expect(fpSpan.name).toBe('FP');
expect(fpSpan.span_id).toMatch(/^[\da-f]{16}$/);

// Span should have meaningful duration (navigation start -> FP event)
expect(fpSpan.end_timestamp).toBeGreaterThan(fpSpan.start_timestamp);
});

sentryTest(
'captures TTFB as a streamed span with duration from navigation start',
async ({ getLocalTestUrl, page }) => {
const ttfbSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => {
const spans = getSpansFromEnvelope(env);
return spans.some(s => getSpanOp(s) === 'ui.webvital.ttfb');
});

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

const ttfbEnvelope = await ttfbSpanEnvelopePromise;
const ttfbSpan = getSpansFromEnvelope(ttfbEnvelope).find(s => getSpanOp(s) === 'ui.webvital.ttfb')!;

expect(ttfbSpan).toBeDefined();
expect(ttfbSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.ttfb' });
expect(ttfbSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.ttfb' });
expect(ttfbSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
expect(ttfbSpan.name).toBe('TTFB');
expect(ttfbSpan.span_id).toMatch(/^[\da-f]{16}$/);

// Span should have meaningful duration (navigation start -> first byte)
expect(ttfbSpan.end_timestamp).toBeGreaterThan(ttfbSpan.start_timestamp);
},
);
10 changes: 10 additions & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {
addTtfbInstrumentationHandler,
addLcpInstrumentationHandler,
addInpInstrumentationHandler,
addFcpInstrumentationHandler,
} from './metrics/instrument';

export {
Expand All @@ -20,6 +21,15 @@ export { startTrackingElementTiming } from './metrics/elementTiming';

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

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

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

export { addHistoryInstrumentationHandler } from './instrument/history';
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/metrics/inp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading