Skip to content

feat(browser): Run static beforeSendSpan for INP spans - #22877

Merged
logaretm merged 7 commits into
developfrom
feat/inp-static-before-send-span
Jul 31, 2026
Merged

feat(browser): Run static beforeSendSpan for INP spans#22877
logaretm merged 7 commits into
developfrom
feat/inp-static-before-send-span

Conversation

@logaretm

@logaretm logaretm commented Jul 30, 2026

Copy link
Copy Markdown
Member

This fixes a beforeSendSpan gap for INP spans in the static trace lifecycle, without reintroducing v1 spans.

INP is always sent as a v2 span. When span streaming is disabled it goes through the standalone send path, which runs captureSpan. That only honors a beforeSendSpan wrapped with withStreamedSpan, so a plain callback (the one static users write, typed for v1 SpanJSON) was silently skipped for INP.

This also lets it call the preprocessSpan and processSpan hooks.

INP is always emitted as a v2 span. In static trace lifecycle it goes
through the standalone send path, which only honored a streamed
beforeSendSpan, so a plain (v1 SpanJSON) callback was silently skipped
for INP. Users opting out of span streaming could not scrub the INP
element name or route.

Split the standalone send path by callback type: a plain beforeSendSpan
now scrubs the span in its native v1 SpanJSON shape, then converts it
forward to v2 (the same one-way conversion gen_ai spans use to reach the
v2 span path). No reverse v2 to v1 conversion is introduced, and captureSpan
stays stream-only.
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 29.8 kB -0.08% -22 B 🔽
@sentry/browser - with treeshaking flags 28.01 kB -0.05% -14 B 🔽
@sentry/browser (incl. Tracing) 47.27 kB +0.41% +189 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 47.27 kB +0.39% +183 B 🔺
@sentry/browser (incl. Tracing, Profiling) 52.04 kB +0.42% +216 B 🔺
@sentry/browser (incl. Tracing, Replay) 86.6 kB +0.24% +202 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 76.03 kB +0.27% +203 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 91.31 kB +0.22% +197 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 103.94 kB +0.18% +184 B 🔺
@sentry/browser (incl. Feedback) 47.11 kB -0.05% -23 B 🔽
@sentry/browser (incl. sendFeedback) 34.64 kB -0.06% -19 B 🔽
@sentry/browser (incl. FeedbackAsync) 39.76 kB -0.03% -10 B 🔽
@sentry/browser (incl. Metrics) 30.88 kB -0.06% -16 B 🔽
@sentry/browser (incl. Logs) 31.11 kB -0.06% -18 B 🔽
@sentry/browser (incl. Metrics & Logs) 31.78 kB -0.06% -18 B 🔽
@sentry/react 31.62 kB +0.03% +7 B 🔺
@sentry/react (incl. Tracing) 49.53 kB +0.41% +201 B 🔺
@sentry/vue 34.86 kB +0.34% +117 B 🔺
@sentry/vue (incl. Tracing) 49.25 kB +0.41% +198 B 🔺
@sentry/svelte 29.83 kB -0.1% -27 B 🔽
CDN Bundle 31.88 kB +0.05% +15 B 🔺
CDN Bundle (incl. Tracing) 47.62 kB +0.34% +161 B 🔺
CDN Bundle (incl. Logs, Metrics) 33.45 kB +0.09% +28 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 48.98 kB +0.32% +156 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 72.8 kB +0.03% +19 B 🔺
CDN Bundle (incl. Tracing, Replay) 85.24 kB +0.19% +161 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 86.56 kB +0.21% +176 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 91.03 kB +0.21% +183 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 92.35 kB +0.21% +191 B 🔺
CDN Bundle - uncompressed 95.16 kB +0.05% +44 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 142.91 kB +0.41% +576 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 99.87 kB +0.05% +44 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 146.9 kB +0.4% +576 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 224.63 kB +0.02% +44 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 262.17 kB +0.23% +576 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 266.14 kB +0.22% +576 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 275.88 kB +0.21% +576 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 279.83 kB +0.21% +576 B 🔺
@sentry/nextjs (client) 52.11 kB +0.36% +185 B 🔺
@sentry/sveltekit (client) 47.7 kB +0.41% +192 B 🔺
@sentry/core/server 79.89 kB +0.16% +121 B 🔺
@sentry/core/browser 51.81 kB +0.22% +111 B 🔺
@sentry/node 121.51 kB +0.09% +101 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 0 B added added
@sentry/node - without tracing 85.03 kB +0.15% +121 B 🔺
@sentry/aws-serverless 93.29 kB +0.12% +107 B 🔺
@sentry/cloudflare (withSentry) - minified 198.72 kB +0.23% +451 B 🔺
@sentry/cloudflare (withSentry) 489.58 kB +0.33% +1.57 kB 🔺

