Skip to content

feat(browser): Emit web vitals as streamed spans#19827

Open
logaretm wants to merge 15 commits intodevelopfrom
awad/js-17931-webvitals-v2-spans
Open

feat(browser): Emit web vitals as streamed spans#19827
logaretm wants to merge 15 commits intodevelopfrom
awad/js-17931-webvitals-v2-spans

Conversation

@logaretm
Copy link
Copy Markdown
Member

@logaretm logaretm commented Mar 16, 2026

Summary

Closes #17931

When span streaming is enabled (traceLifecycle: 'stream'), emit web vital values as non-standalone spans that flow through the v2 pipeline (afterSpanEndcaptureSpan()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:

  • LCP: browser.web_vital.lcp.{value,element,id,url,size,load_time,render_time}
  • CLS: browser.web_vital.cls.{value,source.<N>}
  • INP: 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

  • Emit LCP, CLS, INP as streamed spans when hasSpanStreamingEnabled(client) is true
  • Disable standalone CLS/LCP spans when span streaming is enabled (!spanStreamingEnabled && enableStandaloneClsSpans)
  • Add MAX_PLAUSIBLE_INP_DURATION (60s) sanity check to streamed INP path, matching the existing standalone handler
  • TTFB, FCP, FP are not emitted as spans — they stay as pageload span attributes

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 16, 2026

size-limit report 📦

Path Size % Change Change
@sentry/browser 25.73 kB - -
@sentry/browser - with treeshaking flags 24.22 kB - -
⛔️ @sentry/browser (incl. Tracing) (max: 43 kB) 43.42 kB +1.64% +699 B 🔺
⛔️ @sentry/browser (incl. Tracing, Profiling) (max: 48 kB) 48.26 kB +1.94% +918 B 🔺
⛔️ @sentry/browser (incl. Tracing, Replay) (max: 82 kB) 82.42 kB +1.09% +888 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 71.96 kB +1.22% +862 B 🔺
⛔️ @sentry/browser (incl. Tracing, Replay with Canvas) (max: 87 kB) 87.11 kB +1.02% +879 B 🔺
⛔️ @sentry/browser (incl. Tracing, Replay, Feedback) (max: 99 kB) 99.34 kB +0.92% +902 B 🔺
@sentry/browser (incl. Feedback) 42.52 kB - -
@sentry/browser (incl. sendFeedback) 30.39 kB - -
@sentry/browser (incl. FeedbackAsync) 35.39 kB - -
@sentry/browser (incl. Metrics) 27.04 kB - -
@sentry/browser (incl. Logs) 27.19 kB - -
@sentry/browser (incl. Metrics & Logs) 27.86 kB - -
@sentry/react 27.48 kB - -
@sentry/react (incl. Tracing) 45.68 kB +1.4% +630 B 🔺
@sentry/vue 30.56 kB - -
⛔️ @sentry/vue (incl. Tracing) (max: 45 kB) 45.24 kB +1.49% +660 B 🔺
@sentry/svelte 25.75 kB - -
CDN Bundle 28.41 kB - -
⛔️ CDN Bundle (incl. Tracing) (max: 44 kB) 44.53 kB +1.74% +761 B 🔺
CDN Bundle (incl. Logs, Metrics) 29.79 kB - -
⛔️ CDN Bundle (incl. Tracing, Logs, Metrics) (max: 45 kB) 45.63 kB +1.75% +784 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 68.6 kB - -
⛔️ CDN Bundle (incl. Tracing, Replay) (max: 81 kB) 81.39 kB +0.93% +743 B 🔺
⛔️ CDN Bundle (incl. Tracing, Replay, Logs, Metrics) (max: 82 kB) 82.46 kB +0.96% +781 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 86.94 kB +0.9% +767 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 88 kB +0.91% +793 B 🔺
CDN Bundle - uncompressed 83 kB - -
⛔️ CDN Bundle (incl. Tracing) - uncompressed (max: 130 kB) 132.85 kB +2.34% +3.03 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 87.14 kB - -
⛔️ CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed (max: 134 kB) 136.26 kB +2.28% +3.03 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 210.13 kB - -
⛔️ CDN Bundle (incl. Tracing, Replay) - uncompressed (max: 247 kB) 249.7 kB +1.22% +3 kB 🔺
⛔️ CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed (max: 251 kB) 253.1 kB +1.2% +3 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 262.61 kB +1.16% +3 kB 🔺
⛔️ CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed (max: 264 kB) 266 kB +1.15% +3 kB 🔺
⛔️ @sentry/nextjs (client) (max: 48 kB) 48.18 kB +1.49% +704 B 🔺
@sentry/sveltekit (client) 43.84 kB +1.5% +646 B 🔺
@sentry/node-core 57.86 kB +0.02% +6 B 🔺
@sentry/node 174.81 kB +0.01% +10 B 🔺
@sentry/node - without tracing 97.63 kB +0.01% +9 B 🔺
@sentry/aws-serverless 114.98 kB +0.01% +6 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 45.04 kB added added

View base workflow run

@Lms24 Lms24 force-pushed the lms/feat-span-first branch from 8bf8eaf to c966a4a Compare March 18, 2026 17:14
@logaretm logaretm force-pushed the awad/js-17931-webvitals-v2-spans branch 2 times, most recently from 1a5cfb3 to 28c0d45 Compare March 18, 2026 18:28
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 18, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

Cloudflare

  • Split alarms into multiple traces and link them by JPeer264 in #19373
  • Propagate traceparent to RPC calls - via fetch by JPeer264 in #19991

Core

  • Automatically disable truncation when span streaming is enabled in LangGraph integration by andreiborza in #20231
  • Automatically disable truncation when span streaming is enabled in LangChain integration by andreiborza in #20230
  • Automatically disable truncation when span streaming is enabled in Google GenAI integration by andreiborza in #20229
  • Automatically disable truncation when span streaming is enabled in Anthropic AI integration by andreiborza in #20228
  • Automatically disable truncation when span streaming is enabled in Vercel AI integration by andreiborza in #20232
  • Automatically disable truncation when span streaming is enabled in OpenAI integration by andreiborza in #20227
  • Add enableTruncation option to Vercel AI integration by nicohrubec in #20195
  • Add enableTruncation option to Google GenAI integration by andreiborza in #20184
  • Add enableTruncation option to Anthropic AI integration by andreiborza in #20181
  • Add enableTruncation option to LangGraph integration by andreiborza in #20183
  • Add enableTruncation option to LangChain integration by andreiborza in #20182
  • Add enableTruncation option to OpenAI integration by andreiborza in #20167
  • Export a reusable function to add tracing headers by JPeer264 in #20076

Deps

  • Bump axios from 1.13.5 to 1.15.0 by dependabot in #20180
  • Bump hono from 4.12.7 to 4.12.12 by dependabot in #20118
  • Bump defu from 6.1.4 to 6.1.6 by dependabot in #20104

Other

  • (browser) Emit web vitals as streamed spans by logaretm in #19827
  • (node) Include global scope for eventLoopBlockIntegration by timfish in #20108
  • (node-native) Add support for V8 v14 (Node v25+) by timfish in #20125

Bug Fixes 🐛

Deno

  • Handle reader.closed rejection from releaseLock() in streaming by andreiborza in #20187
  • Avoid inferring invalid span op from Deno tracer by Lms24 in #20128

Other

  • (ci) Prevent command injection in ci-metadata workflow by fix-it-felix-sentry in #19899
  • (core, node) Support loading Express options lazily by isaacs in #20211
  • (e2e) Add op check to waitForTransaction in React Router e2e tests by copilot-swe-agent in #20193
  • (node-integration-tests) Fix flaky kafkajs test race condition by copilot-swe-agent in #20189

Internal Changes 🔧

Ci

  • Remove node-overhead GitHub Action by mydea in #20246
  • Bump dorny/paths-filter from v3.0.1 to v4.0.1 by mydea in #20251
  • Remove codecov steps from jobs that produce no coverage/JUnit data by mydea in #20244

Deps

  • Bump hono from 4.12.7 to 4.12.12 in /dev-packages/e2e-tests/test-applications/cloudflare-hono by dependabot in #20119
  • Bump axios from 1.13.5 to 1.15.0 in /dev-packages/e2e-tests/test-applications/nestjs-basic by dependabot in #20179

Other

  • (bugbot) Add rules to flag test-flake-provoking patterns by Lms24 in #20192
  • (deps-dev) Bump vite from 7.2.0 to 7.3.2 in /dev-packages/e2e-tests/test-applications/tanstackstart-react by dependabot in #20107
  • (react) Remove duplicated test mock by s1gr1d in #20200
  • (size-limit) Bump failing size limit scenario by Lms24 in #20186
  • Fix lint warnings by mydea in #20250
  • Fix flaky ANR test by increasing blocking duration by JPeer264 in #20239
  • Add automatic flaky test detector by nicohrubec in #18684

🤖 This preview updates automatically when you update the PR.

@logaretm logaretm marked this pull request as ready for review March 18, 2026 18:29
@logaretm logaretm requested review from Lms24 and Copilot and removed request for Copilot March 18, 2026 18:29
@Lms24 Lms24 force-pushed the lms/feat-span-first branch from c966a4a to 5963170 Compare March 23, 2026 09:41
@logaretm logaretm force-pushed the awad/js-17931-webvitals-v2-spans branch from 28c0d45 to af2969e Compare March 23, 2026 18:03
Copilot AI review requested due to automatic review settings March 23, 2026 18:14
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.ts helpers + 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.

@logaretm logaretm force-pushed the awad/js-17931-webvitals-v2-spans branch from d764f2e to b80ddd4 Compare March 24, 2026 14:28
@logaretm logaretm force-pushed the awad/js-17931-webvitals-v2-spans branch from a74fec7 to 7206304 Compare March 24, 2026 15:00
@logaretm logaretm force-pushed the awad/js-17931-webvitals-v2-spans branch 2 times, most recently from 86d1929 to 4bf129b Compare March 24, 2026 15:38
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

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.

Create PR

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.

@logaretm logaretm requested a review from Lms24 March 26, 2026 18:25
@Lms24 Lms24 force-pushed the lms/feat-span-first branch from 5963170 to de2e194 Compare March 30, 2026 11:47
@logaretm logaretm force-pushed the awad/js-17931-webvitals-v2-spans branch 2 times, most recently from 22f6a55 to 1417584 Compare March 30, 2026 16:25
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

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.

Create PR

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.

@Lms24 Lms24 force-pushed the lms/feat-span-first branch from 8613474 to e360c3b Compare April 2, 2026 14:32
@logaretm logaretm force-pushed the awad/js-17931-webvitals-v2-spans branch from 1417584 to b94c3b3 Compare April 2, 2026 14:36
Copy link
Copy Markdown
Member

@Lms24 Lms24 left a comment

Choose a reason for hiding this comment

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

Still had some final questions but most of it LGTM!

Comment on lines +79 to +83
span.addEvent(metricName, {
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: unit,
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: value,
});

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: This code path is only used for v2 spans, correct? In this case, we can remove the addEvent call (it no-ops for v2 spans)

Suggested change
span.addEvent(metricName, {
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: unit,
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: value,
});

Comment on lines +61 to +62
[`browser.web_vital.${metricName}.value`]: value,
transaction: routeName,
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.

(Still need to double-check with Michi): I tested this and we still need to send the short lcp attributes so that the web vital pills and and lines show up. Let me check if we actually do this or if we don't on purpose and let the data browsing team fix the pipeline

Suggested change
[`browser.web_vital.${metricName}.value`]: value,
transaction: routeName,
[`browser.web_vital.${metricName}.value`]: value,
[metricName]: value,
transaction: routeName,

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

/**
* Tracks INP as a streamed span.
*/
export function trackInpAsSpan(_client: Client): void {
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.

m: I might be missing something but it seems like the INP logic isn't the same as the _onInp callback for v1 standalone INP spans

@Lms24 Lms24 force-pushed the lms/feat-span-first branch from e360c3b to 595985b Compare April 8, 2026 12:15
Base automatically changed from lms/feat-span-first to develop April 9, 2026 12:46
logaretm and others added 14 commits April 13, 2026 17:53
…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>
@Lms24 Lms24 force-pushed the awad/js-17931-webvitals-v2-spans branch from b94c3b3 to 43438d6 Compare April 13, 2026 15:53
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Missing short metric name attribute breaks UI pills
    • Added short-form metric name attributes (lcp, cls, inp) alongside the browser.web_vital.*.value attributes in _emitWebVitalSpan to ensure Sentry UI web vital pills display correctly.
  • ✅ Fixed: LCP test uses sleep instead of concrete signal
    • Replaced page.waitForTimeout(1000) with event-driven waitForLcp helper that waits for lcp-done event triggered by image load, eliminating timeout-based flakiness.

Create PR

Or push these changes by commenting:

@cursor push 30b63a133d
Preview (30b63a133d)
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/subject.js
new file mode 100644
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/subject.js
@@ -1,0 +1,17 @@
+// Wait for the LCP image to load and dispatch an event
+const img = document.querySelector('img');
+if (img) {
+  const dispatchEvent = () => {
+    // Give the browser a moment to process the LCP
+    setTimeout(() => {
+      window.dispatchEvent(new Event('lcp-done'));
+    }, 100);
+  };
+
+  if (img.complete) {
+    // Image already loaded
+    dispatchEvent();
+  } else {
+    img.addEventListener('load', dispatchEvent);
+  }
+}

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
--- 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
@@ -12,6 +12,14 @@
   await page.setViewportSize({ width: 800, height: 1200 });
 });
 
