diff --git a/.size-limit.js b/.size-limit.js index 4100751f2c40..1e37429bca0c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -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', diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js new file mode 100644 index 000000000000..bd3b6ed17872 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js @@ -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, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js new file mode 100644 index 000000000000..9742a4a5cc29 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js @@ -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')); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html new file mode 100644 index 000000000000..10e2e22f7d6a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html @@ -0,0 +1,10 @@ + + +
+ + + + +Some content
+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts new file mode 100644 index 000000000000..31ddd09977cb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts @@ -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
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts
new file mode 100644
index 000000000000..1f71cb8d76a7
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts
@@ -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);
+});
diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts
index 2b2d4b7f9397..888524ed7c21 100644
--- a/packages/browser-utils/src/index.ts
+++ b/packages/browser-utils/src/index.ts
@@ -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';
diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts
index 28d1f2bfaec8..39aeeb0aa460 100644
--- a/packages/browser-utils/src/metrics/browserMetrics.ts
+++ b/packages/browser-utils/src/metrics/browserMetrics.ts
@@ -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;
}
@@ -84,6 +94,7 @@ interface StartTrackingWebVitalsOptions {
* Start tracking web vitals.
* The callback returned by this function can be used to stop tracking & ensure all measurements are final & captured.
*
+ * @deprecated this function will be removed and streamlined once we stop supporting standalone v1
* @returns A function that forces web vitals collection
*/
export function startTrackingWebVitals({
@@ -97,13 +108,24 @@ export function startTrackingWebVitals({
if (performance.mark) {
WINDOW.performance.mark('sentry-tracing-init');
}
- const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : _trackLCP();
+
+ const lcpCleanupCallback = recordLcpStandaloneSpans
+ ? trackLcpAsStandaloneSpan(client)
+ : recordLcpStandaloneSpans === false
+ ? _trackLCP()
+ : undefined;
+
+ const clsCleanupCallback = recordClsStandaloneSpans
+ ? trackClsAsStandaloneSpan(client)
+ : recordClsStandaloneSpans === false
+ ? _trackCLS()
+ : undefined;
+
const ttfbCleanupCallback = _trackTtfb();
- const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : _trackCLS();
return (): void => {
- lcpCleanupCallback?.();
ttfbCleanupCallback();
+ lcpCleanupCallback?.();
clsCleanupCallback?.();
};
}
diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts
index 831565f07408..3eb0b2920a75 100644
--- a/packages/browser-utils/src/metrics/inp.ts
+++ b/packages/browser-utils/src/metrics/inp.ts
@@ -37,7 +37,7 @@ const ELEMENT_NAME_TIMESTAMP_MAP = new Map