feat(browser): Emit web vitals as streamed spans#19827
feat(browser): Emit web vitals as streamed spans#19827logaretm wants to merge 14 commits intolms/feat-span-firstfrom
Conversation
size-limit report 📦
|
8bf8eaf to
c966a4a
Compare
1a5cfb3 to
28c0d45
Compare
Semver Impact of This PR🟡 Minor (new features) 📋 Changelog PreviewThis is how your changes will appear in the changelog. New Features ✨Browser
Deps
Nuxt
Other
Bug Fixes 🐛Ci
Other
Documentation 📚
Internal Changes 🔧Core
Deps
Deps Dev
Other
🤖 This preview updates automatically when you update the PR. |
c966a4a to
5963170
Compare
28c0d45 to
af2969e
Compare
There was a problem hiding this comment.
Pull request overview
Adds support for emitting certain Web Vitals as streamed (v2 pipeline) spans when traceLifecycle: 'stream' / span streaming is enabled, while keeping existing pageload measurements in place.
Changes:
- Gate standalone CLS/LCP spans off when span streaming is enabled, and wire up streamed LCP/CLS/INP emission from the browser tracing integration.
- Introduce
webVitalSpans.tshelpers + unit tests for emitting streamed Web Vital spans. - Add Playwright integration tests for streamed LCP and CLS spans; export
INP_ENTRY_MAP; add (currently-unused) FCP metric instrumentation.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/browser/src/tracing/browserTracingIntegration.ts | Enables streamed Web Vital span tracking when span streaming is enabled; disables standalone CLS/LCP in that mode |
| packages/browser-utils/src/metrics/webVitalSpans.ts | Implements streamed span emitters for LCP/CLS/INP |
| packages/browser-utils/test/metrics/webVitalSpans.test.ts | Unit tests for streamed web vital span emission helpers |
| packages/browser-utils/src/metrics/instrument.ts | Adds FCP metric instrumentation plumbing (fcp observer + handler) |
| packages/browser-utils/src/metrics/inp.ts | Exports INP_ENTRY_MAP for reuse by streamed INP span logic |
| packages/browser-utils/src/index.ts | Re-exports streamed web vital span trackers from browser-utils |
| dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts | Playwright test validating streamed LCP span + attributes |
| dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html | Test page for streamed LCP |
| dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js | Initializes SDK with span streaming enabled for LCP test |
| dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png | Asset used to trigger LCP |
| dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts | Playwright test validating streamed CLS span + attributes |
| dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html | Test page for streamed CLS |
| dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js | Simulates CLS for the CLS streamed span test |
| dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js | Initializes SDK with span streaming enabled for CLS test |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
d764f2e to
b80ddd4
Compare
a74fec7 to
7206304
Compare
...kages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts
Show resolved
Hide resolved
86d1929 to
4bf129b
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Redundant CLS/LCP tracking when streaming is enabled
- Added recordClsOnPageloadSpan and recordLcpOnPageloadSpan parameters to startTrackingWebVitals to prevent redundant handler registration when span streaming is enabled, eliminating the double-handler issue where one handler did throwaway work.
Or push these changes by commenting:
@cursor push d3ccbaa211
Preview (d3ccbaa211)
diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts
--- a/packages/browser-utils/src/metrics/browserMetrics.ts
+++ b/packages/browser-utils/src/metrics/browserMetrics.ts
@@ -77,6 +77,8 @@
interface StartTrackingWebVitalsOptions {
recordClsStandaloneSpans: boolean;
recordLcpStandaloneSpans: boolean;
+ recordClsOnPageloadSpan?: boolean;
+ recordLcpOnPageloadSpan?: boolean;
client: Client;
}
@@ -89,6 +91,8 @@
export function startTrackingWebVitals({
recordClsStandaloneSpans,
recordLcpStandaloneSpans,
+ recordClsOnPageloadSpan = true,
+ recordLcpOnPageloadSpan = true,
client,
}: StartTrackingWebVitalsOptions): () => void {
const performance = getBrowserPerformanceAPI();
@@ -97,10 +101,22 @@
if (performance.mark) {
WINDOW.performance.mark('sentry-tracing-init');
}
- const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : _trackLCP();
+ let lcpCleanupCallback: (() => void) | undefined;
+ if (recordLcpStandaloneSpans) {
+ trackLcpAsStandaloneSpan(client);
+ } else if (recordLcpOnPageloadSpan) {
+ lcpCleanupCallback = _trackLCP();
+ }
+
const ttfbCleanupCallback = _trackTtfb();
- const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : _trackCLS();
+ let clsCleanupCallback: (() => void) | undefined;
+ if (recordClsStandaloneSpans) {
+ trackClsAsStandaloneSpan(client);
+ } else if (recordClsOnPageloadSpan) {
+ clsCleanupCallback = _trackCLS();
+ }
+
return (): void => {
lcpCleanupCallback?.();
ttfbCleanupCallback();
diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts
--- a/packages/browser/src/tracing/browserTracingIntegration.ts
+++ b/packages/browser/src/tracing/browserTracingIntegration.ts
@@ -519,6 +519,8 @@
_collectWebVitals = startTrackingWebVitals({
recordClsStandaloneSpans: !spanStreamingEnabled && (enableStandaloneClsSpans || false),
recordLcpStandaloneSpans: !spanStreamingEnabled && (enableStandaloneLcpSpans || false),
+ recordClsOnPageloadSpan: !spanStreamingEnabled,
+ recordLcpOnPageloadSpan: !spanStreamingEnabled,
client,
});This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
5963170 to
de2e194
Compare
7adff97 to
22f6a55
Compare
…NTRY_MAP Add `addFcpInstrumentationHandler` using the existing `onFCP` web-vitals library integration, following the same pattern as the other metric handlers. Export `INP_ENTRY_MAP` from inp.ts for reuse in the new web vital spans module. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…is enabled Add non-standalone web vital spans that flow through the v2 span streaming pipeline (afterSpanEnd -> captureSpan -> SpanBuffer). Each web vital gets `browser.web_vital.<metric>.value` attributes and span events for measurement extraction. Spans have meaningful durations showing time from navigation start to the web vital event (except CLS which is a score, not a duration). New tracking functions: trackLcpAsSpan, trackClsAsSpan, trackInpAsSpan, trackTtfbAsSpan, trackFcpAsSpan, trackFpAsSpan — wired up in browserTracingIntegration.setup() when hasSpanStreamingEnabled(client). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add Playwright integration tests verifying CLS, LCP, FCP, FP, and TTFB are emitted as streamed spans with correct attributes, value attributes, and meaningful durations when span streaming is enabled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…dalone spans when streaming TTFB, FCP, and FP should remain as attributes on the pageload span rather than separate streamed spans. Also ensures standalone CLS/LCP spans are disabled when span streaming is enabled to prevent duplicate spans. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…an path The standalone INP handler filters out unrealistically long INP values (>60s) but the streamed span path was missing this sanity check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Gate standalone INP (`startTrackingINP`) behind `!spanStreamingEnabled` and gate streamed INP (`trackInpAsSpan`) behind `enableInp` so both paths respect the user's preference and don't produce duplicate data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove `addFcpInstrumentationHandler`, `instrumentFcp`, and `_previousFcp` which were added to support FCP streamed spans but are no longer called after FCP spans were removed from the implementation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…_sendInpSpan Use `|| 0` fallback instead of `as number` cast, consistent with the LCP and CLS span handlers that already guard against undefined. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…cpSpan Avoid calling browserPerformanceTimeOrigin() twice by caching the result in a local variable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nabled The streamed INP path does not use INTERACTIONS_SPAN_MAP or ELEMENT_NAME_TIMESTAMP_MAP, so registering the listeners is unnecessary overhead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When span streaming is enabled, CLS and LCP are emitted as streamed spans. Previously they were also recorded as measurements on the pageload span because the flags only checked enableStandaloneClsSpans and enableStandaloneLcpSpans, which default to undefined. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… handlers Export the constant from inp.ts and import it in webVitalSpans.ts to avoid the two definitions drifting apart. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Setup spanStreamingEnabled was declared in setup() but referenced in afterAllSetup(), a separate scope. Replace with inline hasSpanStreamingEnabled(client) call. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…enabled When span streaming handles CLS/LCP, `startTrackingWebVitals` no longer registers throwaway `_trackCLS()`/`_trackLCP()` handlers. Instead of adding a separate skip flag, the existing `recordClsStandaloneSpans` and `recordLcpStandaloneSpans` options now accept `undefined` to mean "skip entirely" — three states via two flags instead of three flags. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
22f6a55 to
1417584
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Missing INP integration/E2E test for streamed spans
- Added comprehensive integration test for INP streamed spans covering the full pipeline including instrumentation handler, filtering logic, and span attributes validation.
Or push these changes by commenting:
@cursor push 3b025c734e
Preview (3b025c734e)
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js
new file mode 100644
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js
@@ -1,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,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/subject.js
new file mode 100644
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/subject.js
@@ -1,0 +1,18 @@
+const blockUI =
+ (delay = 100) =>
+ 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=inp-button]').addEventListener('click', blockUI(100));
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/template.html
new file mode 100644
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/template.html
@@ -1,0 +1,10 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <div id="content"></div>
+ <button data-test-id="inp-button" data-sentry-element="InpButton">Click Me</button>
+ </body>
+</html>
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts
new file mode 100644
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts
@@ -1,0 +1,60 @@
+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 hidePage(page: Page): Promise<void> {
+ return page.evaluate(() => {
+ window.dispatchEvent(new Event('pagehide'));
+ });
+}
+
+sentryTest('captures INP as a streamed span with interaction attributes', async ({ getLocalTestUrl, page }) => {
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const inpSpanPromise = waitForStreamedSpan(page, span => {
+ const op = getSpanOp(span);
+ return op === 'ui.interaction.click';
+ });
+
+ await page.goto(url);
+
+ await page.locator('[data-test-id=inp-button]').click();
+ await page.locator('.clicked[data-test-id=inp-button]').isVisible();
+
+ await page.waitForTimeout(500);
+
+ await hidePage(page);
+
+ const inpSpan = await inpSpanPromise;
+
+ 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'));
+
+ // Check INP value attribute
+ expect(inpSpan.attributes?.['browser.web_vital.inp.value']?.type).toBe('double');
+ expect(inpSpan.attributes?.['browser.web_vital.inp.value']?.value).toBeGreaterThan(0);
+
+ // Check exclusive time matches the interaction duration
+ expect(inpSpan.attributes?.['sentry.exclusive_time']?.type).toBe('double');
+ expect(inpSpan.attributes?.['sentry.exclusive_time']?.value).toBeGreaterThan(0);
+
+ // INP span should have meaningful duration (interaction start -> end)
+ expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp);
+
+ expect(inpSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(inpSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+
+ // Check that the span name contains the element
+ expect(inpSpan.name).toContain('InpButton');
+});This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
| }; | ||
|
|
||
| addInpInstrumentationHandler(onInp); | ||
| } |
There was a problem hiding this comment.
Missing INP integration/E2E test for streamed spans
Low Severity
This is a feat PR that introduces streamed span emission for LCP, CLS, and INP web vitals. Integration tests exist for CLS (web-vitals-cls-streamed-spans) and LCP (web-vitals-lcp-streamed-spans), but there is no integration or E2E test covering the trackInpAsSpan / _sendInpSpan streamed path. Per review rules, newly added behavior (especially a distinct code path like INP streaming) benefits from integration test coverage to verify the full pipeline end-to-end.
Triggered by project rule: PR Review Guidelines for Cursor Bot



Summary
Closes #17931
When span streaming is enabled (
traceLifecycle: 'stream'), emit web vital values as non-standalone spans that flow through the v2 pipeline (afterSpanEnd→captureSpan()→SpanBuffer).Only LCP, CLS, and INP are emitted as streamed spans — TTFB, FCP, and FP remain as attributes on the pageload span. When span streaming is enabled, standalone v1 CLS/LCP spans are automatically disabled to prevent duplicates.
Each web vital span carries
browser.web_vital.*attributes per sentry-conventions PRs 229, 233-235:browser.web_vital.lcp.{value,element,id,url,size,load_time,render_time}browser.web_vital.cls.{value,source.<N>}browser.web_vital.inp.value(with MAX_PLAUSIBLE_INP_DURATION sanity check)Spans have meaningful durations (navigation start → event time) instead of being point-in-time, except CLS which is a score.
Changes
hasSpanStreamingEnabled(client)is true!spanStreamingEnabled && enableStandaloneClsSpans)MAX_PLAUSIBLE_INP_DURATION(60s) sanity check to streamed INP path, matching the existing standalone handler🤖 Generated with Claude Code