+function waitForLcp(page: Page): Promise<void> {
+  return page.evaluate(() => {
+    return new Promise(resolve => {
+      window.addEventListener('lcp-done', () => resolve());
+    });
+  });
+}
+
 function hidePage(page: Page): Promise<void> {
   return page.evaluate(() => {
     window.dispatchEvent(new Event('pagehide'));
@@ -32,8 +40,7 @@
 
   await page.goto(url);
 
-  // Wait for LCP to be captured
-  await page.waitForTimeout(1000);
+  await waitForLcp(page);
 
   await hidePage(page);
 

diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts
--- a/packages/browser-utils/src/metrics/webVitalSpans.ts
+++ b/packages/browser-utils/src/metrics/webVitalSpans.ts
@@ -59,6 +59,7 @@
     [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
     [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0,
     [`browser.web_vital.${metricName}.value`]: value,
+    [metricName]: value,
     transaction: routeName,
     // Web vital score calculation relies on the user agent
     'user_agent.original': WINDOW.navigator?.userAgent,

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1695674. Configure here.

expect(mockSpan.addEvent).toHaveBeenCalledWith('lcp', {
'sentry.measurement_unit': 'millisecond',
'sentry.measurement_value': 100,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unit tests assert addEvent calls that never happen

Medium Severity

The unit tests assert that mockSpan.addEvent is called with measurement data (e.g., 'lcp', 'cls', 'inp'), but _emitWebVitalSpan never calls addEvent on the span — it only calls startInactiveSpan and span.end(). Per the PR discussion, addEvent was removed because it no-ops for v2 spans, but the tests weren't updated. These assertions will fail, meaning the new streaming code path lacks valid unit test coverage.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 1695674. Configure here.

reportEvent?: WebVitalReportEvent;
startTime: number;
endTime?: number;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WebVitalSpanOptions unit field is accepted but unused

Low Severity

The unit field in WebVitalSpanOptions is defined and passed by all callers (_sendLcpSpan, _sendClsSpan, _sendInpSpan) but is never destructured or used in _emitWebVitalSpan. This is dead code left over from the removal of the addEvent call, where unit was previously used for sentry.measurement_unit.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1695674. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Emit web vital spans as v2 spans

3 participants