From 3a529920b898bf2dd176541434e303e80f11d793 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 30 Jul 2026 11:47:57 -0400 Subject: [PATCH 1/7] feat(browser): Run static beforeSendSpan for INP spans 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. --- .../web-vitals-inp-before-send-span/init.js | 39 ++++++++++++ .../subject.js | 20 +++++++ .../template.html | 12 ++++ .../web-vitals-inp-before-send-span/test.ts | 44 ++++++++++++++ packages/core/src/tracing/sentrySpan.ts | 18 +++++- .../core/src/tracing/spans/captureSpan.ts | 60 +++++++++++++++++-- .../core/test/lib/tracing/sentrySpan.test.ts | 19 ++++-- 7 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/init.js new file mode 100644 index 000000000000..64cd4ccff9d7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/init.js @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 4000, + enableLongTask: false, + enableInp: true, + instrumentPageLoad: false, + instrumentNavigation: false, + }), + ], + tracesSampleRate: 1, + // A plain (non-streamed) `beforeSendSpan` operates on the v1 `SpanJSON`. INP is sent as a v2 span, + // so this verifies the static callback still runs and its changes are carried into the v2 span. + beforeSendSpan: span => { + if (span.op === 'ui.interaction.click') { + span.description = 'scrubbed'; + span.data['custom.attribute'] = 'from-before-send-span'; + } + + return span; + }, + debug: true, +}); + +const client = Sentry.getClient(); + +// Force page load transaction name to a testable value +Sentry.startBrowserTracingPageLoadSpan(client, { + name: 'test-url', + attributes: { + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/subject.js new file mode 100644 index 000000000000..64524952dfa7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/subject.js @@ -0,0 +1,20 @@ +const blockUI = + (delay = 70) => + e => { + const startTime = Date.now(); + + function getElasped() { + const time = Date.now(); + return time - startTime; + } + + while (getElasped() < delay) { + // + } + + e.target.classList.add('clicked'); + }; + +document.querySelector('[data-test-id=not-so-slow-button]').addEventListener('click', blockUI(300)); +document.querySelector('[data-test-id=slow-button]').addEventListener('click', blockUI(450)); +document.querySelector('[data-test-id=normal-button]').addEventListener('click', blockUI()); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/template.html new file mode 100644 index 000000000000..437426e9ab01 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/template.html @@ -0,0 +1,12 @@ + + + + + + +
Rendered Before Long Task
+ + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/test.ts new file mode 100644 index 000000000000..add8a0ac71c3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/test.ts @@ -0,0 +1,44 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { hidePage, shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +// This app does not enable span streaming (`traceLifecycle: 'static'`) and defines a plain, non-streamed +// `beforeSendSpan` callback (operating on the v1 `SpanJSON`). INP is still emitted as a v2 span, so this +// verifies the static callback runs for INP and its modifications are carried into the v2 span. + +sentryTest('runs a non-streamed `beforeSendSpan` for the INP span', async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'ui.interaction.click'), + ); + + await page.goto(url); + + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await hidePage(page); + + const spanEnvelope = await spanEnvelopePromise; + const inpSpan = getSpansFromEnvelope(spanEnvelope).find(s => getSpanOp(s) === 'ui.interaction.click')!; + + // The callback rewrote the name and added a custom attribute. + expect(inpSpan.name).toBe('scrubbed'); + expect(inpSpan.attributes['custom.attribute']).toEqual({ value: 'from-before-send-span', type: 'string' }); + + // The span is still a valid v2 INP span carrying its web vital value. + const inpValue = inpSpan.attributes['browser.web_vital.inp.value']?.value as number; + expect(inpValue).toBeGreaterThan(0); +}); diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index b71b3dc2e486..06004521e417 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -46,7 +46,8 @@ import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanEnd } from './logSpans'; import { timedEventsToMeasurements } from './measurement'; import { getSegmentSpanCaptureStrategy, type SegmentSpanCaptureConvertOptions } from './segmentSpanCaptureStrategy'; -import { captureSpan } from './spans/captureSpan'; +import { isStreamedBeforeSendSpanCallback } from './spans/beforeSendSpan'; +import { captureSpan, captureStandaloneSpanWithStaticCallback } from './spans/captureSpan'; import { createStreamedSpanEnvelope } from './spans/envelope'; import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled'; import { @@ -556,6 +557,21 @@ function isStandaloneSpan(span: Span): boolean { * TODO(standalone): remove once the static (transaction) trace lifecycle is dropped. */ function sendStandaloneSpan(span: SentrySpan, client: Client): void { + const { beforeSendSpan } = client.getOptions(); + + // A user who opted out of span streaming writes `beforeSendSpan` in the v1 `SpanJSON` format. That + // callback never runs through `captureSpan` (which only honors streamed callbacks), so scrub the + // span in its native v1 shape and convert it forward to v2, mirroring the gen_ai extraction path. + // TODO(standalone): remove this branch once the static trace lifecycle is dropped. + if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) { + const serializedSpan = captureStandaloneSpanWithStaticCallback(span, client, beforeSendSpan); + const dsc = getDynamicSamplingContextFromSpan(span); + // sendEnvelope should not throw + // eslint-disable-next-line @typescript-eslint/no-floating-promises + client.sendEnvelope(createStreamedSpanEnvelope([serializedSpan], dsc, client)); + return; + } + const { _segmentSpan, ...serializedSpan } = captureSpan(span, client); const dsc = getDynamicSamplingContextFromSpan(_segmentSpan); // sendEnvelope should not throw diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index b65f85b02d46..7d9bf64f5e29 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -11,16 +11,18 @@ import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; -import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types/span'; +import type { SerializedStreamedSpan, Span, SpanAttributeValue, SpanJSON, StreamedSpanJSON } from '../../types/span'; import { getCombinedScopeData } from '../../utils/scopeData'; import { INTERNAL_getSegmentSpan, showSpanDropWarning, + spanToJSON, spanToStreamedSpanJSON, streamedSpanJsonToSerializedSpan, } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; +import { spanJsonToSerializedStreamedSpan } from './spanJsonToStreamedSpan'; import { scopeContextsToSpanAttributes } from './scopeContextAttributes'; import { DEFAULT_ENVIRONMENT } from '../../constants'; import { @@ -126,17 +128,15 @@ function applySdkMetadataToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, client }); } -function applyCommonSpanAttributes( - spanJSON: StreamedSpanJSON, +function commonSpanAttributes( serializedSegmentSpan: StreamedSpanJSON, client: Client, scopeData: ScopeData, -): void { +): RawAttributes> { const sdk = client.getSdkMetadata(); const { release, environment } = client.getOptions(); - // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) - safeSetSpanJSONAttributes(spanJSON, { + return { [SENTRY_TRACE_LIFECYCLE]: 'stream', [SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name, [SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id, @@ -149,7 +149,55 @@ function applyCommonSpanAttributes( [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, ...scopeData.attributes, + }; +} + +function applyCommonSpanAttributes( + spanJSON: StreamedSpanJSON, + serializedSegmentSpan: StreamedSpanJSON, + client: Client, + scopeData: ScopeData, +): void { + // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) + safeSetSpanJSONAttributes(spanJSON, commonSpanAttributes(serializedSegmentSpan, client, scopeData)); +} + +/** + * Captures a standalone span whose `beforeSendSpan` callback expects the v1 {@link SpanJSON} format + * (i.e. the user opted out of span streaming). The span is serialized to v1, the common attributes + * are applied so the callback can scrub them, the callback runs in its native format, and the result + * is converted forward to a serialized v2 span. This mirrors how gen_ai spans reach the v2 span path + * from a static transaction, so there is never a reverse v2 -> v1 conversion. + * + * TODO(standalone): remove once the static (transaction) trace lifecycle is dropped. + */ +export function captureStandaloneSpanWithStaticCallback( + span: Span, + client: Client, + beforeSendSpan: (span: SpanJSON) => SpanJSON, +): SerializedStreamedSpan { + const spanJSON = spanToJSON(span); + + const segmentSpan = INTERNAL_getSegmentSpan(span); + const serializedSegmentSpan = spanToStreamedSpanJSON(segmentSpan); + + const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); + const finalScopeData = getCombinedScopeData(spanIsolationScope, spanScope); + + const commonAttributes = commonSpanAttributes(serializedSegmentSpan, client, finalScopeData); + Object.entries(commonAttributes).forEach(([key, value]) => { + if (value != null && !(key in spanJSON.data)) { + spanJSON.data[key] = value as SpanAttributeValue; + } }); + + const processedSpan = beforeSendSpan(spanJSON); + if (!processedSpan) { + showSpanDropWarning(); + return spanJsonToSerializedStreamedSpan(spanJSON); + } + + return spanJsonToSerializedStreamedSpan(processedSpan); } /** diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 7522b7061234..3fd9459dde1f 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -295,10 +295,11 @@ describe('SentrySpan', () => { expect(mockSend).toHaveBeenCalled(); }); - test('ignores a non-streamed `beforeSendSpan` for standalone spans', () => { - // Standalone spans are sent as v2 streamed spans, which only honor a `beforeSendSpan` wrapped - // with `withStreamedSpan`. A plain callback is ignored, so the span is sent unmodified. - const beforeSendSpan = vi.fn(() => null as unknown as SpanJSON); + test('runs a non-streamed `beforeSendSpan` for standalone spans', () => { + // A standalone span is sent as a v2 streamed span, but a user opting out of span streaming still + // writes `beforeSendSpan` in the v1 `SpanJSON` format. We scrub the span in its v1 shape before + // converting it forward to v2, so the callback runs and its changes are applied. + const beforeSendSpan = vi.fn((span: SpanJSON) => ({ ...span, description: 'scrubbed' })); const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://username@domain/123', @@ -309,6 +310,10 @@ describe('SentrySpan', () => { setCurrentClient(client); const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + const envelopes: Envelope[] = []; + client.on('beforeEnvelope', envelope => { + envelopes.push(envelope); + }); // @ts-expect-error Accessing private transport API const mockSend = vi.spyOn(client._transport, 'send'); const span = new SentrySpan({ @@ -320,9 +325,13 @@ describe('SentrySpan', () => { }); span.end(); - expect(beforeSendSpan).not.toHaveBeenCalled(); + expect(beforeSendSpan).toHaveBeenCalledTimes(1); expect(mockSend).toHaveBeenCalled(); expect(recordDroppedEventSpy).not.toHaveBeenCalled(); + + const spanItem = envelopes[0]?.[1][0] as [{ type: string }, { items: Array<{ name: string }> }]; + expect(spanItem[0].type).toBe('span'); + expect(spanItem[1].items[0]!.name).toBe('scrubbed'); }); test('sends a standalone span on its own and excludes it from the parent transaction', async () => { From d9d9eee52dc847a35a9f5f3a7b6b26c2df73911d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 30 Jul 2026 16:26:18 -0400 Subject: [PATCH 2/7] fix(core): Run processSpan hooks for standalone spans with a static beforeSendSpan 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. --- .../core/src/tracing/spans/captureSpan.ts | 26 ++++++++----- .../tracing/spans/spanJsonToStreamedSpan.ts | 14 +++++-- .../core/test/lib/tracing/sentrySpan.test.ts | 39 +++++++++++++++++++ 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 7d9bf64f5e29..cf2051b3deed 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -22,7 +22,7 @@ import { } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; -import { spanJsonToSerializedStreamedSpan } from './spanJsonToStreamedSpan'; +import { spanJsonToStreamedSpanJSON } from './spanJsonToStreamedSpan'; import { scopeContextsToSpanAttributes } from './scopeContextAttributes'; import { DEFAULT_ENVIRONMENT } from '../../constants'; import { @@ -165,9 +165,11 @@ function applyCommonSpanAttributes( /** * Captures a standalone span whose `beforeSendSpan` callback expects the v1 {@link SpanJSON} format * (i.e. the user opted out of span streaming). The span is serialized to v1, the common attributes - * are applied so the callback can scrub them, the callback runs in its native format, and the result - * is converted forward to a serialized v2 span. This mirrors how gen_ai spans reach the v2 span path - * from a static transaction, so there is never a reverse v2 -> v1 conversion. + * are applied so the callback can scrub them, and the callback runs in its native format. The result + * is then converted forward to the intermediate v2 span JSON, on which the `preprocessSpan`/`processSpan` + * hooks run (so integrations like Replay still enrich it, e.g. attaching `sentry.replay_id`), before it + * is serialized. This mirrors how gen_ai spans reach the v2 span path from a static transaction, so + * there is never a reverse v2 -> v1 conversion. * * TODO(standalone): remove once the static (transaction) trace lifecycle is dropped. */ @@ -191,13 +193,17 @@ export function captureStandaloneSpanWithStaticCallback( } }); - const processedSpan = beforeSendSpan(spanJSON); - if (!processedSpan) { - showSpanDropWarning(); - return spanJsonToSerializedStreamedSpan(spanJSON); - } + const processedSpan = beforeSendSpan(spanJSON) || (showSpanDropWarning(), spanJSON); + + const streamedSpanJSON = spanJsonToStreamedSpanJSON(processedSpan); + + // A standalone span is never a segment span (see `spanJsonToStreamedSpanJSON`), so we only run the + // regular span hooks. These let integrations enrich the span the same way they do in the streaming + // pipeline, e.g. Replay attaching `sentry.replay_id`. + client.emit('preprocessSpan', streamedSpanJSON); + client.emit('processSpan', streamedSpanJSON); - return spanJsonToSerializedStreamedSpan(processedSpan); + return streamedSpanJsonToSerializedSpan(streamedSpanJSON); } /** diff --git a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts index de664b802138..fb6af1f5d40c 100644 --- a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts +++ b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts @@ -3,10 +3,11 @@ import type { SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../t import { streamedSpanJsonToSerializedSpan } from '../../utils/spanUtils'; /** - * Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan. + * Converts a v1 SpanJSON (from a legacy transaction) to the intermediate v2 {@link StreamedSpanJSON} + * (raw attributes), before serialization. Use this when a hook needs to mutate the span JSON. */ -export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStreamedSpan { - const streamedSpan: StreamedSpanJSON = { +export function spanJsonToStreamedSpanJSON(span: SpanJSON): StreamedSpanJSON { + return { trace_id: span.trace_id, span_id: span.span_id, parent_span_id: span.parent_span_id, @@ -18,6 +19,11 @@ export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStre attributes: { ...(span.data as RawAttributes>) }, links: span.links, }; +} - return streamedSpanJsonToSerializedSpan(streamedSpan); +/** + * Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan. + */ +export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStreamedSpan { + return streamedSpanJsonToSerializedSpan(spanJsonToStreamedSpanJSON(span)); } diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 3fd9459dde1f..835911459242 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -334,6 +334,45 @@ describe('SentrySpan', () => { expect(spanItem[1].items[0]!.name).toBe('scrubbed'); }); + test('runs `processSpan` hooks for standalone spans with a non-streamed `beforeSendSpan`', () => { + // Integrations (e.g. Replay attaching `sentry.replay_id`) enrich spans via the `processSpan` hook. + // The static-callback standalone path must still run those hooks, so this asserts a subscriber runs + // and its mutation lands on the sent v2 span. + const beforeSendSpan = vi.fn((span: SpanJSON) => span); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + beforeSendSpan, + }), + ); + setCurrentClient(client); + + client.on('processSpan', span => { + (span.attributes ?? (span.attributes = {}))['sentry.replay_id'] = 'abc'; + }); + + const envelopes: Envelope[] = []; + client.on('beforeEnvelope', envelope => { + envelopes.push(envelope); + }); + + const span = new SentrySpan({ + name: 'test', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + span.end(); + + const spanItem = envelopes[0]?.[1][0] as [ + { type: string }, + { items: Array<{ attributes: Record }> }, + ]; + expect(spanItem[1].items[0]!.attributes['sentry.replay_id']!.value).toBe('abc'); + }); + test('sends a standalone span on its own and excludes it from the parent transaction', async () => { const client = new TestClient( getDefaultTestClientOptions({ From f3d1a02150ccdc04a1eda9811b8b520079f8f895 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 30 Jul 2026 16:38:39 -0400 Subject: [PATCH 3/7] ref(core): Run processSpan hooks before the static beforeSendSpan for 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. --- .../core/src/tracing/spans/captureSpan.ts | 32 ++++++++++++------- .../core/test/lib/tracing/sentrySpan.test.ts | 19 ++++++++--- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index cf2051b3deed..39027d1d9b86 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -22,7 +22,7 @@ import { } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; -import { spanJsonToStreamedSpanJSON } from './spanJsonToStreamedSpan'; +import { spanJsonToSerializedStreamedSpan, spanJsonToStreamedSpanJSON } from './spanJsonToStreamedSpan'; import { scopeContextsToSpanAttributes } from './scopeContextAttributes'; import { DEFAULT_ENVIRONMENT } from '../../constants'; import { @@ -164,12 +164,13 @@ function applyCommonSpanAttributes( /** * Captures a standalone span whose `beforeSendSpan` callback expects the v1 {@link SpanJSON} format - * (i.e. the user opted out of span streaming). The span is serialized to v1, the common attributes - * are applied so the callback can scrub them, and the callback runs in its native format. The result - * is then converted forward to the intermediate v2 span JSON, on which the `preprocessSpan`/`processSpan` - * hooks run (so integrations like Replay still enrich it, e.g. attaching `sentry.replay_id`), before it - * is serialized. This mirrors how gen_ai spans reach the v2 span path from a static transaction, so - * there is never a reverse v2 -> v1 conversion. + * (i.e. the user opted out of span streaming). The span is serialized to v1 and the common attributes + * are applied. It is then converted forward to the intermediate v2 span JSON, on which the + * `preprocessSpan`/`processSpan` hooks run (so integrations like Replay enrich it, e.g. attaching + * `sentry.replay_id`), matching the order in {@link captureSpan} where hooks run before `beforeSendSpan`. + * The enrichment is reflected back onto the v1 JSON so the callback sees it, the callback runs in its + * native format, and the result is serialized. This mirrors how gen_ai spans reach the v2 span path + * from a static transaction, so there is never a reverse v2 -> v1 conversion. * * TODO(standalone): remove once the static (transaction) trace lifecycle is dropped. */ @@ -193,17 +194,24 @@ export function captureStandaloneSpanWithStaticCallback( } }); - const processedSpan = beforeSendSpan(spanJSON) || (showSpanDropWarning(), spanJSON); - - const streamedSpanJSON = spanJsonToStreamedSpanJSON(processedSpan); - // A standalone span is never a segment span (see `spanJsonToStreamedSpanJSON`), so we only run the // regular span hooks. These let integrations enrich the span the same way they do in the streaming // pipeline, e.g. Replay attaching `sentry.replay_id`. + const streamedSpanJSON = spanJsonToStreamedSpanJSON(spanJSON); client.emit('preprocessSpan', streamedSpanJSON); client.emit('processSpan', streamedSpanJSON); - return streamedSpanJsonToSerializedSpan(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; + + const processedSpan = beforeSendSpan(spanJSON) || (showSpanDropWarning(), spanJSON); + + return spanJsonToSerializedStreamedSpan(processedSpan); } /** diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 835911459242..47fcd10822ac 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -334,11 +334,16 @@ describe('SentrySpan', () => { expect(spanItem[1].items[0]!.name).toBe('scrubbed'); }); - test('runs `processSpan` hooks for standalone spans with a non-streamed `beforeSendSpan`', () => { + test('runs `processSpan` hooks before a non-streamed `beforeSendSpan` for standalone spans', () => { // Integrations (e.g. Replay attaching `sentry.replay_id`) enrich spans via the `processSpan` hook. - // The static-callback standalone path must still run those hooks, so this asserts a subscriber runs - // and its mutation lands on the sent v2 span. - const beforeSendSpan = vi.fn((span: SpanJSON) => span); + // The static-callback standalone path must run those hooks, and (as in `captureSpan`) run them + // before `beforeSendSpan`, so the callback sees the enrichment and its mutation lands on the span. + let seenReplayId: unknown; + const beforeSendSpan = vi.fn((span: SpanJSON) => { + seenReplayId = span.data['sentry.replay_id']; + span.data['sentry.replay_id'] = 'scrubbed'; + return span; + }); const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://username@domain/123', @@ -366,11 +371,15 @@ describe('SentrySpan', () => { }); span.end(); + // The callback saw the hook-added attribute (hooks ran first)... + expect(seenReplayId).toBe('abc'); + + // ...and its own change to that attribute is what gets sent. const spanItem = envelopes[0]?.[1][0] as [ { type: string }, { items: Array<{ attributes: Record }> }, ]; - expect(spanItem[1].items[0]!.attributes['sentry.replay_id']!.value).toBe('abc'); + expect(spanItem[1].items[0]!.attributes['sentry.replay_id']!.value).toBe('scrubbed'); }); test('sends a standalone span on its own and excludes it from the parent transaction', async () => { From 2293bfce94fd3e7ddab7ca5d435e5f0572ff1351 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 30 Jul 2026 16:53:11 -0400 Subject: [PATCH 4/7] fix(core): Preserve mutated top-level SpanJSON fields in v1 to v2 conversion 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`. --- .../tracing/spans/spanJsonToStreamedSpan.ts | 25 ++++++++++- .../spans/spanJsonToStreamedSpan.test.ts | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts index fb6af1f5d40c..127622190103 100644 --- a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts +++ b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts @@ -1,12 +1,35 @@ import type { RawAttributes } from '../../attributes'; +import { + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_PROFILE_ID, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '../../semanticAttributes'; import type { SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../types/span'; import { streamedSpanJsonToSerializedSpan } from '../../utils/spanUtils'; +// v1 SpanJSON mirrors some attributes as top-level fields (see `SentrySpan.getSpanJSON`). A +// `beforeSendSpan` callback edits the top-level field, so those edits have to be folded back into +// attributes, letting the top-level value win over the (initially identical) attribute. This is the +// inverse of `getSpanJSON` and mirrors how `convertSpanJsonToTransactionEvent` rebuilds `data`. +const TOP_LEVEL_ATTRIBUTE_FIELDS = [ + ['op', SEMANTIC_ATTRIBUTE_SENTRY_OP], + ['origin', SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + ['profile_id', SEMANTIC_ATTRIBUTE_PROFILE_ID], + ['exclusive_time', SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME], +] as const; + /** * Converts a v1 SpanJSON (from a legacy transaction) to the intermediate v2 {@link StreamedSpanJSON} * (raw attributes), before serialization. Use this when a hook needs to mutate the span JSON. */ export function spanJsonToStreamedSpanJSON(span: SpanJSON): StreamedSpanJSON { + const attributes = { ...(span.data as RawAttributes>) }; + + for (const [field, attribute] of TOP_LEVEL_ATTRIBUTE_FIELDS) { + attributes[attribute] = span[field] ?? attributes[attribute]; + } + return { trace_id: span.trace_id, span_id: span.span_id, @@ -16,7 +39,7 @@ export function spanJsonToStreamedSpanJSON(span: SpanJSON): StreamedSpanJSON { end_timestamp: span.timestamp || span.start_timestamp, status: !span.status || span.status === 'ok' || span.status === 'cancelled' ? 'ok' : 'error', is_segment: false, - attributes: { ...(span.data as RawAttributes>) }, + attributes, links: span.links, }; } diff --git a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts index 94b25c590d19..b18d8ec9c263 100644 --- a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts @@ -79,6 +79,51 @@ describe('spanJsonToSerializedStreamedSpan', () => { expect(result.attributes['gen_ai.usage.output_tokens']).toEqual({ type: 'integer', value: 50 }); }); + it('maps top-level op/origin into sentry.op/sentry.origin attributes', () => { + const span = makeSpanJSON({ op: 'ui.interaction.click', origin: 'auto.http.browser.inp', data: {} }); + + const result = spanJsonToSerializedStreamedSpan(span); + + expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'ui.interaction.click' }); + expect(result.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.inp' }); + }); + + it('lets a mutated top-level op/origin win over the original data attribute', () => { + // A `beforeSendSpan` callback mutates the top-level field while the data attribute stays stale. + const span = makeSpanJSON({ + op: 'changed.op', + origin: 'changed.origin', + data: { 'sentry.op': 'stale.op', 'sentry.origin': 'stale.origin' }, + }); + + const result = spanJsonToSerializedStreamedSpan(span); + + expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'changed.op' }); + expect(result.attributes['sentry.origin']).toEqual({ type: 'string', value: 'changed.origin' }); + }); + + it('keeps the data attribute when there is no top-level op/origin', () => { + const span = makeSpanJSON({ op: undefined, origin: undefined, data: { 'sentry.op': 'from.data' } }); + + const result = spanJsonToSerializedStreamedSpan(span); + + expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'from.data' }); + expect(result.attributes['sentry.origin']).toBeUndefined(); + }); + + it('maps mutated top-level profile_id/exclusive_time back into attributes', () => { + const span = makeSpanJSON({ + profile_id: 'new-profile', + exclusive_time: 42, + data: { 'sentry.profile_id': 'stale-profile', 'sentry.exclusive_time': 1 }, + }); + + const result = spanJsonToSerializedStreamedSpan(span); + + expect(result.attributes['sentry.profile_id']).toEqual({ type: 'string', value: 'new-profile' }); + expect(result.attributes['sentry.exclusive_time']).toEqual({ type: 'integer', value: 42 }); + }); + it('carries over links', () => { const span = makeSpanJSON({ links: [{ trace_id: 'aabb', span_id: 'ccdd', sampled: true, attributes: { foo: 'bar' } }], From ded63294ad5a318bdf3fd16f13abb008c25be172 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 31 Jul 2026 10:15:46 -0400 Subject: [PATCH 5/7] ref(core): Plainly convert standalone static spans, set replay id at 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. --- .../src/metrics/webVitalSpans.ts | 28 +++++++- .../test/metrics/webVitalSpans.test.ts | 67 +++++++++++++++++++ .../core/src/tracing/spans/captureSpan.ts | 35 +++------- .../tracing/spans/spanJsonToStreamedSpan.ts | 14 ++-- .../core/test/lib/tracing/sentrySpan.test.ts | 32 ++------- 5 files changed, 115 insertions(+), 61 deletions(-) diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index 85f09b801ef2..eae7c66878f2 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -1,8 +1,9 @@ -import type { Client, Span, SpanAttributes } from '@sentry/core'; +import type { Client, Integration, Span, SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, debug, getActiveSpan, + getClient, getCurrentScope, getRootSpan, hasSpanStreamingEnabled, @@ -108,6 +109,13 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { attributes[`browser.web_vital.${metricName}.report_event`] = reportEvent; } + // A standalone span is sent as a plain v2 span without running the `processSpan` hooks (see + // `captureStandaloneSpanWithStaticCallback`), so Replay can't attach the replay id itself. Set it + // here, mirroring Replay's `processSpan`, so INP keeps its replay association like it did on v1. + if (standalone) { + Object.assign(attributes, getReplayAttributes()); + } + const span = startInactiveSpan({ name, attributes, @@ -122,6 +130,24 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { } } +interface ReplayIntegration extends Integration { + getReplayId: (onlyIfSampled?: boolean) => string | undefined; + getRecordingMode: () => 'session' | 'buffer' | undefined; +} + +function getReplayAttributes(): SpanAttributes { + const replay = getClient()?.getIntegrationByName('Replay'); + const replayId = replay?.getReplayId(true); + if (!replayId) { + return {}; + } + + return { + 'sentry.replay_id': replayId, + 'sentry._internal.replay_is_buffering': replay!.getRecordingMode() === 'buffer' ? true : undefined, + }; +} + /** * Tracks LCP as a streamed span. */ diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts index 53f248a4c640..ef938d7017f0 100644 --- a/packages/browser-utils/test/metrics/webVitalSpans.test.ts +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -19,6 +19,7 @@ vi.mock('@sentry/core', async () => { browserPerformanceTimeOrigin: vi.fn(), timestampInSeconds: vi.fn(), getCurrentScope: vi.fn(), + getClient: vi.fn(), startInactiveSpan: vi.fn(), getActiveSpan: vi.fn(), getRootSpan: vi.fn(), @@ -64,6 +65,7 @@ describe('_emitWebVitalSpan', () => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ attributes: {} } as any); + vi.mocked(SentryCore.getClient).mockReturnValue({ getIntegrationByName: () => undefined } as any); }); afterEach(() => { @@ -118,6 +120,71 @@ describe('_emitWebVitalSpan', () => { ); }); + it('adds the replay id to a standalone span when a replay is recording', () => { + vi.mocked(SentryCore.getClient).mockReturnValue({ + getIntegrationByName: () => ({ getReplayId: () => 'replay-123', getRecordingMode: () => 'session' }), + } as any); + + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: 100, + startTime: 1.5, + standalone: true, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.replay_id': 'replay-123', + 'sentry._internal.replay_is_buffering': undefined, + }), + }), + ); + }); + + it('flags buffering when the replay is in buffer mode', () => { + vi.mocked(SentryCore.getClient).mockReturnValue({ + getIntegrationByName: () => ({ getReplayId: () => 'replay-123', getRecordingMode: () => 'buffer' }), + } as any); + + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: 100, + startTime: 1.5, + standalone: true, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ 'sentry._internal.replay_is_buffering': true }), + }), + ); + }); + + it('does not add a replay id to non-standalone spans', () => { + vi.mocked(SentryCore.getClient).mockReturnValue({ + getIntegrationByName: () => ({ getReplayId: () => 'replay-123', getRecordingMode: () => 'session' }), + } as any); + + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: 100, + startTime: 1.5, + }); + + const attributes = vi.mocked(SentryCore.startInactiveSpan).mock.calls[0]![0].attributes!; + expect(attributes['sentry.replay_id']).toBeUndefined(); + }); + it('includes pageload span id when parentSpan is a pageload span', () => { const mockPageloadSpan = createMockPageloadSpan('abc123'); vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 39027d1d9b86..bc8fcd912c5f 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -22,7 +22,7 @@ import { } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; -import { spanJsonToSerializedStreamedSpan, spanJsonToStreamedSpanJSON } from './spanJsonToStreamedSpan'; +import { spanJsonToSerializedStreamedSpan } from './spanJsonToStreamedSpan'; import { scopeContextsToSpanAttributes } from './scopeContextAttributes'; import { DEFAULT_ENVIRONMENT } from '../../constants'; import { @@ -132,6 +132,7 @@ function commonSpanAttributes( serializedSegmentSpan: StreamedSpanJSON, client: Client, scopeData: ScopeData, + includeScopeAttributes = true, ): RawAttributes> { const sdk = client.getSdkMetadata(); const { release, environment } = client.getOptions(); @@ -148,7 +149,7 @@ function commonSpanAttributes( [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, - ...scopeData.attributes, + ...(includeScopeAttributes ? scopeData.attributes : undefined), }; } @@ -164,13 +165,10 @@ function applyCommonSpanAttributes( /** * Captures a standalone span whose `beforeSendSpan` callback expects the v1 {@link SpanJSON} format - * (i.e. the user opted out of span streaming). The span is serialized to v1 and the common attributes - * are applied. It is then converted forward to the intermediate v2 span JSON, on which the - * `preprocessSpan`/`processSpan` hooks run (so integrations like Replay enrich it, e.g. attaching - * `sentry.replay_id`), matching the order in {@link captureSpan} where hooks run before `beforeSendSpan`. - * The enrichment is reflected back onto the v1 JSON so the callback sees it, the callback runs in its - * native format, and the result is serialized. This mirrors how gen_ai spans reach the v2 span path - * from a static transaction, so there is never a reverse v2 -> v1 conversion. + * (i.e. the user opted out of span streaming). The span is serialized to v1, the common attributes are + * applied, the callback runs in its native format, and the result is converted forward to a serialized + * v2 span. This mirrors how gen_ai spans reach the v2 span path from a static transaction (a plain + * conversion, no `processSpan` hooks), so there is never a reverse v2 -> v1 conversion. * * TODO(standalone): remove once the static (transaction) trace lifecycle is dropped. */ @@ -187,28 +185,15 @@ export function captureStandaloneSpanWithStaticCallback( const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); const finalScopeData = getCombinedScopeData(spanIsolationScope, spanScope); - const commonAttributes = commonSpanAttributes(serializedSegmentSpan, client, finalScopeData); + // Skip scope attributes: their `{ unit, value }` shape is unexpected for a static callback, and like + // transactions, standalone spans don't get them. + const commonAttributes = commonSpanAttributes(serializedSegmentSpan, client, finalScopeData, false); Object.entries(commonAttributes).forEach(([key, value]) => { if (value != null && !(key in spanJSON.data)) { spanJSON.data[key] = value as SpanAttributeValue; } }); - // A standalone span is never a segment span (see `spanJsonToStreamedSpanJSON`), so we only run the - // regular span hooks. These let integrations enrich the span the same way they do in the streaming - // pipeline, e.g. Replay attaching `sentry.replay_id`. - 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; - const processedSpan = beforeSendSpan(spanJSON) || (showSpanDropWarning(), spanJSON); return spanJsonToSerializedStreamedSpan(processedSpan); diff --git a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts index 127622190103..e16ac8b1d2a0 100644 --- a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts +++ b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts @@ -20,17 +20,16 @@ const TOP_LEVEL_ATTRIBUTE_FIELDS = [ ] as const; /** - * Converts a v1 SpanJSON (from a legacy transaction) to the intermediate v2 {@link StreamedSpanJSON} - * (raw attributes), before serialization. Use this when a hook needs to mutate the span JSON. + * Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan. */ -export function spanJsonToStreamedSpanJSON(span: SpanJSON): StreamedSpanJSON { +export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStreamedSpan { const attributes = { ...(span.data as RawAttributes>) }; for (const [field, attribute] of TOP_LEVEL_ATTRIBUTE_FIELDS) { attributes[attribute] = span[field] ?? attributes[attribute]; } - return { + const streamedSpan: StreamedSpanJSON = { trace_id: span.trace_id, span_id: span.span_id, parent_span_id: span.parent_span_id, @@ -42,11 +41,6 @@ export function spanJsonToStreamedSpanJSON(span: SpanJSON): StreamedSpanJSON { attributes, links: span.links, }; -} -/** - * Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan. - */ -export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStreamedSpan { - return streamedSpanJsonToSerializedSpan(spanJsonToStreamedSpanJSON(span)); + return streamedSpanJsonToSerializedSpan(streamedSpan); } diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 47fcd10822ac..7f509cc23c0e 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -334,14 +334,12 @@ describe('SentrySpan', () => { expect(spanItem[1].items[0]!.name).toBe('scrubbed'); }); - test('runs `processSpan` hooks before a non-streamed `beforeSendSpan` for standalone spans', () => { - // Integrations (e.g. Replay attaching `sentry.replay_id`) enrich spans via the `processSpan` hook. - // The static-callback standalone path must run those hooks, and (as in `captureSpan`) run them - // before `beforeSendSpan`, so the callback sees the enrichment and its mutation lands on the span. - let seenReplayId: unknown; + test('does not apply scope attributes to standalone spans with a non-streamed `beforeSendSpan`', () => { + // Scope attributes can hold `{ unit, value }` objects, unexpected for a static callback, so they + // are not applied to the standalone (INP) span, just as they are not applied to transactions. + const seen: SpanJSON['data'][] = []; const beforeSendSpan = vi.fn((span: SpanJSON) => { - seenReplayId = span.data['sentry.replay_id']; - span.data['sentry.replay_id'] = 'scrubbed'; + seen.push({ ...span.data }); return span; }); const client = new TestClient( @@ -352,15 +350,7 @@ describe('SentrySpan', () => { }), ); setCurrentClient(client); - - client.on('processSpan', span => { - (span.attributes ?? (span.attributes = {}))['sentry.replay_id'] = 'abc'; - }); - - const envelopes: Envelope[] = []; - client.on('beforeEnvelope', envelope => { - envelopes.push(envelope); - }); + getCurrentScope().setAttribute('my.scope.attr', 'from-scope'); const span = new SentrySpan({ name: 'test', @@ -371,15 +361,7 @@ describe('SentrySpan', () => { }); span.end(); - // The callback saw the hook-added attribute (hooks ran first)... - expect(seenReplayId).toBe('abc'); - - // ...and its own change to that attribute is what gets sent. - const spanItem = envelopes[0]?.[1][0] as [ - { type: string }, - { items: Array<{ attributes: Record }> }, - ]; - expect(spanItem[1].items[0]!.attributes['sentry.replay_id']!.value).toBe('scrubbed'); + expect(seen[0]!['my.scope.attr']).toBeUndefined(); }); test('sends a standalone span on its own and excludes it from the parent transaction', async () => { From 758dbb4ef9a94d14095c4d9b2d5ab5729c56978b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 31 Jul 2026 10:22:36 -0400 Subject: [PATCH 6/7] ref(core): Drop top-level field fold from v1 to v2 span conversion 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. --- .../tracing/spans/spanJsonToStreamedSpan.ts | 25 +---------- .../spans/spanJsonToStreamedSpan.test.ts | 45 ------------------- 2 files changed, 1 insertion(+), 69 deletions(-) diff --git a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts index e16ac8b1d2a0..de664b802138 100644 --- a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts +++ b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts @@ -1,34 +1,11 @@ import type { RawAttributes } from '../../attributes'; -import { - SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, - SEMANTIC_ATTRIBUTE_PROFILE_ID, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, -} from '../../semanticAttributes'; import type { SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../types/span'; import { streamedSpanJsonToSerializedSpan } from '../../utils/spanUtils'; -// v1 SpanJSON mirrors some attributes as top-level fields (see `SentrySpan.getSpanJSON`). A -// `beforeSendSpan` callback edits the top-level field, so those edits have to be folded back into -// attributes, letting the top-level value win over the (initially identical) attribute. This is the -// inverse of `getSpanJSON` and mirrors how `convertSpanJsonToTransactionEvent` rebuilds `data`. -const TOP_LEVEL_ATTRIBUTE_FIELDS = [ - ['op', SEMANTIC_ATTRIBUTE_SENTRY_OP], - ['origin', SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], - ['profile_id', SEMANTIC_ATTRIBUTE_PROFILE_ID], - ['exclusive_time', SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME], -] as const; - /** * Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan. */ export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStreamedSpan { - const attributes = { ...(span.data as RawAttributes>) }; - - for (const [field, attribute] of TOP_LEVEL_ATTRIBUTE_FIELDS) { - attributes[attribute] = span[field] ?? attributes[attribute]; - } - const streamedSpan: StreamedSpanJSON = { trace_id: span.trace_id, span_id: span.span_id, @@ -38,7 +15,7 @@ export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStre end_timestamp: span.timestamp || span.start_timestamp, status: !span.status || span.status === 'ok' || span.status === 'cancelled' ? 'ok' : 'error', is_segment: false, - attributes, + attributes: { ...(span.data as RawAttributes>) }, links: span.links, }; diff --git a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts index b18d8ec9c263..94b25c590d19 100644 --- a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts @@ -79,51 +79,6 @@ describe('spanJsonToSerializedStreamedSpan', () => { expect(result.attributes['gen_ai.usage.output_tokens']).toEqual({ type: 'integer', value: 50 }); }); - it('maps top-level op/origin into sentry.op/sentry.origin attributes', () => { - const span = makeSpanJSON({ op: 'ui.interaction.click', origin: 'auto.http.browser.inp', data: {} }); - - const result = spanJsonToSerializedStreamedSpan(span); - - expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'ui.interaction.click' }); - expect(result.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.inp' }); - }); - - it('lets a mutated top-level op/origin win over the original data attribute', () => { - // A `beforeSendSpan` callback mutates the top-level field while the data attribute stays stale. - const span = makeSpanJSON({ - op: 'changed.op', - origin: 'changed.origin', - data: { 'sentry.op': 'stale.op', 'sentry.origin': 'stale.origin' }, - }); - - const result = spanJsonToSerializedStreamedSpan(span); - - expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'changed.op' }); - expect(result.attributes['sentry.origin']).toEqual({ type: 'string', value: 'changed.origin' }); - }); - - it('keeps the data attribute when there is no top-level op/origin', () => { - const span = makeSpanJSON({ op: undefined, origin: undefined, data: { 'sentry.op': 'from.data' } }); - - const result = spanJsonToSerializedStreamedSpan(span); - - expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'from.data' }); - expect(result.attributes['sentry.origin']).toBeUndefined(); - }); - - it('maps mutated top-level profile_id/exclusive_time back into attributes', () => { - const span = makeSpanJSON({ - profile_id: 'new-profile', - exclusive_time: 42, - data: { 'sentry.profile_id': 'stale-profile', 'sentry.exclusive_time': 1 }, - }); - - const result = spanJsonToSerializedStreamedSpan(span); - - expect(result.attributes['sentry.profile_id']).toEqual({ type: 'string', value: 'new-profile' }); - expect(result.attributes['sentry.exclusive_time']).toEqual({ type: 'integer', value: 42 }); - }); - it('carries over links', () => { const span = makeSpanJSON({ links: [{ trace_id: 'aabb', span_id: 'ccdd', sampled: true, attributes: { foo: 'bar' } }], From fc7c72236f48fb1c55e11db020550bd542f5a07b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 31 Jul 2026 10:32:25 -0400 Subject: [PATCH 7/7] docs(core): Mark standalone INP scaffolding for removal with the static 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. --- packages/browser-utils/src/metrics/webVitalSpans.ts | 4 ++++ packages/core/src/tracing/spans/captureSpan.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index eae7c66878f2..9c4f82249874 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -112,6 +112,8 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { // A standalone span is sent as a plain v2 span without running the `processSpan` hooks (see // `captureStandaloneSpanWithStaticCallback`), so Replay can't attach the replay id itself. Set it // here, mirroring Replay's `processSpan`, so INP keeps its replay association like it did on v1. + // TODO(standalone): remove once the static (transaction) trace lifecycle is dropped and INP always + // streams, at which point Replay's `processSpan` runs and attaches the replay id. if (standalone) { Object.assign(attributes, getReplayAttributes()); } @@ -135,6 +137,8 @@ interface ReplayIntegration extends Integration { getRecordingMode: () => 'session' | 'buffer' | undefined; } +// TODO(standalone): remove once the static (transaction) trace lifecycle is dropped; Replay's +// `processSpan` then attaches the replay id to the streamed INP span instead. function getReplayAttributes(): SpanAttributes { const replay = getClient()?.getIntegrationByName('Replay'); const replayId = replay?.getReplayId(true); diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index bc8fcd912c5f..39ad44585c5f 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -132,6 +132,8 @@ function commonSpanAttributes( serializedSegmentSpan: StreamedSpanJSON, client: Client, scopeData: ScopeData, + // TODO(standalone): remove this param (always include scope attributes) once the static (transaction) + // trace lifecycle is dropped and standalone spans no longer need to look transaction-shaped. includeScopeAttributes = true, ): RawAttributes> { const sdk = client.getSdkMetadata();