View base workflow run

@logaretm
logaretm force-pushed the feat/inp-static-before-send-span branch from 7dd9dd3 to 3a52992 Compare July 30, 2026 17:43
@logaretm
logaretm marked this pull request as ready for review July 30, 2026 17:50
@logaretm
logaretm requested a review from a team as a code owner July 30, 2026 17:50
@logaretm
logaretm requested review from Lms24 and msonnb and removed request for a team July 30, 2026 17:50
Comment thread packages/core/src/tracing/spans/captureSpan.ts
logaretm added 2 commits July 30, 2026 16:26
…eforeSendSpan

The static-callback standalone path bypassed `captureSpan`, so it never emitted
the `preprocessSpan`/`processSpan` hooks. Integrations that enrich spans through
those hooks stopped running for INP when a plain `beforeSendSpan` was set, most
notably Replay no longer attaching `sentry.replay_id`.

Convert the post-callback v1 SpanJSON to the intermediate StreamedSpanJSON and
run the hooks on it before serializing, matching the streaming pipeline. Still no
reverse v2 -> v1 conversion: the user callback runs on v1 first, hooks run on the
forward-converted v2 JSON.
… standalone spans

Keep the hook/callback order consistent with `captureSpan`: run
`preprocessSpan`/`processSpan` first, then the user's v1 `beforeSendSpan`, so the
callback sees integration enrichment (e.g. Replay's `sentry.replay_id`) and can
act on it.

The hooks mutate the v2 `StreamedSpanJSON`; reflect their attribute and name
changes back onto the v1 `SpanJSON` before invoking the callback. The v1 status
is a free-form message while v2 is only `'ok' | 'error'`, so snapshot the original
v1 status and restore it rather than round-trip through the lossy v2 form.
Comment thread packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts Outdated
…version

v1 SpanJSON mirrors op/origin/profile_id/exclusive_time as top-level fields
alongside their attributes. A static `beforeSendSpan` edits the top-level field,
but the forward converter only copied `data`, so those edits were dropped from
the sent v2 span. Fold the top-level fields back into attributes (top-level wins
over the initially identical attribute), the inverse of `getSpanJSON`.
const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span);
const finalScopeData = getCombinedScopeData(spanIsolationScope, spanScope);

const commonAttributes = commonSpanAttributes(serializedSegmentSpan, client, finalScopeData);

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: to make sure we stay compatible with the static beforeSendSpan callback, we shouldn't apply the scopeData.attributes to it. The reason is, scope data attributes can have a {unit, value} object value which might be unexpected for users who deal primarily with transactions. Scope attributes are not applied to transactions, only to streamed spans. I think it's okay not to apply scope attributes to INP spans, even if they're technically v2 spans.

Comment on lines +200 to +210
const streamedSpanJSON = spanJsonToStreamedSpanJSON(spanJSON);
client.emit('preprocessSpan', streamedSpanJSON);
client.emit('processSpan', streamedSpanJSON);

// Reflect the hook enrichment back onto the v1 JSON so `beforeSendSpan` (which runs on v1, after the
// hooks, as in `captureSpan`) sees it. Attributes and name map cleanly. The v1 status is a free-form
// message, but v2 only has `'ok' | 'error'`, so restore the original v1 status rather than lose detail.
const originalStatus = spanJSON.status;
spanJSON.data = streamedSpanJSON.attributes as SpanJSON['data'];
spanJSON.description = streamedSpanJSON.name;
spanJSON.status = originalStatus;

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 think we can just omit calling the hooks and partially applying the modified data. Or did we miss data on the span that gets added via hook consumers? My concern is primarily that

  1. this requires another conversion
  2. hook listeners might add data to this span like its a regular streamed span (e.g. scope attributes).

Unless we need data form any of the hook listeners, I'd prefer skipping calling and backmapping.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Doesn't replay_id rely on processSpan hook for the INP? or is it something we can ignore here?

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.

Ah yeah right... I guess we need the attributes that are on the current v2 span or on the v1 span we had on v10 (https://github.com/getsentry/sentry-javascript/blob/v10/packages/browser-utils/src/metrics/utils.ts#L80). Not sure if we actually need replay_id specifically but we used to send it so we should continue doing so. It's fine then to leave this as-is I'd say.

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: if we can avoid calling the hooks, I don't think we need to modify this file at all, right? a spanJsonToSerializedStreamedSpan call at the end should be enough

logaretm added 2 commits July 31, 2026 10:15
…emission

Follow the gen_ai extraction pattern: a standalone span with a static beforeSendSpan
is plainly converted from v1 SpanJSON to a v2 span, without running the
preprocessSpan/processSpan hooks or back-mapping. This drops the extra conversion
and avoids hook consumers adding streamed-span-only data (e.g. scope attributes) to
what a static callback treats as a transaction-shaped span.

Because the hooks no longer run, Replay can't attach the replay id itself, so set it
(and the buffering flag) on the INP span at emission in browser-utils, mirroring how
v1 standalone web vital spans did it on v10. Scope attributes are also excluded from
the static path, as they can hold {unit, value} objects unexpected for such callbacks.
Revert spanJsonToStreamedSpan to its original form. The static standalone path now
plainly converts like gen_ai, so it doesn't need op/origin/profile_id/exclusive_time
folded back from top-level SpanJSON fields. A static beforeSendSpan that mutates a
top-level field (rather than the data attribute) won't take effect, matching how the
converter already behaves for gen_ai spans, and keeps this file untouched by the PR.
@logaretm
logaretm requested a review from Lms24 July 31, 2026 14:26
…ic lifecycle

Add TODO(standalone) markers to the two remaining pieces that only exist to serve the
static trace lifecycle: the `includeScopeAttributes` flag on `commonSpanAttributes` and
the `getReplayAttributes` helper. Both go away once transactions are dropped (v12), when
INP always streams and Replay's `processSpan` attaches the replay id itself.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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.

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 fc7c722. Configure here.


return {
'sentry.replay_id': replayId,
'sentry._internal.replay_is_buffering': replay!.getRecordingMode() === 'buffer' ? true : undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-null assertion lacks safety comment

Low Severity

getReplayAttributes uses a non-null assertion on replay with no comment explaining why a safer narrow isn't possible. The same replay lookup in logs/metrics already avoids this with optional chaining after the replayId check.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit fc7c722. Configure here.


const processedSpan = beforeSendSpan(spanJSON) || (showSpanDropWarning(), spanJSON);

return spanJsonToSerializedStreamedSpan(processedSpan);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Callback op changes not preserved

Medium Severity

captureStandaloneSpanWithStaticCallback converts the post-callback v1 SpanJSON with spanJsonToSerializedStreamedSpan, which only copies data into v2 attributes. Top-level op and origin edits from a static beforeSendSpan therefore never reach the sent INP span, even though description and data changes do.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fc7c722. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

shut up, we know

@Lms24 Lms24 left a comment

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.

I think this looks better. thanks for reworking!

@logaretm
logaretm merged commit 9482537 into develop Jul 31, 2026
270 checks passed
@logaretm
logaretm deleted the feat/inp-static-before-send-span branch July 31, 2026 15:19
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.

2 participants