From 6af286751fa2afa3a96be536df60b1cc46d59cc5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 24 Jul 2026 18:32:38 +0200 Subject: [PATCH 01/13] feat(core)!: Stream beforeSendSpan payloads by default Make streamed span JSON the default beforeSendSpan contract and provide withStaticSpan for callbacks that still process transaction span JSON. Deprecate withStreamedSpan for removal in version 12. BREAKING CHANGE: beforeSendSpan receives StreamedSpanJSON by default. Fixes #22349 Co-Authored-By: Cursor Co-authored-by: Cursor --- .../beforeSendSpan-streamed/init.js | 6 +-- .../beforeSendSpan-streamed/scenario.ts | 5 +- .../rollup-utils/plugins/bundlePlugins.mjs | 3 +- packages/astro/src/index.server.ts | 2 + packages/astro/src/index.types.ts | 2 + packages/aws-serverless/src/index.ts | 2 + packages/browser/src/exports.ts | 2 + .../browser/src/integrations/spanstreaming.ts | 10 ++-- .../test/integrations/spanstreaming.test.ts | 6 +-- packages/bun/src/index.ts | 2 + packages/cloudflare/src/index.ts | 2 + packages/core/src/client.ts | 4 +- .../core/src/integrations/spanStreaming.ts | 8 +-- packages/core/src/shared-exports.ts | 8 ++- .../core/src/tracing/spans/beforeSendSpan.ts | 53 +++++++++++++------ packages/core/src/types/options.ts | 21 ++++++-- .../test/integrations/spanStreaming.test.ts | 6 +-- packages/core/test/lib/client.test.ts | 33 +++++++----- .../core/test/lib/tracing/sentrySpan.test.ts | 19 ++++--- .../lib/tracing/spans/beforeSendSpan.test.ts | 46 ++++++++++++---- .../lib/tracing/spans/captureSpan.test.ts | 13 +++-- packages/deno/src/index.ts | 2 + packages/effect/src/index.types.ts | 2 + packages/elysia/src/index.ts | 2 + packages/google-cloud-serverless/src/index.ts | 2 + packages/nextjs/src/index.types.ts | 2 + packages/node/src/index.ts | 3 +- packages/nuxt/src/index.types.ts | 2 + packages/react-router/src/index.types.ts | 2 + packages/remix/src/cloudflare/index.ts | 2 + packages/remix/src/index.types.ts | 2 + packages/remix/src/server/index.ts | 2 + packages/solidstart/src/index.types.ts | 2 + packages/solidstart/src/server/index.ts | 2 + packages/sveltekit/src/index.types.ts | 2 + packages/sveltekit/src/server/index.ts | 2 + packages/sveltekit/src/worker/index.ts | 2 + .../tanstackstart-react/src/index.types.ts | 2 + packages/vercel-edge/src/index.ts | 2 + 39 files changed, 204 insertions(+), 86 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/beforeSendSpan-streamed/init.js b/dev-packages/browser-integration-tests/suites/public-api/beforeSendSpan-streamed/init.js index f099c1f61c3e..a3fae7e92b48 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/beforeSendSpan-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/beforeSendSpan-streamed/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1, - beforeSendSpan: Sentry.withStreamedSpan(span => { + beforeSendSpan: span => { if (span.attributes['sentry.op'] === 'pageload') { span.name = 'customPageloadSpanName'; span.links = [ @@ -24,5 +24,5 @@ Sentry.init({ span.status = 'something'; } return span; - }), + }, }); diff --git a/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-streamed/scenario.ts index 3c7c0ed81edf..3e885e4170f9 100644 --- a/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-streamed/scenario.ts +++ b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-streamed/scenario.ts @@ -4,10 +4,9 @@ import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, - traceLifecycle: 'stream', transport: loggingTransport, release: '1.0.0', - beforeSendSpan: Sentry.withStreamedSpan(span => { + beforeSendSpan: span => { if (span.name === 'test-child-span') { span.name = 'customChildSpanName'; if (!span.attributes) { @@ -27,7 +26,7 @@ Sentry.init({ ]; } return span; - }), + }, }); Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { diff --git a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs index ecf793e49585..665038a1dcb3 100644 --- a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs +++ b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs @@ -150,7 +150,8 @@ export function makeTerserPlugin() { '_resolveFilename', // Set on e.g. the shim feedbackIntegration to be able to detect it '_isShim', - // Marker set by `withStreamedSpan()` to tag streamed `beforeSendSpan` callbacks + // Markers used to distinguish static and explicitly streamed `beforeSendSpan` callbacks + '_static', '_streamed', // This is used in metadata integration '_sentryModuleMetadata', diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 245c0993459d..2e09c7c0e642 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -172,6 +172,8 @@ export { unleashIntegration, growthbookIntegration, spanStreamingIntegration, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, metrics, } from '@sentry/node'; diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts index 0c8a5cca7bb6..b3f5037f8751 100644 --- a/packages/astro/src/index.types.ts +++ b/packages/astro/src/index.types.ts @@ -21,6 +21,8 @@ export declare function init(options: Options | clientSdk.BrowserOptions | NodeO export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// eslint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index faa62b15b6d8..9c2dbac85ea0 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -159,6 +159,8 @@ export { growthbookIntegration, metrics, spanStreamingIntegration, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index d1e9d7662db3..e3a7e5da2850 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -72,6 +72,8 @@ export { spanToTraceHeader, spanToBaggageHeader, updateSpanName, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, metrics, } from '@sentry/core/browser'; diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index e8ed1a0de537..617d1a700f5d 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -4,7 +4,7 @@ import { debug, defineIntegration, hasSpanStreamingEnabled, - isStreamedBeforeSendSpanCallback, + isStaticBeforeSendSpanCallback, SpanBuffer, spanIsSampled, } from '@sentry/core/browser'; @@ -26,12 +26,12 @@ export const spanStreamingIntegration = defineIntegration(() => { } const beforeSendSpan = clientOptions.beforeSendSpan; - // If users misconfigure their SDK by opting into span streaming but - // using an incompatible beforeSendSpan callback, we fall back to the static trace lifecycle. - if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) { + if (isStaticBeforeSendSpanCallback(beforeSendSpan)) { clientOptions.traceLifecycle = 'static'; DEBUG_BUILD && - debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`); + debug.warn( + `SpanStreaming integration is incompatible with a beforeSendSpan callback using \`withStaticSpan\`! ${fallbackMsg}`, + ); return; } diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts index db4209a823e8..b1c9705a1871 100644 --- a/packages/browser/test/integrations/spanstreaming.test.ts +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -64,21 +64,21 @@ describe('spanStreamingIntegration', () => { }, ); - it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => { + it('falls back to static trace lifecycle if beforeSendSpan is marked as static', () => { const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); const client = new BrowserClient({ ...getDefaultBrowserClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], traceLifecycle: 'stream', - beforeSendSpan: (span: Span) => span, + beforeSendSpan: SentryCore.withStaticSpan(span => span), }); SentryCore.setCurrentClient(client); client.init(); expect(debugSpy).toHaveBeenCalledWith( - 'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.', + 'SpanStreaming integration is incompatible with a beforeSendSpan callback using `withStaticSpan`! Falling back to static trace lifecycle.', ); debugSpy.mockRestore(); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index deecd197a3a9..46864a390e8b 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -176,6 +176,8 @@ export { unleashIntegration, metrics, spanStreamingIntegration, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 2a0a3050f8e3..05fb34e15789 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -105,6 +105,8 @@ export { growthbookIntegration, logger, metrics, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 8de2eb856239..4c491f3adaa2 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -11,7 +11,7 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal'; import type { Scope } from './scope'; import { updateSession } from './session'; import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext'; -import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; +import { isStaticBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; import { extractGenAiSpansFromEvent } from './tracing/spans/extractGenAiSpans'; import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base'; import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types/breadcrumb'; @@ -1677,7 +1677,7 @@ function processBeforeSend( // oxlint-disable-next-line typescript/no-deprecated beforeSendTransaction, } = options; - const beforeSendSpan = !isStreamedBeforeSendSpanCallback(options.beforeSendSpan) && options.beforeSendSpan; + const beforeSendSpan = isStaticBeforeSendSpanCallback(options.beforeSendSpan) && options.beforeSendSpan; let processedEvent = event; diff --git a/packages/core/src/integrations/spanStreaming.ts b/packages/core/src/integrations/spanStreaming.ts index ffd5d0bb7694..36bc2ff55c2d 100644 --- a/packages/core/src/integrations/spanStreaming.ts +++ b/packages/core/src/integrations/spanStreaming.ts @@ -1,7 +1,7 @@ import type { IntegrationFn } from '../types/integration'; import { DEBUG_BUILD } from '../debug-build'; import { defineIntegration } from '../integration'; -import { isStreamedBeforeSendSpanCallback } from '../tracing/spans/beforeSendSpan'; +import { isStaticBeforeSendSpanCallback } from '../tracing/spans/beforeSendSpan'; import { captureSpan } from '../tracing/spans/captureSpan'; import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled'; import { SpanBuffer } from '../tracing/spans/spanBuffer'; @@ -24,10 +24,12 @@ export const spanStreamingIntegration = defineIntegration(() => { } const beforeSendSpan = clientOptions.beforeSendSpan; - if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) { + if (isStaticBeforeSendSpanCallback(beforeSendSpan)) { clientOptions.traceLifecycle = 'static'; DEBUG_BUILD && - debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`); + debug.warn( + `SpanStreaming integration is incompatible with a beforeSendSpan callback using \`withStaticSpan\`! ${fallbackMsg}`, + ); return; } diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 4034ff73d37d..82c7dbd8f8fe 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -85,8 +85,12 @@ export { prepareEvent } from './utils/prepareEvent'; export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; -export { withStreamedSpan } from './tracing/spans/beforeSendSpan'; -export { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; +export { + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated + withStreamedSpan, +} from './tracing/spans/beforeSendSpan'; +export { isStaticBeforeSendSpanCallback, isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; export { safeSetSpanJSONAttributes } from './tracing/spans/captureSpan'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; diff --git a/packages/core/src/tracing/spans/beforeSendSpan.ts b/packages/core/src/tracing/spans/beforeSendSpan.ts index 57797e5daead..3306a594c94f 100644 --- a/packages/core/src/tracing/spans/beforeSendSpan.ts +++ b/packages/core/src/tracing/spans/beforeSendSpan.ts @@ -1,42 +1,63 @@ -import type { CoreOptions } from '../../types/options'; -import type { BeforeSendStreamedSpanCallback } from '../../types/options'; -import type { StreamedSpanJSON } from '../../types/span'; +import type { BeforeSendStaticSpanCallback, BeforeSendStreamedSpanCallback } from '../../types/options'; +import type { SpanJSON, StreamedSpanJSON } from '../../types/span'; import { addNonEnumerableProperty } from '../../utils/object'; -type StaticBeforeSendSpanCallback = CoreOptions['beforeSendSpan']; - /** - * A wrapper to use the new span format in your `beforeSendSpan` callback. + * A wrapper to use the legacy transaction span format in your `beforeSendSpan` callback. * - * When using `traceLifecycle: 'stream'`, wrap your callback with this function - * to receive and return {@link StreamedSpanJSON} instead of the standard {@link SpanJSON}. + * When using `traceLifecycle: 'static'`, wrap your callback with this function + * to receive and return {@link SpanJSON} instead of {@link StreamedSpanJSON}. * * @example * * Sentry.init({ - * traceLifecycle: 'stream', - * beforeSendSpan: withStreamedSpan((span) => { - * // span is of type StreamedSpanJSON + * traceLifecycle: 'static', + * beforeSendSpan: withStaticSpan((span) => { + * // span is of type SpanJSON * return span; * }), * }); * + * @param callback - The callback function that receives and returns a {@link SpanJSON}. + * @returns A callback that is compatible with the `beforeSendSpan` option when using `traceLifecycle: 'static'`. + */ +export function withStaticSpan(callback: (span: SpanJSON) => SpanJSON): BeforeSendStaticSpanCallback { + addNonEnumerableProperty(callback, '_static', true); + return callback as BeforeSendStaticSpanCallback; +} + +/** + * A wrapper to explicitly use the streamed span format in your `beforeSendSpan` callback. + * + * @deprecated `beforeSendSpan` callbacks receive {@link StreamedSpanJSON} by default. + * This function will be removed in SDK version 12. + * * @param callback - The callback function that receives and returns a {@link StreamedSpanJSON}. - * @returns A callback that is compatible with the `beforeSendSpan` option when using `traceLifecycle: 'stream'`. + * @returns The provided callback. */ export function withStreamedSpan( callback: (span: StreamedSpanJSON) => StreamedSpanJSON, -): StaticBeforeSendSpanCallback & { _streamed: true } { +): BeforeSendStreamedSpanCallback & { _streamed: true } { addNonEnumerableProperty(callback, '_streamed', true); - return callback as unknown as StaticBeforeSendSpanCallback & { _streamed: true }; + return callback as BeforeSendStreamedSpanCallback & { _streamed: true }; +} + +/** + * Typesafe check to identify if a `beforeSendSpan` callback expects the static span JSON format. + * + * @param callback - The `beforeSendSpan` callback to check. + * @returns `true` if the callback was wrapped with {@link withStaticSpan}. + */ +export function isStaticBeforeSendSpanCallback(callback: unknown): callback is BeforeSendStaticSpanCallback { + return !!callback && typeof callback === 'function' && '_static' in callback && !!callback._static; } /** * Typesafe check to identify if a `beforeSendSpan` callback expects the streamed span JSON format. * * @param callback - The `beforeSendSpan` callback to check. - * @returns `true` if the callback was wrapped with {@link withStreamedSpan}. + * @returns `true` unless the callback was wrapped with {@link withStaticSpan}. */ export function isStreamedBeforeSendSpanCallback(callback: unknown): callback is BeforeSendStreamedSpanCallback { - return !!callback && typeof callback === 'function' && '_streamed' in callback && !!callback._streamed; + return !!callback && typeof callback === 'function' && !isStaticBeforeSendSpanCallback(callback); } diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 1e753620b2a9..eba82dc3330f 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -617,14 +617,14 @@ export interface ClientOptions SpanJSON) & { _streamed?: true }; + beforeSendSpan?: BeforeSendStreamedSpanCallback | BeforeSendStaticSpanCallbackMarker; /** * An event-processing callback for transaction events, guaranteed to be invoked after all other event @@ -671,12 +671,23 @@ export interface ClientOptions StreamedSpanJSON) & { /** - * When true, indicates this callback is designed to handle the {@link StreamedSpanJSON} format - * used with `traceLifecycle: 'stream'`. Set this by wrapping your callback with `withStreamedSpan`. + * @deprecated `beforeSendSpan` callbacks use {@link StreamedSpanJSON} by default. */ _streamed?: true; }; +declare const beforeSendStaticSpanCallback: unique symbol; + +type BeforeSendStaticSpanCallbackMarker = { + readonly [beforeSendStaticSpanCallback]: true; +}; + +/** A callback for processing spans sent as part of transaction events. */ +export type BeforeSendStaticSpanCallback = ((span: SpanJSON) => SpanJSON) & + BeforeSendStaticSpanCallbackMarker & { + _static: true; + }; + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, diff --git a/packages/core/test/integrations/spanStreaming.test.ts b/packages/core/test/integrations/spanStreaming.test.ts index 8b2badf575ff..a2cf52f4deed 100644 --- a/packages/core/test/integrations/spanStreaming.test.ts +++ b/packages/core/test/integrations/spanStreaming.test.ts @@ -58,21 +58,21 @@ describe('spanStreamingIntegration (core)', () => { }, ); - it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => { + it('falls back to static trace lifecycle if beforeSendSpan is marked as static', () => { const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); const client = new TestClient({ ...getDefaultTestClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], traceLifecycle: 'stream', - beforeSendSpan: (span: SentryCore.SpanJSON) => span, + beforeSendSpan: SentryCore.withStaticSpan(span => span), }); SentryCore.setCurrentClient(client); client.init(); expect(debugSpy).toHaveBeenCalledWith( - 'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.', + 'SpanStreaming integration is incompatible with a beforeSendSpan callback using `withStaticSpan`! Falling back to static trace lifecycle.', ); debugSpy.mockRestore(); diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index d30314073bcf..37b5d743991a 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -11,6 +11,7 @@ import { setCurrentClient, SyncPromise, withMonitor, + withStaticSpan, } from '../../src'; import * as integrationModule from '../../src/integration'; import * as logsInternalModule from '../../src/logs/internal'; @@ -1180,7 +1181,7 @@ describe('Client', () => { test('calls `beforeSendSpan` and uses original spans without any changes', () => { expect.assertions(3); - const beforeSendSpan = vi.fn(span => span); + const beforeSendSpan = withStaticSpan(vi.fn(span => span)); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan }); const client = new TestClient(options); @@ -1399,14 +1400,16 @@ describe('Client', () => { }); test('does not modify existing contexts for root span in `beforeSendSpan`', () => { - const beforeSendSpan = vi.fn((span: SpanJSON) => { - return { - ...span, - data: { - modified: 'true', - }, - }; - }); + const beforeSendSpan = withStaticSpan( + vi.fn((span: SpanJSON) => { + return { + ...span, + data: { + modified: 'true', + }, + }; + }), + ); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan }); const client = new TestClient(options); @@ -1514,10 +1517,12 @@ describe('Client', () => { test('calls `beforeSendSpan` and uses the modified spans', () => { expect.assertions(4); - const beforeSendSpan = vi.fn(span => { - span.data = { version: 'bravo' }; - return span; - }); + const beforeSendSpan = withStaticSpan( + vi.fn(span => { + span.data = { version: 'bravo' }; + return span; + }), + ); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan }); const client = new TestClient(options); @@ -1596,7 +1601,7 @@ describe('Client', () => { test('does not discard span and warn when returning null from `beforeSendSpan', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const beforeSendSpan = vi.fn(() => null as unknown as SpanJSON); + const beforeSendSpan = withStaticSpan(vi.fn(() => null as unknown as SpanJSON)); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan }); const client = new TestClient(options); diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 7f509cc23c0e..40f7a1344f9c 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -14,6 +14,7 @@ import { markSpanForOtelSourceInference, spanSourceWasExplicitlySet, } from '../../../src/tracing/utils'; +import { withStaticSpan } from '../../../src/tracing/spans/beforeSendSpan'; import type { Envelope } from '../../../src/types/envelope'; import type { SpanJSON } from '../../../src/types/span'; import { spanToJSON, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils'; @@ -272,7 +273,7 @@ describe('SentrySpan', () => { }); test('sends the span if `beforeSendSpan` does not modify the span', () => { - const beforeSendSpan = vi.fn(span => span); + const beforeSendSpan = withStaticSpan(vi.fn(span => span)); const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://username@domain/123', @@ -295,11 +296,11 @@ describe('SentrySpan', () => { expect(mockSend).toHaveBeenCalled(); }); - test('runs a non-streamed `beforeSendSpan` for standalone spans', () => { + test('runs a static `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 beforeSendSpan = withStaticSpan(vi.fn((span: SpanJSON) => ({ ...span, description: 'scrubbed' }))); const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://username@domain/123', @@ -334,14 +335,16 @@ describe('SentrySpan', () => { expect(spanItem[1].items[0]!.name).toBe('scrubbed'); }); - test('does not apply scope attributes to standalone spans with a non-streamed `beforeSendSpan`', () => { + test('does not apply scope attributes to standalone spans with a static `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) => { - seen.push({ ...span.data }); - return span; - }); + const beforeSendSpan = withStaticSpan( + vi.fn((span: SpanJSON) => { + seen.push({ ...span.data }); + return span; + }), + ); const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://username@domain/123', diff --git a/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts index 79fd838a1b27..2616415d8bf2 100644 --- a/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts @@ -1,26 +1,52 @@ import { describe, expect, it, vi } from 'vitest'; -import { withStreamedSpan } from '../../../../src'; -import { isStreamedBeforeSendSpanCallback } from '../../../../src/tracing/spans/beforeSendSpan'; +import { withStaticSpan, withStreamedSpan } from '../../../../src'; +import { + isStaticBeforeSendSpanCallback, + isStreamedBeforeSendSpanCallback, +} from '../../../../src/tracing/spans/beforeSendSpan'; + +describe('beforeSendSpan callback formats', () => { + describe('withStaticSpan', () => { + it('marks the callback as static without making the marker enumerable', () => { + const beforeSendSpan = vi.fn(); + const wrapped = withStaticSpan(beforeSendSpan); + + expect(wrapped._static).toBe(true); + expect(Object.keys(wrapped)).not.toContain('_static'); + }); + }); -describe('beforeSendSpan for span streaming', () => { describe('withStreamedSpan', () => { - it('should be able to modify the span', () => { + it('marks the callback as streamed', () => { const beforeSendSpan = vi.fn(); const wrapped = withStreamedSpan(beforeSendSpan); expect(wrapped._streamed).toBe(true); }); }); + describe('isStaticBeforeSendSpanCallback', () => { + it('returns true if the callback is wrapped with withStaticSpan', () => { + const wrapped = withStaticSpan(vi.fn()); + + expect(isStaticBeforeSendSpanCallback(wrapped)).toBe(true); + }); + + it('returns false for an unwrapped callback', () => { + expect(isStaticBeforeSendSpanCallback(vi.fn())).toBe(false); + }); + }); + describe('isStreamedBeforeSendSpanCallback', () => { + it('returns true for an unwrapped callback', () => { + expect(isStreamedBeforeSendSpanCallback(vi.fn())).toBe(true); + }); + it('returns true if the callback is wrapped with withStreamedSpan', () => { - const beforeSendSpan = vi.fn(); - const wrapped = withStreamedSpan(beforeSendSpan); - expect(isStreamedBeforeSendSpanCallback(wrapped)).toBe(true); + expect(isStreamedBeforeSendSpanCallback(withStreamedSpan(vi.fn()))).toBe(true); }); - it('returns false if the callback is not wrapped with withStreamedSpan', () => { - const beforeSendSpan = vi.fn(); - expect(isStreamedBeforeSendSpanCallback(beforeSendSpan)).toBe(false); + it('returns false if the callback is wrapped with withStaticSpan', () => { + expect(isStreamedBeforeSendSpanCallback(withStaticSpan(vi.fn()))).toBe(false); }); }); }); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 3f40374908e2..c122451ba100 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -15,8 +15,8 @@ import { SEMANTIC_ATTRIBUTE_USER_USERNAME, startInactiveSpan, startSpan, + withStaticSpan, withScope, - withStreamedSpan, } from '../../../../src'; import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; import { scopeContextsToSpanAttributes } from '../../../../src/tracing/spans/scopeContextAttributes'; @@ -471,8 +471,8 @@ describe('captureSpan', () => { }); describe('beforeSendSpan', () => { - it('applies beforeSendSpan if it is a span streaming compatible callback', () => { - const beforeSendSpan = withStreamedSpan(vi.fn(span => span)); + it('applies an unwrapped beforeSendSpan callback', () => { + const beforeSendSpan = vi.fn(span => span); const client = new TestClient( getDefaultTestClientOptions({ @@ -492,8 +492,8 @@ describe('captureSpan', () => { expect(beforeSendSpan).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId })); }); - it("doesn't apply beforeSendSpan if it is not a span streaming compatible callback", () => { - const beforeSendSpan = vi.fn(span => span); + it("doesn't apply beforeSendSpan if it is marked as static", () => { + const beforeSendSpan = withStaticSpan(vi.fn(span => span)); const client = new TestClient( getDefaultTestClientOptions({ @@ -515,8 +515,7 @@ describe('captureSpan', () => { it('logs a warning if the beforeSendSpan callback returns null', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - // @ts-expect-error - the types dissallow returning null but this is javascript, so we need to test it - const beforeSendSpan = withStreamedSpan(() => null); + const beforeSendSpan = vi.fn(() => null as unknown as StreamedSpanJSON); const client = new TestClient( getDefaultTestClientOptions({ diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 65a708042822..e5ff095d1589 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -97,6 +97,8 @@ export { wrapMcpServerWithSentry, featureFlagsIntegration, metrics, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, logger, consoleLoggingIntegration, diff --git a/packages/effect/src/index.types.ts b/packages/effect/src/index.types.ts index e1544da73485..940d59f49f7f 100644 --- a/packages/effect/src/index.types.ts +++ b/packages/effect/src/index.types.ts @@ -22,6 +22,8 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// eslint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index e6aba2167d7f..0ed6b676034e 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -154,6 +154,8 @@ export { unleashIntegration, metrics, spanStreamingIntegration, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, bunServerIntegration, makeFetchTransport, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 3afeb1b9dc7d..10a820c19139 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -159,6 +159,8 @@ export { unleashIntegration, metrics, spanStreamingIntegration, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index e0d918708bb1..151d338cf3ce 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -27,6 +27,8 @@ export declare const consoleIntegration: typeof serverSdk.consoleIntegration; // Node-only at runtime; the edge build exports an inert shim so named imports resolve in edge-compiled modules. export declare const pinoIntegration: typeof serverSdk.pinoIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// eslint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; // Different implementation in server and worker diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 997986bbb3a5..ab6946366c7c 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -176,7 +176,8 @@ export type { CaptureContext, } from '@sentry/core'; -export { metrics, withStreamedSpan } from '@sentry/core'; +// eslint-disable-next-line typescript/no-deprecated +export { metrics, withStaticSpan, withStreamedSpan } from '@sentry/core'; export * as logger from './logs/exports'; export { childProcessIntegration } from './integrations/childProcess'; diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index e058e0bfd099..e2e8ba3b66d0 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -17,6 +17,8 @@ export declare function init(options: Options | SentryNuxtClientOptions | Sentry export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// eslint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts index e7fb4382d0f2..16f2eca441a3 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -17,6 +17,8 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// eslint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const defaultStackParser: StackParser; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index ef2f89cee2a5..a91869f0d976 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -117,6 +117,8 @@ export { spanToTraceHeader, spanToBaggageHeader, updateSpanName, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, featureFlagsIntegration, } from '@sentry/core'; diff --git a/packages/remix/src/index.types.ts b/packages/remix/src/index.types.ts index 1a54068ae019..867bc011dfc2 100644 --- a/packages/remix/src/index.types.ts +++ b/packages/remix/src/index.types.ts @@ -19,6 +19,8 @@ export declare const browserTracingIntegration: typeof clientSdk.browserTracingI export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// eslint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index a1555930bad4..5266cca46dee 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -132,6 +132,8 @@ export { createConsolaReporter, createSentryWinstonTransport, spanStreamingIntegration, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/solidstart/src/index.types.ts b/packages/solidstart/src/index.types.ts index d984e1384a93..1b458e7cf929 100644 --- a/packages/solidstart/src/index.types.ts +++ b/packages/solidstart/src/index.types.ts @@ -19,6 +19,8 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// eslint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 6fa2a75fcfdc..e955e115b2cf 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -131,6 +131,8 @@ export { createConsolaReporter, createSentryWinstonTransport, spanStreamingIntegration, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index 1f4bbae0b4a9..0a328b38ab1b 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -48,6 +48,8 @@ export declare function wrapLoadWithSentry any>(orig export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// eslint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; // Different implementation in server and worker diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index d72e610185a3..e42178dfa0c7 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -136,6 +136,8 @@ export { vercelAIIntegration, metrics, spanStreamingIntegration, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index de2146c9e259..e52ebc8aedc4 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -83,6 +83,8 @@ export { withIsolationScope, withMonitor, withScope, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, supabaseIntegration, instrumentSupabaseClient, diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index d6465a0e14d2..faedd2ab18b5 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -19,6 +19,8 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// eslint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index c67b84ed1ff7..92aaa77fcc9a 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -99,6 +99,8 @@ export { featureFlagsIntegration, logger, metrics, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; From 389094f98c31ed60450ee268931919f83f04aa10 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 28 Jul 2026 14:05:56 +0200 Subject: [PATCH 02/13] ref(core): Centralize span lifecycle and `beforeSendSpan` validation `withStreamedSpan` no longer marks its callback. Nothing reads `_streamed` since `isStreamedBeforeSendSpanCallback` became "not wrapped with `withStaticSpan`", so the wrapper returns the callback unchanged instead of mutating a function it was handed. Resolve `traceLifecycle` once in the `Client` constructor, where any value other than `'static'` normalizes to the `'stream'` default. Neither span streaming integration writes back to the option anymore, which removes an ordering hazard: integrations that read `hasSpanStreamingEnabled` during their own `setup` could observe the value before or after the mutation depending on integration order. The registration gates now test `!== 'static'` so they agree with the normalized value; previously an unknown value failed the gate but still resolved to `'stream'`, leaving nothing to flush spans. Move the `beforeSendSpan` format check into the constructor as well. A callback is only invoked for the span format matching the trace lifecycle, so a mismatch means it is never called. The client can warn where the integration could not: with `traceLifecycle: 'static'` the streaming integration is never registered, so an unwrapped callback previously went unreported. A `withStaticSpan` callback under `traceLifecycle: 'stream'` no longer downgrades the lifecycle to `'static'`. Opting out of streaming requires setting `traceLifecycle: 'static'` explicitly. Refs #22349 Co-Authored-By: Claude Opus 5 (1M context) --- .../rollup-utils/plugins/bundlePlugins.mjs | 3 +- packages/astro/src/index.server.ts | 2 +- packages/astro/src/index.types.ts | 2 +- packages/aws-serverless/src/index.ts | 2 +- packages/browser/src/exports.ts | 2 +- .../browser/src/integrations/spanstreaming.ts | 23 +---- packages/browser/src/sdk.ts | 4 +- .../test/integrations/spanstreaming.test.ts | 64 +++++++------- packages/bun/src/index.ts | 2 +- packages/cloudflare/src/index.ts | 2 +- packages/core/src/client.ts | 23 ++++- .../core/src/integrations/spanStreaming.ts | 22 +---- packages/core/src/server-runtime-client.ts | 4 +- packages/core/src/shared-exports.ts | 2 +- .../core/src/tracing/spans/beforeSendSpan.ts | 7 +- packages/core/src/types/options.ts | 7 +- .../test/integrations/spanStreaming.test.ts | 63 +++++++------- packages/core/test/lib/client.test.ts | 83 +++++++++++++++++++ .../lib/tracing/spans/beforeSendSpan.test.ts | 7 +- packages/deno/src/index.ts | 2 +- packages/effect/src/index.types.ts | 2 +- packages/elysia/src/index.ts | 2 +- packages/google-cloud-serverless/src/index.ts | 2 +- packages/nextjs/src/index.types.ts | 2 +- packages/node/src/index.ts | 2 +- packages/nuxt/src/index.types.ts | 2 +- packages/react-router/src/index.types.ts | 2 +- packages/remix/src/cloudflare/index.ts | 2 +- packages/remix/src/index.types.ts | 2 +- packages/remix/src/server/index.ts | 2 +- packages/solidstart/src/index.types.ts | 2 +- packages/solidstart/src/server/index.ts | 2 +- packages/sveltekit/src/index.types.ts | 2 +- packages/sveltekit/src/server/index.ts | 2 +- packages/sveltekit/src/worker/index.ts | 2 +- .../tanstackstart-react/src/index.types.ts | 2 +- packages/vercel-edge/src/index.ts | 2 +- 37 files changed, 210 insertions(+), 150 deletions(-) diff --git a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs index 665038a1dcb3..23aab90f4edf 100644 --- a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs +++ b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs @@ -150,9 +150,8 @@ export function makeTerserPlugin() { '_resolveFilename', // Set on e.g. the shim feedbackIntegration to be able to detect it '_isShim', - // Markers used to distinguish static and explicitly streamed `beforeSendSpan` callbacks + // Marker used to detect `beforeSendSpan` callbacks expecting the static span format '_static', - '_streamed', // This is used in metadata integration '_sentryModuleMetadata', ], diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 2e09c7c0e642..c011b2a9d4b2 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -173,7 +173,7 @@ export { growthbookIntegration, spanStreamingIntegration, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, metrics, } from '@sentry/node'; diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts index b3f5037f8751..3472adfad925 100644 --- a/packages/astro/src/index.types.ts +++ b/packages/astro/src/index.types.ts @@ -22,7 +22,7 @@ export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsInteg export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const withStaticSpan: typeof clientSdk.withStaticSpan; -// eslint-disable-next-line typescript/no-deprecated +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 9c2dbac85ea0..78fd0d96733c 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -160,7 +160,7 @@ export { metrics, spanStreamingIntegration, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index e3a7e5da2850..86e038cbb850 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -73,7 +73,7 @@ export { spanToBaggageHeader, updateSpanName, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, metrics, } from '@sentry/core/browser'; diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index 617d1a700f5d..9b617ba4b014 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -4,34 +4,19 @@ import { debug, defineIntegration, hasSpanStreamingEnabled, - isStaticBeforeSendSpanCallback, SpanBuffer, spanIsSampled, } from '@sentry/core/browser'; import { DEBUG_BUILD } from '../debug-build'; +const INTEGRATION_NAME = 'SpanStreaming' as const; + export const spanStreamingIntegration = defineIntegration(() => { return { - name: 'SpanStreaming' as const, - + name: INTEGRATION_NAME, setup(client) { - const initialMessage = 'SpanStreaming integration requires'; - const fallbackMsg = 'Falling back to static trace lifecycle.'; - const clientOptions = client.getOptions(); - if (!hasSpanStreamingEnabled(client)) { - clientOptions.traceLifecycle = 'static'; - DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); - return; - } - - const beforeSendSpan = clientOptions.beforeSendSpan; - if (isStaticBeforeSendSpanCallback(beforeSendSpan)) { - clientOptions.traceLifecycle = 'static'; - DEBUG_BUILD && - debug.warn( - `SpanStreaming integration is incompatible with a beforeSendSpan callback using \`withStaticSpan\`! ${fallbackMsg}`, - ); + DEBUG_BUILD && debug.log(`[${INTEGRATION_NAME}] \`traceLifecycle\` is "static", skipping setup.`); return; } diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index d3bf3c960d6e..0309aed98248 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -116,9 +116,7 @@ export function init(options: BrowserOptions = {}): Client | undefined { defaultIntegrations, }); - options.traceLifecycle ??= 'stream'; - - if (options.traceLifecycle === 'stream' && !integrations.some(integration => integration.name === 'SpanStreaming')) { + if (options.traceLifecycle !== 'static' && !integrations.some(integration => integration.name === 'SpanStreaming')) { integrations.push(spanStreamingIntegration()); } diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts index b1c9705a1871..9fd7c934b1be 100644 --- a/packages/browser/test/integrations/spanstreaming.test.ts +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -40,62 +40,60 @@ describe('spanStreamingIntegration', () => { expect(integration.setup).toBeDefined(); }); - it.each(['static', 'somethingElse'])( - 'logs a warning if traceLifecycle is not set to "stream" but to %s', - traceLifecycle => { - const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); - const client = new BrowserClient({ - ...getDefaultBrowserClientOptions(), - dsn: 'https://username@domain/123', - integrations: [spanStreamingIntegration()], - // @ts-expect-error - we want to test the warning for invalid traceLifecycle values - traceLifecycle, - }); - - SentryCore.setCurrentClient(client); - client.init(); - - expect(debugSpy).toHaveBeenCalledWith( - 'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.', - ); - debugSpy.mockRestore(); - - expect(client.getOptions().traceLifecycle).toBe('static'); - }, - ); - - it('falls back to static trace lifecycle if beforeSendSpan is marked as static', () => { - const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + it('does not set up span streaming if traceLifecycle is "static"', () => { + const debugSpy = vi.spyOn(debug, 'log').mockImplementation(() => {}); const client = new BrowserClient({ ...getDefaultBrowserClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], - traceLifecycle: 'stream', - beforeSendSpan: SentryCore.withStaticSpan(span => span), + traceLifecycle: 'static', + tracesSampleRate: 1, }); SentryCore.setCurrentClient(client); client.init(); - expect(debugSpy).toHaveBeenCalledWith( - 'SpanStreaming integration is incompatible with a beforeSendSpan callback using `withStaticSpan`! Falling back to static trace lifecycle.', - ); + expect(debugSpy).toHaveBeenCalledWith('[SpanStreaming] `traceLifecycle` is "static", skipping setup.'); debugSpy.mockRestore(); - expect(client.getOptions().traceLifecycle).toBe('static'); + expect(MockSpanBuffer).not.toHaveBeenCalled(); + + // Without the hooks registered, ending a span must not enqueue anything + client.emit('afterSpanEnd', new SentryCore.SentrySpan({ name: 'test', sampled: true })); + expect(mockSpanBufferInstance.add).not.toHaveBeenCalled(); + }); + + it.each([ + ['explicitly set to "stream"', 'stream' as const], + ['left unset', undefined], + ])('sets up span streaming if traceLifecycle is %s', (_, traceLifecycle) => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(MockSpanBuffer).toHaveBeenCalledTimes(1); + expect(client.getOptions().traceLifecycle).toBe('stream'); }); - it('does nothing if traceLifecycle set to "stream"', () => { + it('still sets up span streaming if beforeSendSpan is wrapped with withStaticSpan', () => { const client = new BrowserClient({ ...getDefaultBrowserClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], traceLifecycle: 'stream', + beforeSendSpan: SentryCore.withStaticSpan(span => span), }); SentryCore.setCurrentClient(client); client.init(); + expect(MockSpanBuffer).toHaveBeenCalledTimes(1); expect(client.getOptions().traceLifecycle).toBe('stream'); }); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 46864a390e8b..e6639bf842d5 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -177,7 +177,7 @@ export { metrics, spanStreamingIntegration, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 05fb34e15789..78b808427b80 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -106,7 +106,7 @@ export { logger, metrics, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 4c491f3adaa2..e209ad97a401 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -234,7 +234,13 @@ export abstract class Client { * @param options Options for the client. */ protected constructor(options: O) { - this._options = { attachStacktrace: true, traceLifecycle: 'stream', ...options }; + // Any value other than `'static'` normalizes to the `'stream'` default, so that `traceLifecycle` + // is always one of the two known values for the rest of the SDK. + this._options = { + attachStacktrace: true, + ...options, + traceLifecycle: options.traceLifecycle === 'static' ? 'static' : 'stream', + }; this._integrations = {}; this._numProcessing = 0; this._outcomes = {}; @@ -250,6 +256,21 @@ export abstract class Client { DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.'); } + const { beforeSendSpan, traceLifecycle } = this._options; + // A `beforeSendSpan` callback is only invoked for the span format matching the trace lifecycle, + // so a mismatch means it is silently never called. + if ( + DEBUG_BUILD && + beforeSendSpan && + isStaticBeforeSendSpanCallback(beforeSendSpan) !== (traceLifecycle === 'static') + ) { + debug.warn( + `Ignoring \`beforeSendSpan\`: ${ + traceLifecycle === 'static' ? 'wrap it with' : 'remove' + } \`Sentry.withStaticSpan\` to use it with \`traceLifecycle: "${traceLifecycle}"\`.`, + ); + } + if (this._dsn) { const url = getEnvelopeEndpointWithUrlEncodedAuth( this._dsn, diff --git a/packages/core/src/integrations/spanStreaming.ts b/packages/core/src/integrations/spanStreaming.ts index 36bc2ff55c2d..662e8c83a093 100644 --- a/packages/core/src/integrations/spanStreaming.ts +++ b/packages/core/src/integrations/spanStreaming.ts @@ -1,35 +1,21 @@ import type { IntegrationFn } from '../types/integration'; import { DEBUG_BUILD } from '../debug-build'; import { defineIntegration } from '../integration'; -import { isStaticBeforeSendSpanCallback } from '../tracing/spans/beforeSendSpan'; import { captureSpan } from '../tracing/spans/captureSpan'; import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled'; import { SpanBuffer } from '../tracing/spans/spanBuffer'; import { debug } from '../utils/debug-logger'; import { spanIsSampled } from '../utils/spanUtils'; +const INTEGRATION_NAME = 'SpanStreaming' as const; + export const spanStreamingIntegration = defineIntegration(() => { return { - name: 'SpanStreaming' as const, + name: INTEGRATION_NAME, setup(client) { - const initialMessage = 'SpanStreaming integration requires'; - const fallbackMsg = 'Falling back to static trace lifecycle.'; - const clientOptions = client.getOptions(); - if (!hasSpanStreamingEnabled(client)) { - clientOptions.traceLifecycle = 'static'; - DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); - return; - } - - const beforeSendSpan = clientOptions.beforeSendSpan; - if (isStaticBeforeSendSpanCallback(beforeSendSpan)) { - clientOptions.traceLifecycle = 'static'; - DEBUG_BUILD && - debug.warn( - `SpanStreaming integration is incompatible with a beforeSendSpan callback using \`withStaticSpan\`! ${fallbackMsg}`, - ); + DEBUG_BUILD && debug.log(`[${INTEGRATION_NAME}] \`traceLifecycle\` is "static", skipping setup.`); return; } diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index ec10c516ef9b..ae564663f6c7 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -40,12 +40,10 @@ export class ServerRuntimeClient< public constructor(options: O) { addUserAgentToTransportHeaders(options); - options.traceLifecycle ??= 'stream'; - // When span streaming is enabled (`traceLifecycle: 'stream'`), the `spanStreamingIntegration` // is required to flush spans. We add it here so the individual server SDKs don't have to. // A user-provided `spanStreamingIntegration` always takes precedence over the one we add. - if (options.traceLifecycle === 'stream' && !options.integrations.some(i => i.name === 'SpanStreaming')) { + if (options.traceLifecycle !== 'static' && !options.integrations.some(i => i.name === 'SpanStreaming')) { options.integrations.push(spanStreamingIntegration()); } diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 82c7dbd8f8fe..704e71d511b8 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -87,7 +87,7 @@ export { createCheckInEnvelope } from './checkin'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; export { withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from './tracing/spans/beforeSendSpan'; export { isStaticBeforeSendSpanCallback, isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; diff --git a/packages/core/src/tracing/spans/beforeSendSpan.ts b/packages/core/src/tracing/spans/beforeSendSpan.ts index 3306a594c94f..a6065218438a 100644 --- a/packages/core/src/tracing/spans/beforeSendSpan.ts +++ b/packages/core/src/tracing/spans/beforeSendSpan.ts @@ -30,16 +30,15 @@ export function withStaticSpan(callback: (span: SpanJSON) => SpanJSON): BeforeSe * A wrapper to explicitly use the streamed span format in your `beforeSendSpan` callback. * * @deprecated `beforeSendSpan` callbacks receive {@link StreamedSpanJSON} by default. - * This function will be removed in SDK version 12. + * This function returns the callback unchanged and will be removed in SDK version 12. * * @param callback - The callback function that receives and returns a {@link StreamedSpanJSON}. * @returns The provided callback. */ export function withStreamedSpan( callback: (span: StreamedSpanJSON) => StreamedSpanJSON, -): BeforeSendStreamedSpanCallback & { _streamed: true } { - addNonEnumerableProperty(callback, '_streamed', true); - return callback as BeforeSendStreamedSpanCallback & { _streamed: true }; +): BeforeSendStreamedSpanCallback { + return callback; } /** diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index eba82dc3330f..b9ee4e298aae 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -669,12 +669,7 @@ export interface ClientOptions StreamedSpanJSON) & { - /** - * @deprecated `beforeSendSpan` callbacks use {@link StreamedSpanJSON} by default. - */ - _streamed?: true; -}; +export type BeforeSendStreamedSpanCallback = (span: StreamedSpanJSON) => StreamedSpanJSON; declare const beforeSendStaticSpanCallback: unique symbol; diff --git a/packages/core/test/integrations/spanStreaming.test.ts b/packages/core/test/integrations/spanStreaming.test.ts index a2cf52f4deed..3219fb4e14d4 100644 --- a/packages/core/test/integrations/spanStreaming.test.ts +++ b/packages/core/test/integrations/spanStreaming.test.ts @@ -34,57 +34,54 @@ describe('spanStreamingIntegration (core)', () => { expect(integration.setup).toBeDefined(); }); - it.each(['static', 'somethingElse'])( - 'logs a warning if traceLifecycle is not set to "stream" but to %s', - traceLifecycle => { - const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); - const client = new TestClient({ - ...getDefaultTestClientOptions(), - dsn: 'https://username@domain/123', - integrations: [spanStreamingIntegration()], - // @ts-expect-error - we want to test the warning for invalid traceLifecycle values - traceLifecycle, - }); - - SentryCore.setCurrentClient(client); - client.init(); - - expect(debugSpy).toHaveBeenCalledWith( - 'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.', - ); - debugSpy.mockRestore(); - - expect(client.getOptions().traceLifecycle).toBe('static'); - }, - ); - - it('falls back to static trace lifecycle if beforeSendSpan is marked as static', () => { - const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + it('does not set up span streaming if traceLifecycle is "static"', () => { + const debugSpy = vi.spyOn(debug, 'log').mockImplementation(() => {}); const client = new TestClient({ ...getDefaultTestClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], - traceLifecycle: 'stream', - beforeSendSpan: SentryCore.withStaticSpan(span => span), + traceLifecycle: 'static', + tracesSampleRate: 1, }); SentryCore.setCurrentClient(client); client.init(); - expect(debugSpy).toHaveBeenCalledWith( - 'SpanStreaming integration is incompatible with a beforeSendSpan callback using `withStaticSpan`! Falling back to static trace lifecycle.', - ); + expect(debugSpy).toHaveBeenCalledWith('[SpanStreaming] `traceLifecycle` is "static", skipping setup.'); debugSpy.mockRestore(); - expect(client.getOptions().traceLifecycle).toBe('static'); + expect(MockSpanBuffer).not.toHaveBeenCalled(); + + // Without the hooks registered, ending a span must not enqueue anything + client.emit('afterSpanEnd', new SentryCore.SentrySpan({ name: 'test', sampled: true })); + expect(mockSpanBufferInstance.add).not.toHaveBeenCalled(); + }); + + it.each([ + ['explicitly set to "stream"', 'stream' as const], + ['left unset', undefined], + ])('sets up span streaming if traceLifecycle is %s', (_, traceLifecycle) => { + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(MockSpanBuffer).toHaveBeenCalledWith(client); + expect(client.getOptions().traceLifecycle).toBe('stream'); }); - it('sets up buffer when traceLifecycle is "stream"', () => { + it('still sets up span streaming if beforeSendSpan is wrapped with withStaticSpan', () => { const client = new TestClient({ ...getDefaultTestClientOptions(), dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], traceLifecycle: 'stream', + beforeSendSpan: SentryCore.withStaticSpan(span => span), }); SentryCore.setCurrentClient(client); diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 37b5d743991a..3efb54d71468 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -95,6 +95,80 @@ describe('Client', () => { expect(consoleWarnSpy).toHaveBeenCalledTimes(0); consoleWarnSpy.mockRestore(); }); + + test('warns that a streamed beforeSendSpan is ignored with traceLifecycle "static"', () => { + const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + + new TestClient( + getDefaultTestClientOptions({ dsn: PUBLIC_DSN, traceLifecycle: 'static', beforeSendSpan: span => span }), + ); + + expect(warnSpy).toHaveBeenCalledWith( + 'Ignoring `beforeSendSpan`: wrap it with `Sentry.withStaticSpan` to use it with `traceLifecycle: "static"`.', + ); + warnSpy.mockRestore(); + }); + + test('warns that a static beforeSendSpan is ignored with traceLifecycle "stream"', () => { + const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + + new TestClient( + getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + traceLifecycle: 'stream', + beforeSendSpan: withStaticSpan(span => span), + }), + ); + + expect(warnSpy).toHaveBeenCalledWith( + 'Ignoring `beforeSendSpan`: remove `Sentry.withStaticSpan` to use it with `traceLifecycle: "stream"`.', + ); + warnSpy.mockRestore(); + }); + + test('reports the normalized traceLifecycle when warning about an unknown value', () => { + const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + + new TestClient( + getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + // @ts-expect-error - we want to test normalization of invalid traceLifecycle values + traceLifecycle: 'somethingElse', + beforeSendSpan: withStaticSpan(span => span), + }), + ); + + expect(warnSpy).toHaveBeenCalledWith( + 'Ignoring `beforeSendSpan`: remove `Sentry.withStaticSpan` to use it with `traceLifecycle: "stream"`.', + ); + warnSpy.mockRestore(); + }); + + test('does not warn for a streamed beforeSendSpan with traceLifecycle "stream"', () => { + const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + + new TestClient( + getDefaultTestClientOptions({ dsn: PUBLIC_DSN, traceLifecycle: 'stream', beforeSendSpan: span => span }), + ); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('does not warn for a static beforeSendSpan with traceLifecycle "static"', () => { + const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + + new TestClient( + getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + traceLifecycle: 'static', + beforeSendSpan: withStaticSpan(span => span), + }), + ); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); }); describe('init() / transaction option warnings', () => { @@ -212,6 +286,15 @@ describe('Client', () => { expect(client.getOptions().traceLifecycle).toBe('static'); }); + + test('normalizes an unknown traceLifecycle to stream', () => { + const client = new TestClient( + // @ts-expect-error - we want to test normalization of invalid traceLifecycle values + getDefaultTestClientOptions({ traceLifecycle: 'somethingElse' }), + ); + + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); }); describe('getTransport()', () => { diff --git a/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts index 2616415d8bf2..c4b3f8817b9c 100644 --- a/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts @@ -17,10 +17,11 @@ describe('beforeSendSpan callback formats', () => { }); describe('withStreamedSpan', () => { - it('marks the callback as streamed', () => { + it('returns the callback unchanged', () => { const beforeSendSpan = vi.fn(); - const wrapped = withStreamedSpan(beforeSendSpan); - expect(wrapped._streamed).toBe(true); + + expect(withStreamedSpan(beforeSendSpan)).toBe(beforeSendSpan); + expect(Object.keys(beforeSendSpan)).not.toContain('_streamed'); }); }); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index e5ff095d1589..6ab8a5a7df80 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -98,7 +98,7 @@ export { featureFlagsIntegration, metrics, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, logger, consoleLoggingIntegration, diff --git a/packages/effect/src/index.types.ts b/packages/effect/src/index.types.ts index 940d59f49f7f..bc77a68bd35d 100644 --- a/packages/effect/src/index.types.ts +++ b/packages/effect/src/index.types.ts @@ -23,7 +23,7 @@ export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsInteg export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const withStaticSpan: typeof clientSdk.withStaticSpan; -// eslint-disable-next-line typescript/no-deprecated +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 0ed6b676034e..c66def738a3c 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -155,7 +155,7 @@ export { metrics, spanStreamingIntegration, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, bunServerIntegration, makeFetchTransport, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 10a820c19139..fb2fe70a528d 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -160,7 +160,7 @@ export { metrics, spanStreamingIntegration, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 151d338cf3ce..42ef9d6a9502 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -28,7 +28,7 @@ export declare const consoleIntegration: typeof serverSdk.consoleIntegration; export declare const pinoIntegration: typeof serverSdk.pinoIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const withStaticSpan: typeof clientSdk.withStaticSpan; -// eslint-disable-next-line typescript/no-deprecated +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; // Different implementation in server and worker diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index ab6946366c7c..4a3ce0a9da89 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -176,7 +176,7 @@ export type { CaptureContext, } from '@sentry/core'; -// eslint-disable-next-line typescript/no-deprecated +// oxlint-disable-next-line typescript/no-deprecated export { metrics, withStaticSpan, withStreamedSpan } from '@sentry/core'; export * as logger from './logs/exports'; diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index e2e8ba3b66d0..4865aac868d3 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -18,7 +18,7 @@ export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsInteg export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const withStaticSpan: typeof clientSdk.withStaticSpan; -// eslint-disable-next-line typescript/no-deprecated +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts index 16f2eca441a3..af8c4da6d94a 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -18,7 +18,7 @@ export declare const contextLinesIntegration: typeof clientSdk.contextLinesInteg export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const withStaticSpan: typeof clientSdk.withStaticSpan; -// eslint-disable-next-line typescript/no-deprecated +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const defaultStackParser: StackParser; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index a91869f0d976..99c642506693 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -118,7 +118,7 @@ export { spanToBaggageHeader, updateSpanName, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, featureFlagsIntegration, } from '@sentry/core'; diff --git a/packages/remix/src/index.types.ts b/packages/remix/src/index.types.ts index 867bc011dfc2..498b640d7e6e 100644 --- a/packages/remix/src/index.types.ts +++ b/packages/remix/src/index.types.ts @@ -20,7 +20,7 @@ export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsInteg export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const withStaticSpan: typeof clientSdk.withStaticSpan; -// eslint-disable-next-line typescript/no-deprecated +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 5266cca46dee..304e3c69ac29 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -133,7 +133,7 @@ export { createSentryWinstonTransport, spanStreamingIntegration, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/solidstart/src/index.types.ts b/packages/solidstart/src/index.types.ts index 1b458e7cf929..71e45f1d2a2d 100644 --- a/packages/solidstart/src/index.types.ts +++ b/packages/solidstart/src/index.types.ts @@ -20,7 +20,7 @@ export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsInteg export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const withStaticSpan: typeof clientSdk.withStaticSpan; -// eslint-disable-next-line typescript/no-deprecated +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index e955e115b2cf..1bfb72c8f444 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -132,7 +132,7 @@ export { createSentryWinstonTransport, spanStreamingIntegration, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index 0a328b38ab1b..5404d001b205 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -49,7 +49,7 @@ export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsInteg export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const withStaticSpan: typeof clientSdk.withStaticSpan; -// eslint-disable-next-line typescript/no-deprecated +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; // Different implementation in server and worker diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index e42178dfa0c7..9d3f170926f7 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -137,7 +137,7 @@ export { metrics, spanStreamingIntegration, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from '@sentry/node'; diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index e52ebc8aedc4..65fe98529a5c 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -84,7 +84,7 @@ export { withMonitor, withScope, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, supabaseIntegration, instrumentSupabaseClient, diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index faedd2ab18b5..d1464a88175e 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -20,7 +20,7 @@ export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsInteg export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const withStaticSpan: typeof clientSdk.withStaticSpan; -// eslint-disable-next-line typescript/no-deprecated +// oxlint-disable-next-line typescript/no-deprecated export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 92aaa77fcc9a..56705a2d936c 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -100,7 +100,7 @@ export { logger, metrics, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; From d49b810bc9e52cf61ca22e13b62eba7ff9685e71 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 28 Jul 2026 16:10:55 +0200 Subject: [PATCH 03/13] cleanup --- packages/browser/test/integrations/spanstreaming.test.ts | 4 ++-- packages/core/src/tracing/spans/beforeSendSpan.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts index 9fd7c934b1be..16c93f6c8f12 100644 --- a/packages/browser/test/integrations/spanstreaming.test.ts +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -6,7 +6,7 @@ import { } from '@sentry/core/browser'; import { SENTRY_TRACE_LIFECYCLE } from '@sentry/conventions/attributes'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { BrowserClient, spanStreamingIntegration } from '../../src'; +import { BrowserClient, spanStreamingIntegration, withStaticSpan } from '../../src'; import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; // Mock SpanBuffer as a class that can be instantiated @@ -87,7 +87,7 @@ describe('spanStreamingIntegration', () => { dsn: 'https://username@domain/123', integrations: [spanStreamingIntegration()], traceLifecycle: 'stream', - beforeSendSpan: SentryCore.withStaticSpan(span => span), + beforeSendSpan: withStaticSpan(span => span), }); SentryCore.setCurrentClient(client); diff --git a/packages/core/src/tracing/spans/beforeSendSpan.ts b/packages/core/src/tracing/spans/beforeSendSpan.ts index a6065218438a..f577af902f37 100644 --- a/packages/core/src/tracing/spans/beforeSendSpan.ts +++ b/packages/core/src/tracing/spans/beforeSendSpan.ts @@ -3,7 +3,7 @@ import type { SpanJSON, StreamedSpanJSON } from '../../types/span'; import { addNonEnumerableProperty } from '../../utils/object'; /** - * A wrapper to use the legacy transaction span format in your `beforeSendSpan` callback. + * A wrapper to use the static, transaction-based span format in your `beforeSendSpan` callback. * * When using `traceLifecycle: 'static'`, wrap your callback with this function * to receive and return {@link SpanJSON} instead of {@link StreamedSpanJSON}. @@ -55,7 +55,7 @@ export function isStaticBeforeSendSpanCallback(callback: unknown): callback is B * Typesafe check to identify if a `beforeSendSpan` callback expects the streamed span JSON format. * * @param callback - The `beforeSendSpan` callback to check. - * @returns `true` unless the callback was wrapped with {@link withStaticSpan}. + * @returns `true` unless the callback was marked as a static span callback. */ export function isStreamedBeforeSendSpanCallback(callback: unknown): callback is BeforeSendStreamedSpanCallback { return !!callback && typeof callback === 'function' && !isStaticBeforeSendSpanCallback(callback); From 442b02c0b1fe64c3be52a44955650a6d56942977 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 29 Jul 2026 11:09:23 +0200 Subject: [PATCH 04/13] fix a few oversights, jsdoc, migration --- MIGRATION.md | 137 +++++++++++++++++++++++++++- packages/core/src/shared-exports.ts | 1 - packages/core/src/types/options.ts | 27 +++--- packages/node/src/index.ts | 8 +- 4 files changed, 153 insertions(+), 20 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index e939dd14dc04..56382fba5f59 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -198,11 +198,138 @@ Each span is sent to Sentry the moment it finishes instead of being buffered unt The new model comes with some changes to Sentry hooks such as `beforeSendSpan` or options like `ignoreSpans` and requires manual migration. `beforeSendTransaction` and `ignoreTransactions` will **no-op**. Users who cannot migrate yet can opt into the previous transaction-based static model. -> **TODO(v11):** The migration path for span streaming is still being defined. Document: -> -> - the concrete before/after for `beforeSendSpan` and `ignoreSpans`, -> - the exact replacement for `beforeSendTransaction` / `ignoreTransactions`, -> - how to opt back into the transaction-based model (option name + example). +#### `beforeSendSpan` receives the streamed span format + +Your `beforeSendSpan` callback now receives a `StreamedSpanJSON` payload and is invoked as each span finishes, rather than for all spans of a transaction right before that transaction is sent. As in v10, it is invoked for the root span as well as for child spans. + +The payload fields were renamed: + +| Before (`SpanJSON`) | After (`StreamedSpanJSON`) | +| ------------------- | ------------------------------ | +| `description` | `name` | +| `data` | `attributes` | +| `op` | `attributes['sentry.op']` | +| `timestamp` | `end_timestamp` | +| `status` (`string`) | `status` (`'ok'` or `'error'`) | +| `is_segment?` | `is_segment` (always set) | + +```js +// Before +Sentry.init({ + beforeSendSpan: span => { + if (span.op === 'db.query') { + span.description = scrub(span.description); + span.data['db.statement'] = scrub(span.data['db.statement']); + } + return span; + }, +}); + +// After +Sentry.init({ + beforeSendSpan: span => { + if (span.attributes?.['sentry.op'] === 'db.query') { + span.name = scrub(span.name); + span.attributes['db.statement'] = scrub(span.attributes['db.statement']); + } + return span; + }, +}); +``` + +Returning `null` to drop a span was already disallowed in v9 and remains a no-op. Use `ignoreSpans` to filter spans. + +If you cannot migrate a callback yet, wrap it with `Sentry.withStaticSpan()` and opt out of span streaming (see below). `withStaticSpan` marks the callback as expecting the legacy `SpanJSON` format: + +```js +Sentry.init({ + traceLifecycle: 'static', + beforeSendSpan: Sentry.withStaticSpan(span => { + span.description = scrub(span.description); + return span; + }), +}); +``` + +A `beforeSendSpan` callback that does not match the configured `traceLifecycle` is **never invoked** — an unwrapped callback is ignored in `'static'` mode, and a `withStaticSpan`-wrapped callback is ignored in `'stream'` mode. Enable debug logging to surface a warning about the mismatch. Previously, an incompatible callback silently downgraded the SDK to the static lifecycle instead. + +The `withStreamedSpan()` helper is now a no-op, since streamed payloads are the default. It is deprecated and will be removed in v12 — remove the wrapper: + +```js +// Before +beforeSendSpan: Sentry.withStreamedSpan(span => span); + +// After +beforeSendSpan: span => span; +``` + +The `isStreamedBeforeSendSpanCallback()` helper is no longer exported. + +#### Replacing `beforeSendTransaction` + +`beforeSendTransaction` no-ops because no transaction events are produced. For **scrubbing and data modification**, move the logic to `beforeSendSpan` and guard on `is_segment` to target what used to be the transaction: + +```js +// Before +Sentry.init({ + beforeSendTransaction: event => { + event.transaction = scrubIds(event.transaction); + return event; + }, +}); + +// After +Sentry.init({ + beforeSendSpan: span => { + if (span.is_segment) { + span.name = scrubIds(span.name); + } + return span; + }, +}); +``` + +Note that scope `tags` and `extra` are not carried over to streamed spans, since spans only have attributes. Use `Sentry.setAttribute()` / `Sentry.setAttributes()` instead — scope attributes are applied to the segment span and are readable and writable via `span.attributes` in `beforeSendSpan`. + +For **dropping** a transaction, use `ignoreSpans` (see below) — `beforeSendSpan` cannot drop spans. + +#### Replacing `ignoreTransactions` with `ignoreSpans` + +`ignoreTransactions` no-ops. Use `ignoreSpans` to match the segment span instead: when a segment span is ignored, all of its child spans are dropped with it, which is equivalent to dropping the whole transaction. + +```js +// Before +Sentry.init({ + ignoreTransactions: ['GET /health'], +}); + +// After +Sentry.init({ + ignoreSpans: ['GET /health'], +}); +``` + +`ignoreSpans` matches on the span `name` (formerly `description`), not on the transaction name. Because it applies to every span rather than just to root spans, consider narrowing the filter with the object form so that child spans sharing a name are not dropped as collateral: + +```js +Sentry.init({ + ignoreSpans: [{ name: 'GET /health', op: 'http.server' }], +}); +``` + +`ignoreSpans` itself is unchanged in shape, but it now takes effect when a span **starts** rather than when the transaction is sent. Matched spans are never recorded at all, which means a matched non-segment span's children are re-parented to its parent instead of being dropped. + +#### Opting out of span streaming + +To keep the previous transaction-based model, set `traceLifecycle: 'static'`: + +```js +Sentry.init({ + traceLifecycle: 'static', +}); +``` + +In Node, Vercel Edge and Cloudflare you can also set the `SENTRY_TRACE_LIFECYCLE=static` environment variable instead. The static lifecycle only exists for backwards compatibility and is planned for removal in a future major version, so treat this as a temporary measure. ### Logs are enabled by default diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 704e71d511b8..057d70a37e47 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -90,7 +90,6 @@ export { // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, } from './tracing/spans/beforeSendSpan'; -export { isStaticBeforeSendSpanCallback, isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; export { safeSetSpanJSONAttributes } from './tracing/spans/captureSpan'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index b9ee4e298aae..00b6e8de1206 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -615,10 +615,13 @@ export interface ClientOptions PromiseLike | ErrorEvent | null; /** - * This function can be defined to modify a child span before it's sent. + * This function can be defined to modify a span before it's sent. * - * When using `traceLifecycle: 'static'`, wrap your callback with {@link withStaticSpan} - * to receive and return {@link SpanJSON} instead. + * The callback receives and returns a {@link StreamedSpanJSON}, matching the default + * `traceLifecycle: 'stream'`. When using `traceLifecycle: 'static'`, wrap your callback with + * {@link withStaticSpan} to receive and return a {@link SpanJSON} instead. + * + * A callback that doesn't match the configured `traceLifecycle` is never invoked. * * @param span The span generated by the SDK. * @@ -671,17 +674,17 @@ export interface ClientOptions StreamedSpanJSON; -declare const beforeSendStaticSpanCallback: unique symbol; - -type BeforeSendStaticSpanCallbackMarker = { - readonly [beforeSendStaticSpanCallback]: true; -}; +/** + * The marker that {@link withStaticSpan} adds to a `beforeSendSpan` callback. + * + * Deliberately not callable: `beforeSendSpan` must have exactly one call signature for TypeScript to + * contextually type an unwrapped callback's parameter as {@link StreamedSpanJSON}. A union of two + * callable types would infer `any` instead. + */ +type BeforeSendStaticSpanCallbackMarker = { _static: true }; /** A callback for processing spans sent as part of transaction events. */ -export type BeforeSendStaticSpanCallback = ((span: SpanJSON) => SpanJSON) & - BeforeSendStaticSpanCallbackMarker & { - _static: true; - }; +export type BeforeSendStaticSpanCallback = ((span: SpanJSON) => SpanJSON) & BeforeSendStaticSpanCallbackMarker; /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 4a3ce0a9da89..def0126bb115 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -176,8 +176,12 @@ export type { CaptureContext, } from '@sentry/core'; -// oxlint-disable-next-line typescript/no-deprecated -export { metrics, withStaticSpan, withStreamedSpan } from '@sentry/core'; +export { + metrics, + withStaticSpan, + // oxlint-disable-next-line typescript/no-deprecated + withStreamedSpan, +} from '@sentry/core'; export * as logger from './logs/exports'; export { childProcessIntegration } from './integrations/childProcess'; From 1f60b46ae6fb3209d80069370beeb99d1a9a5a7a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 29 Jul 2026 12:44:10 +0200 Subject: [PATCH 05/13] account for v2 web vital spans in static mode --- .../scenario-unwrapped.ts | 28 +++++ .../beforeSendSpan-static/scenario.ts | 31 +++++ .../public-api/beforeSendSpan-static/test.ts | 46 ++++++++ .../tracing/spans/spanJsonToStreamedSpan.ts | 59 ++++++++-- .../lib/tracing/spans/captureSpan.test.ts | 110 +++++++++++++++++- .../spans/spanJsonToStreamedSpan.test.ts | 103 +++++++++++++++- 6 files changed, 365 insertions(+), 12 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario-unwrapped.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/test.ts diff --git a/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario-unwrapped.ts b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario-unwrapped.ts new file mode 100644 index 000000000000..e3cad96f2f45 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario-unwrapped.ts @@ -0,0 +1,28 @@ +import type { SpanJSON } from '@sentry/core'; +import type { NodeOptions } from '@sentry/node'; +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Simulates a callback that was not migrated to `withStaticSpan`. The cast stands in for the +// JavaScript users who don't get a type error here. +const unmigratedBeforeSendSpan = ((span: SpanJSON) => { + span.description = 'thisShouldNotBeApplied'; + return span; +}) as unknown as NodeOptions['beforeSendSpan']; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + transport: loggingTransport, + release: '1.0.0', + traceLifecycle: 'static', + beforeSendSpan: unmigratedBeforeSendSpan, +}); + +Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { + Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => { + // noop + }); +}); + +void Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario.ts new file mode 100644 index 000000000000..8dee60e5b99d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/scenario.ts @@ -0,0 +1,31 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + transport: loggingTransport, + release: '1.0.0', + traceLifecycle: 'static', + beforeSendSpan: Sentry.withStaticSpan(span => { + if (span.description === 'test-child-span') { + span.description = 'customChildSpanName'; + span.data['sentry.custom_attribute'] = 'customAttributeValue'; + } + + if (span.is_segment) { + span.description = 'customRootSpanName'; + span.data['sentry.custom_root_attribute'] = 'customRootAttributeValue'; + } + + return span; + }), +}); + +Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { + Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => { + // noop + }); +}); + +void Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/test.ts b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/test.ts new file mode 100644 index 000000000000..1dc4c63b042b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/beforeSendSpan-static/test.ts @@ -0,0 +1,46 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('withStaticSpan applies changes to child spans', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: event => { + expect(event.spans).toHaveLength(1); + + const childSpan = event.spans![0]!; + expect(childSpan.description).toBe('customChildSpanName'); + expect(childSpan.data['sentry.custom_attribute']).toBe('customAttributeValue'); + }, + }) + .start() + .completed(); +}); + +test('withStaticSpan applies changes to the root span', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('customRootSpanName'); + expect(event.contexts?.trace?.data?.['sentry.custom_root_attribute']).toBe('customRootAttributeValue'); + }, + }) + .start() + .completed(); +}); + +test('a beforeSendSpan callback without withStaticSpan is not invoked in the static trace lifecycle', async () => { + await createRunner(__dirname, 'scenario-unwrapped.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('test-span'); + expect(event.spans).toHaveLength(1); + expect(event.spans![0]!.description).toBe('test-child-span'); + }, + }) + .start() + .completed(); +}); diff --git a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts index de664b802138..083a30246603 100644 --- a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts +++ b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts @@ -1,12 +1,24 @@ import type { RawAttributes } from '../../attributes'; -import type { SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../types/span'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import type { SpanLinkJSON } from '../../types/link'; +import type { SerializedStreamedSpan, SpanAttributes, SpanJSON, SpanOrigin, StreamedSpanJSON } from '../../types/span'; 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 an intermediate v2 StreamedSpanJSON. */ -export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStreamedSpan { - const streamedSpan: StreamedSpanJSON = { +export function spanJsonToStreamedSpanJson(span: SpanJSON): StreamedSpanJSON { + // `op` and `origin` are dedicated fields on a v1 span but plain attributes on a v2 span. Copy them + // back so that writes to the v1 fields (e.g. in a `withStaticSpan` callback) aren't dropped. + const attributes = { ...span.data } as RawAttributes>; + if (span.op !== undefined) { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = span.op; + } + if (span.origin !== undefined) { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = span.origin; + } + + return { trace_id: span.trace_id, span_id: span.span_id, parent_span_id: span.parent_span_id, @@ -14,10 +26,43 @@ export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStre start_timestamp: span.start_timestamp, 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>) }, + is_segment: span.is_segment ?? false, + 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); +/** + * Converts an intermediate v2 StreamedSpanJSON back to a v1 SpanJSON. + * + * The inverse of {@link spanJsonToStreamedSpanJson}, used to hand a `withStaticSpan` `beforeSendSpan` + * callback the v1 format it expects for spans that are streamed despite the static trace lifecycle. + * + * v1 fields that have no v2 counterpart (`measurements`, `segment_id`) are not restored. + * `profile_id` and `exclusive_time` stay readable through `data`. + */ +export function streamedSpanJsonToSpanJson(span: StreamedSpanJSON): SpanJSON { + const data = { ...span.attributes } as SpanAttributes; + + return { + trace_id: span.trace_id, + span_id: span.span_id, + parent_span_id: span.parent_span_id, + description: span.name, + start_timestamp: span.start_timestamp, + timestamp: span.end_timestamp, + status: span.status, + is_segment: span.is_segment, + data, + op: data[SEMANTIC_ATTRIBUTE_SENTRY_OP], + origin: data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined, + links: span.links as SpanLinkJSON[] | undefined, + }; } diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index c122451ba100..aedd1b7d006a 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { Contexts, StreamedSpanJSON } from '../../../../src'; +import type { Contexts, SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../../../src'; import { captureSpan, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, @@ -20,6 +20,7 @@ import { } from '../../../../src'; import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; import { scopeContextsToSpanAttributes } from '../../../../src/tracing/spans/scopeContextAttributes'; +import type { TestClientOptions } from '../../../mocks/client'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; import { SENTRY_SEGMENT_ID, @@ -471,7 +472,7 @@ describe('captureSpan', () => { }); describe('beforeSendSpan', () => { - it('applies an unwrapped beforeSendSpan callback', () => { + it('applies a default beforeSendSpan callback', () => { const beforeSendSpan = vi.fn(span => span); const client = new TestClient( @@ -480,6 +481,7 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', + traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -492,7 +494,7 @@ describe('captureSpan', () => { expect(beforeSendSpan).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId })); }); - it("doesn't apply beforeSendSpan if it is marked as static", () => { + it("doesn't apply beforeSendSpan if it is marked as static by default", () => { const beforeSendSpan = withStaticSpan(vi.fn(span => span)); const client = new TestClient( @@ -501,6 +503,7 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', + traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -523,6 +526,7 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', + traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -538,6 +542,106 @@ describe('captureSpan', () => { consoleWarnSpy.mockRestore(); }); + + // Standalone spans (INP web vital spans) are streamed even with the static trace lifecycle, + // so a `withStaticSpan` callback has to see them in the v1 format it expects. + describe('with the static trace lifecycle', () => { + // Captures a child span so that ending it doesn't also send a transaction (which would apply the + // static callback a second time, via the transaction pipeline). + function captureStaticChildSpan( + beforeSendSpan: TestClientOptions['beforeSendSpan'], + attributes: Record = { 'sentry.op': 'http.client' }, + ): SerializedStreamedSpan { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSendSpan, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + const rootSpan = startInactiveSpan({ name: 'root' }); + const span = startInactiveSpan({ name: 'my-span', attributes, parentSpan: rootSpan }); + span.end(); + return span; + }); + + return captureSpan(span, client); + } + + it('applies a static beforeSendSpan callback and hands it the v1 span format', () => { + const beforeSendSpan = vi.fn((span: SpanJSON) => span); + + captureStaticChildSpan(withStaticSpan(beforeSendSpan)); + + expect(beforeSendSpan).toHaveBeenCalledTimes(1); + expect(beforeSendSpan).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'my-span', + op: 'http.client', + data: expect.objectContaining({ 'sentry.op': 'http.client' }), + timestamp: expect.any(Number), + }), + ); + }); + + it('carries modifications from a static beforeSendSpan callback into the serialized span', () => { + const serialized = captureStaticChildSpan( + withStaticSpan((span: SpanJSON) => { + span.description = 'redacted'; + span.data['http.url'] = '[Filtered]'; + span.op = 'http.other'; + return span; + }), + { 'sentry.op': 'http.client', 'http.url': 'https://example.com/secret' }, + ); + + expect(serialized.name).toBe('redacted'); + expect(serialized.attributes['http.url']).toEqual({ type: 'string', value: '[Filtered]' }); + expect(serialized.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.other' }); + }); + + it('preserves span fields the callback leaves untouched', () => { + const serialized = captureStaticChildSpan(withStaticSpan((span: SpanJSON) => span)); + + expect(serialized).toEqual( + expect.objectContaining({ + name: 'my-span', + is_segment: false, + status: 'ok', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }), + ); + expect(serialized.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); + }); + + it("doesn't a default beforeSendSpan callback without the withStaticSpan wrapper", () => { + const beforeSendSpan = vi.fn(span => span); + + const serialized = captureStaticChildSpan(beforeSendSpan); + + expect(beforeSendSpan).not.toHaveBeenCalled(); + expect(serialized.name).toBe('my-span'); + }); + + it('keeps the span if a static beforeSendSpan callback returns null', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const serialized = captureStaticChildSpan(withStaticSpan(vi.fn(() => null as unknown as SpanJSON))); + + // The drop warning itself is only logged once per process, so it is asserted in the streamed test above. + expect(serialized.name).toBe('my-span'); + expect(serialized.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); + + consoleWarnSpy.mockRestore(); + }); + }); }); }); diff --git a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts index 94b25c590d19..22758f4879d0 100644 --- a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import type { SpanJSON } from '../../../../src/types/span'; -import { spanJsonToSerializedStreamedSpan } from '../../../../src/tracing/spans/spanJsonToStreamedSpan'; +import type { SpanJSON, StreamedSpanJSON } from '../../../../src/types/span'; +import { + spanJsonToSerializedStreamedSpan, + spanJsonToStreamedSpanJson, + streamedSpanJsonToSpanJson, +} from '../../../../src/tracing/spans/spanJsonToStreamedSpan'; function makeSpanJSON(overrides: Partial = {}): SpanJSON { return { @@ -90,4 +94,99 @@ describe('spanJsonToSerializedStreamedSpan', () => { { trace_id: 'aabb', span_id: 'ccdd', sampled: true, attributes: { foo: { type: 'string', value: 'bar' } } }, ]); }); + + it('preserves is_segment', () => { + expect(spanJsonToStreamedSpanJson(makeSpanJSON({ is_segment: true })).is_segment).toBe(true); + expect(spanJsonToStreamedSpanJson(makeSpanJSON({ is_segment: undefined })).is_segment).toBe(false); + }); + + it('copies op and origin into attributes so edits to the v1 fields are kept', () => { + const result = spanJsonToStreamedSpanJson( + makeSpanJSON({ op: 'http.other', origin: 'manual.http', data: { 'sentry.op': 'http.client' } }), + ); + + expect(result.attributes?.['sentry.op']).toBe('http.other'); + expect(result.attributes?.['sentry.origin']).toBe('manual.http'); + }); +}); + +describe('streamedSpanJsonToSpanJson', () => { + function makeStreamedSpanJSON(overrides: Partial = {}): StreamedSpanJSON { + return { + span_id: 'abc123def456789a', + trace_id: '00112233445566778899aabbccddeeff', + name: 'GET /users', + start_timestamp: 1000, + end_timestamp: 1005, + status: 'ok', + is_segment: false, + attributes: { 'sentry.op': 'http.client', 'sentry.origin': 'auto.http', 'http.url': 'https://example.com' }, + ...overrides, + }; + } + + it('maps StreamedSpanJSON fields to the v1 SpanJSON fields', () => { + const result = streamedSpanJsonToSpanJson(makeStreamedSpanJSON({ parent_span_id: 'parent00deadbeef' })); + + expect(result).toEqual({ + span_id: 'abc123def456789a', + trace_id: '00112233445566778899aabbccddeeff', + parent_span_id: 'parent00deadbeef', + description: 'GET /users', + start_timestamp: 1000, + timestamp: 1005, + status: 'ok', + is_segment: false, + op: 'http.client', + origin: 'auto.http', + links: undefined, + data: { 'sentry.op': 'http.client', 'sentry.origin': 'auto.http', 'http.url': 'https://example.com' }, + }); + }); + + it('copies attributes rather than aliasing them', () => { + const streamedSpan = makeStreamedSpanJSON(); + const result = streamedSpanJsonToSpanJson(streamedSpan); + + result.data['http.url'] = '[Filtered]'; + + expect(streamedSpan.attributes?.['http.url']).toBe('https://example.com'); + }); + + it('handles a span without attributes', () => { + const result = streamedSpanJsonToSpanJson(makeStreamedSpanJSON({ attributes: undefined })); + + expect(result.data).toEqual({}); + expect(result.op).toBeUndefined(); + expect(result.origin).toBeUndefined(); + }); + + it.each([ + ['a segment span', true], + ['a child span', false], + ])('round-trips %s unchanged', (_, is_segment) => { + const streamedSpan = makeStreamedSpanJSON({ + is_segment, + status: 'error', + parent_span_id: 'parent00deadbeef', + links: [{ trace_id: 'aabb', span_id: 'ccdd', sampled: true, attributes: { foo: 'bar' } }], + }); + + expect(spanJsonToStreamedSpanJson(streamedSpanJsonToSpanJson(streamedSpan))).toEqual(streamedSpan); + }); + + it('round-trips modifications made in the v1 format back into the streamed format', () => { + const streamedSpan = makeStreamedSpanJSON(); + + const spanJson = streamedSpanJsonToSpanJson(streamedSpan); + spanJson.description = 'redacted'; + spanJson.data['http.url'] = '[Filtered]'; + spanJson.op = 'http.other'; + + const result = spanJsonToStreamedSpanJson(spanJson); + + expect(result.name).toBe('redacted'); + expect(result.attributes?.['http.url']).toBe('[Filtered]'); + expect(result.attributes?.['sentry.op']).toBe('http.other'); + }); }); From e0642aa2e13043cbe6dea9fa774b34440201b0c5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 29 Jul 2026 12:44:25 +0200 Subject: [PATCH 06/13] reword migration --- MIGRATION.md | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 56382fba5f59..f54f3ae3468f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -194,13 +194,16 @@ The same applies to the no-code entry points, e.g. `node --import=@sentry/node/i Affected SDKs: All SDKs. -Each span is sent to Sentry the moment it finishes instead of being buffered until the root span completes. This means spans are no longer bound by the 1000-span per transaction limit and their individual payload-size limits have been increased. +Spans are now sent to Sentry in small batches instead of being buffered until the root span completes. +This means spans are no longer bound by the 1000-span per transaction limit and their individual payload-size limits have been increased. -The new model comes with some changes to Sentry hooks such as `beforeSendSpan` or options like `ignoreSpans` and requires manual migration. `beforeSendTransaction` and `ignoreTransactions` will **no-op**. Users who cannot migrate yet can opt into the previous transaction-based static model. +The new model comes with some changes to Sentry hooks such as `beforeSendSpan` or options like `ignoreSpans` and requires manual migration. +The `beforeSendTransaction` and `ignoreTransactions` options will **no-op**. +If you cannot migrate to span streaming yet, you can opt into the previous transaction-based static model. #### `beforeSendSpan` receives the streamed span format -Your `beforeSendSpan` callback now receives a `StreamedSpanJSON` payload and is invoked as each span finishes, rather than for all spans of a transaction right before that transaction is sent. As in v10, it is invoked for the root span as well as for child spans. +Your `beforeSendSpan` callback now receives a `StreamedSpanJSON` object and is invoked as each span finishes, rather than for all spans of a transaction right before that transaction is sent. As in v10, it is invoked for the root span as well as for child spans. The payload fields were renamed: @@ -211,7 +214,6 @@ The payload fields were renamed: | `op` | `attributes['sentry.op']` | | `timestamp` | `end_timestamp` | | `status` (`string`) | `status` (`'ok'` or `'error'`) | -| `is_segment?` | `is_segment` (always set) | ```js // Before @@ -253,7 +255,7 @@ Sentry.init({ A `beforeSendSpan` callback that does not match the configured `traceLifecycle` is **never invoked** — an unwrapped callback is ignored in `'static'` mode, and a `withStaticSpan`-wrapped callback is ignored in `'stream'` mode. Enable debug logging to surface a warning about the mismatch. Previously, an incompatible callback silently downgraded the SDK to the static lifecycle instead. -The `withStreamedSpan()` helper is now a no-op, since streamed payloads are the default. It is deprecated and will be removed in v12 — remove the wrapper: +The `withStreamedSpan()` helper is now a no-op, since streamed payloads are the default. It is deprecated and will be removed in v12. You can remove the wrapper: ```js // Before @@ -263,7 +265,7 @@ beforeSendSpan: Sentry.withStreamedSpan(span => span); beforeSendSpan: span => span; ``` -The `isStreamedBeforeSendSpanCallback()` helper is no longer exported. +The internal `isStreamedBeforeSendSpanCallback()` function from `@sentry/core` is no longer exported. #### Replacing `beforeSendTransaction` @@ -289,9 +291,9 @@ Sentry.init({ }); ``` -Note that scope `tags` and `extra` are not carried over to streamed spans, since spans only have attributes. Use `Sentry.setAttribute()` / `Sentry.setAttributes()` instead — scope attributes are applied to the segment span and are readable and writable via `span.attributes` in `beforeSendSpan`. +Note that scope `tags` and `extra` are not carried over to streamed spans, since spans only have attributes. Use `Sentry.setAttribute()` / `Sentry.setAttributes()` instead. -For **dropping** a transaction, use `ignoreSpans` (see below) — `beforeSendSpan` cannot drop spans. +For **dropping** a transaction, use `ignoreSpans` (see below). The `beforeSendSpan` callback cannot drop spans. #### Replacing `ignoreTransactions` with `ignoreSpans` @@ -309,11 +311,11 @@ Sentry.init({ }); ``` -`ignoreSpans` matches on the span `name` (formerly `description`), not on the transaction name. Because it applies to every span rather than just to root spans, consider narrowing the filter with the object form so that child spans sharing a name are not dropped as collateral: +`ignoreSpans` matches on the span `name` (formerly `description`). Because it applies to every span rather than just to root spans, consider narrowing the filter with the object form so that child spans sharing a name are not dropped as collateral: ```js Sentry.init({ - ignoreSpans: [{ name: 'GET /health', op: 'http.server' }], + ignoreSpans: [{ name: 'GET /health', attributes: { 'sentry.op': 'http.server' } }], }); ``` @@ -326,6 +328,12 @@ To keep the previous transaction-based model, set `traceLifecycle: 'static'`: ```js Sentry.init({ traceLifecycle: 'static', + + // `beforeSendSpan` MUST be wrapped with Sentry.withStaticSpan: + beforeSendSpan: Sentry.withStaticSpan(span => { + span.description = scrub(span.description); + return span; + }), }); ``` From 3bce874d01c8aaf00390e6bb259c0b6a2ee26dc8 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 30 Jul 2026 18:21:10 +0200 Subject: [PATCH 07/13] remove marker type and "cheat" in `withStaticSpan` callback --- MIGRATION.md | 2 +- packages/core/src/client.ts | 15 +++++++----- .../core/src/tracing/spans/beforeSendSpan.ts | 23 +++++++------------ packages/core/src/types/options.ts | 15 +++++------- 4 files changed, 24 insertions(+), 31 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index f54f3ae3468f..afd15c0c1952 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -265,7 +265,7 @@ beforeSendSpan: Sentry.withStreamedSpan(span => span); beforeSendSpan: span => span; ``` -The internal `isStreamedBeforeSendSpanCallback()` function from `@sentry/core` is no longer exported. +The internal `isStreamedBeforeSendSpanCallback()` function from `@sentry/core` was removed. #### Replacing `beforeSendTransaction` diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index e209ad97a401..f9a20a930705 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -39,7 +39,7 @@ import type { StartSpanOptions } from './types/startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './types/transport'; import type { ResolvedDataCollection } from './types/datacollection'; import { createClientReportEnvelope } from './utils/clientreport'; -import { debug } from './utils/debug-logger'; +import { consoleSandbox, debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils/envelope'; import { getPossibleEventMessages } from './utils/eventUtils'; @@ -264,11 +264,14 @@ export abstract class Client { beforeSendSpan && isStaticBeforeSendSpanCallback(beforeSendSpan) !== (traceLifecycle === 'static') ) { - debug.warn( - `Ignoring \`beforeSendSpan\`: ${ - traceLifecycle === 'static' ? 'wrap it with' : 'remove' - } \`Sentry.withStaticSpan\` to use it with \`traceLifecycle: "${traceLifecycle}"\`.`, - ); + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + `Ignoring \`beforeSendSpan\`: ${ + traceLifecycle === 'static' ? 'wrap it with' : 'remove' + } \`Sentry.withStaticSpan\` to use it with \`traceLifecycle: "${traceLifecycle}"\`.`, + ); + }); } if (this._dsn) { diff --git a/packages/core/src/tracing/spans/beforeSendSpan.ts b/packages/core/src/tracing/spans/beforeSendSpan.ts index f577af902f37..65b4703fc4cb 100644 --- a/packages/core/src/tracing/spans/beforeSendSpan.ts +++ b/packages/core/src/tracing/spans/beforeSendSpan.ts @@ -1,12 +1,12 @@ import type { BeforeSendStaticSpanCallback, BeforeSendStreamedSpanCallback } from '../../types/options'; -import type { SpanJSON, StreamedSpanJSON } from '../../types/span'; +import type { StreamedSpanJSON } from '../../types/span'; import { addNonEnumerableProperty } from '../../utils/object'; /** * A wrapper to use the static, transaction-based span format in your `beforeSendSpan` callback. * * When using `traceLifecycle: 'static'`, wrap your callback with this function - * to receive and return {@link SpanJSON} instead of {@link StreamedSpanJSON}. + * to receive and return a static span instead of a {@link StreamedSpanJSON}. * * @example * @@ -18,12 +18,15 @@ import { addNonEnumerableProperty } from '../../utils/object'; * }), * }); * - * @param callback - The callback function that receives and returns a {@link SpanJSON}. + * @param callback - A {@link BeforeSendStaticSpanCallback} that receives and returns a static span. * @returns A callback that is compatible with the `beforeSendSpan` option when using `traceLifecycle: 'static'`. */ -export function withStaticSpan(callback: (span: SpanJSON) => SpanJSON): BeforeSendStaticSpanCallback { +export function withStaticSpan(callback: BeforeSendStaticSpanCallback): BeforeSendStreamedSpanCallback { addNonEnumerableProperty(callback, '_static', true); - return callback as BeforeSendStaticSpanCallback; + // `beforeSendSpan` is typed as a single streamed callback so that an unwrapped callback's parameter + // is contextually typed as `StreamedSpanJSON`. The `_static` marker tells the SDK to hand this + // callback a `SpanJSON` instead, so the declared parameter type is deliberately not what it receives. + return callback as unknown as BeforeSendStreamedSpanCallback; } /** @@ -50,13 +53,3 @@ export function withStreamedSpan( export function isStaticBeforeSendSpanCallback(callback: unknown): callback is BeforeSendStaticSpanCallback { return !!callback && typeof callback === 'function' && '_static' in callback && !!callback._static; } - -/** - * Typesafe check to identify if a `beforeSendSpan` callback expects the streamed span JSON format. - * - * @param callback - The `beforeSendSpan` callback to check. - * @returns `true` unless the callback was marked as a static span callback. - */ -export function isStreamedBeforeSendSpanCallback(callback: unknown): callback is BeforeSendStreamedSpanCallback { - return !!callback && typeof callback === 'function' && !isStaticBeforeSendSpanCallback(callback); -} diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 00b6e8de1206..aafa01cb500e 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -627,7 +627,7 @@ export interface ClientOptions StreamedSpanJSON; /** - * The marker that {@link withStaticSpan} adds to a `beforeSendSpan` callback. + * A callback for processing spans sent as part of transaction events. * - * Deliberately not callable: `beforeSendSpan` must have exactly one call signature for TypeScript to - * contextually type an unwrapped callback's parameter as {@link StreamedSpanJSON}. A union of two - * callable types would infer `any` instead. + * Pass this to {@link withStaticSpan} rather than to `beforeSendSpan` directly. + * + * @see {@link SpanJSON} for the static span format used with `traceLifecycle: 'static'` */ -type BeforeSendStaticSpanCallbackMarker = { _static: true }; - -/** A callback for processing spans sent as part of transaction events. */ -export type BeforeSendStaticSpanCallback = ((span: SpanJSON) => SpanJSON) & BeforeSendStaticSpanCallbackMarker; +export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< From a30dbe2f032e4db9a7b66fb6884b89aec408df3b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 30 Jul 2026 18:38:12 +0200 Subject: [PATCH 08/13] fix tests --- packages/core/test/lib/client.test.ts | 10 +++++----- .../lib/tracing/spans/beforeSendSpan.test.ts | 19 +++---------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 3efb54d71468..fc34bb12f045 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -97,7 +97,7 @@ describe('Client', () => { }); test('warns that a streamed beforeSendSpan is ignored with traceLifecycle "static"', () => { - const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); new TestClient( getDefaultTestClientOptions({ dsn: PUBLIC_DSN, traceLifecycle: 'static', beforeSendSpan: span => span }), @@ -110,7 +110,7 @@ describe('Client', () => { }); test('warns that a static beforeSendSpan is ignored with traceLifecycle "stream"', () => { - const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); new TestClient( getDefaultTestClientOptions({ @@ -127,7 +127,7 @@ describe('Client', () => { }); test('reports the normalized traceLifecycle when warning about an unknown value', () => { - const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); new TestClient( getDefaultTestClientOptions({ @@ -145,7 +145,7 @@ describe('Client', () => { }); test('does not warn for a streamed beforeSendSpan with traceLifecycle "stream"', () => { - const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); new TestClient( getDefaultTestClientOptions({ dsn: PUBLIC_DSN, traceLifecycle: 'stream', beforeSendSpan: span => span }), @@ -156,7 +156,7 @@ describe('Client', () => { }); test('does not warn for a static beforeSendSpan with traceLifecycle "static"', () => { - const warnSpy = vi.spyOn(debugLoggerModule.debug, 'warn').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); new TestClient( getDefaultTestClientOptions({ diff --git a/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts index c4b3f8817b9c..acab09bfea06 100644 --- a/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { withStaticSpan, withStreamedSpan } from '../../../../src'; -import { - isStaticBeforeSendSpanCallback, - isStreamedBeforeSendSpanCallback, -} from '../../../../src/tracing/spans/beforeSendSpan'; +import { isStaticBeforeSendSpanCallback } from '../../../../src/tracing/spans/beforeSendSpan'; describe('beforeSendSpan callback formats', () => { describe('withStaticSpan', () => { @@ -35,19 +32,9 @@ describe('beforeSendSpan callback formats', () => { it('returns false for an unwrapped callback', () => { expect(isStaticBeforeSendSpanCallback(vi.fn())).toBe(false); }); - }); - - describe('isStreamedBeforeSendSpanCallback', () => { - it('returns true for an unwrapped callback', () => { - expect(isStreamedBeforeSendSpanCallback(vi.fn())).toBe(true); - }); - - it('returns true if the callback is wrapped with withStreamedSpan', () => { - expect(isStreamedBeforeSendSpanCallback(withStreamedSpan(vi.fn()))).toBe(true); - }); - it('returns false if the callback is wrapped with withStaticSpan', () => { - expect(isStreamedBeforeSendSpanCallback(withStaticSpan(vi.fn()))).toBe(false); + it('returns false if the callback is wrapped with withStreamedSpan', () => { + expect(isStaticBeforeSendSpanCallback(withStreamedSpan(vi.fn()))).toBe(false); }); }); }); From 0978dcd1707944a4d731b27721d2f1376eb4f631 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 31 Jul 2026 10:28:53 +0200 Subject: [PATCH 09/13] remove v1 path from static spans, reword migration guide, add span status explanation --- MIGRATION.md | 21 +++- .../browser/src/integrations/spanstreaming.ts | 2 +- packages/browser/src/sdk.ts | 10 +- .../core/src/integrations/spanStreaming.ts | 2 +- packages/core/src/server-runtime-client.ts | 10 +- packages/core/src/tracing/sentrySpan.ts | 11 +- .../core/src/tracing/spans/captureSpan.ts | 4 +- .../tracing/spans/spanJsonToStreamedSpan.ts | 59 ++-------- .../lib/tracing/spans/captureSpan.test.ts | 108 +----------------- .../spans/spanJsonToStreamedSpan.test.ts | 103 +---------------- 10 files changed, 53 insertions(+), 277 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index afd15c0c1952..72d8d5c39030 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -215,6 +215,11 @@ The payload fields were renamed: | `timestamp` | `end_timestamp` | | `status` (`string`) | `status` (`'ok'` or `'error'`) | +The `status` field, now only contains two statuses: `'ok'` and `'error'`. +Streamed spans always have a status (while status was optional on transaction-based spans). +Previously more fine-grained error statuses are now mapped to `'error'`. +Additional error information may be set via span attributes (e.g. `sentry.status.message`). + ```js // Before Sentry.init({ @@ -241,7 +246,7 @@ Sentry.init({ Returning `null` to drop a span was already disallowed in v9 and remains a no-op. Use `ignoreSpans` to filter spans. -If you cannot migrate a callback yet, wrap it with `Sentry.withStaticSpan()` and opt out of span streaming (see below). `withStaticSpan` marks the callback as expecting the legacy `SpanJSON` format: +If you cannot migrate the callback yet, opt out of span streaming and wrap `beforeSendSpan` with `Sentry.withStaticSpan()`: ```js Sentry.init({ @@ -269,12 +274,17 @@ The internal `isStreamedBeforeSendSpanCallback()` function from `@sentry/core` w #### Replacing `beforeSendTransaction` -`beforeSendTransaction` no-ops because no transaction events are produced. For **scrubbing and data modification**, move the logic to `beforeSendSpan` and guard on `is_segment` to target what used to be the transaction: +`beforeSendTransaction` no-ops because no transaction events are produced. +For **scrubbing and data modification**, move the logic to `beforeSendSpan` and guard on `is_segment` to target what used to be the transaction +For **dropping** a transaction or child spans, use `ignoreSpans` (see below). The `beforeSendSpan` callback cannot drop spans. ```js // Before Sentry.init({ beforeSendTransaction: event => { + if (event.transaction === 'GET /health') { + return null; + } event.transaction = scrubIds(event.transaction); return event; }, @@ -282,6 +292,9 @@ Sentry.init({ // After Sentry.init({ + ignoreSpans: [ + 'GET /health' + ] beforeSendSpan: span => { if (span.is_segment) { span.name = scrubIds(span.name); @@ -293,8 +306,6 @@ Sentry.init({ Note that scope `tags` and `extra` are not carried over to streamed spans, since spans only have attributes. Use `Sentry.setAttribute()` / `Sentry.setAttributes()` instead. -For **dropping** a transaction, use `ignoreSpans` (see below). The `beforeSendSpan` callback cannot drop spans. - #### Replacing `ignoreTransactions` with `ignoreSpans` `ignoreTransactions` no-ops. Use `ignoreSpans` to match the segment span instead: when a segment span is ignored, all of its child spans are dropped with it, which is equivalent to dropping the whole transaction. @@ -337,7 +348,7 @@ Sentry.init({ }); ``` -In Node, Vercel Edge and Cloudflare you can also set the `SENTRY_TRACE_LIFECYCLE=static` environment variable instead. The static lifecycle only exists for backwards compatibility and is planned for removal in a future major version, so treat this as a temporary measure. +In Node, Bun, Vercel Edge and Cloudflare you can also set the `SENTRY_TRACE_LIFECYCLE=static` environment variable instead. The static lifecycle only exists for backwards compatibility and is planned for removal in a future major version, so treat this as a temporary measure. ### Logs are enabled by default diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index 9b617ba4b014..2c3c69aeaa1f 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -9,7 +9,7 @@ import { } from '@sentry/core/browser'; import { DEBUG_BUILD } from '../debug-build'; -const INTEGRATION_NAME = 'SpanStreaming' as const; +export const INTEGRATION_NAME = 'SpanStreaming' as const; export const spanStreamingIntegration = defineIntegration(() => { return { diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index 0309aed98248..84f4940c8b41 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -19,7 +19,10 @@ import { globalHandlersIntegration } from './integrations/globalhandlers'; import { httpContextIntegration } from './integrations/httpcontext'; import { linkedErrorsIntegration } from './integrations/linkederrors'; import { spotlightBrowserIntegration } from './integrations/spotlight'; -import { spanStreamingIntegration } from './integrations/spanstreaming'; +import { + spanStreamingIntegration, + INTEGRATION_NAME as SPAN_STREAMING_INTEGRATION_NAME, +} from './integrations/spanstreaming'; import { defaultStackParser } from './stack-parsers'; import { makeFetchTransport } from './transports/fetch'; import { normalizeStringifyValue } from './normalizeStringifyValue'; @@ -116,7 +119,10 @@ export function init(options: BrowserOptions = {}): Client | undefined { defaultIntegrations, }); - if (options.traceLifecycle !== 'static' && !integrations.some(integration => integration.name === 'SpanStreaming')) { + if ( + options.traceLifecycle !== 'static' && + !integrations.some(integration => integration.name === SPAN_STREAMING_INTEGRATION_NAME) + ) { integrations.push(spanStreamingIntegration()); } diff --git a/packages/core/src/integrations/spanStreaming.ts b/packages/core/src/integrations/spanStreaming.ts index 662e8c83a093..d325906b6b1e 100644 --- a/packages/core/src/integrations/spanStreaming.ts +++ b/packages/core/src/integrations/spanStreaming.ts @@ -7,7 +7,7 @@ import { SpanBuffer } from '../tracing/spans/spanBuffer'; import { debug } from '../utils/debug-logger'; import { spanIsSampled } from '../utils/spanUtils'; -const INTEGRATION_NAME = 'SpanStreaming' as const; +export const INTEGRATION_NAME = 'SpanStreaming' as const; export const spanStreamingIntegration = defineIntegration(() => { return { diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index ae564663f6c7..e0426e558ed3 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -2,7 +2,10 @@ import { createCheckInEnvelope } from './checkin'; import { Client } from './client'; import { getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; -import { spanStreamingIntegration } from './integrations/spanStreaming'; +import { + spanStreamingIntegration, + INTEGRATION_NAME as SPAN_STREAMING_INTEGRATION_NAME, +} from './integrations/spanStreaming'; import type { Scope } from './scope'; import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base'; import { addUserAgentToTransportHeaders } from './transports/userAgent'; @@ -43,7 +46,10 @@ export class ServerRuntimeClient< // When span streaming is enabled (`traceLifecycle: 'stream'`), the `spanStreamingIntegration` // is required to flush spans. We add it here so the individual server SDKs don't have to. // A user-provided `spanStreamingIntegration` always takes precedence over the one we add. - if (options.traceLifecycle !== 'static' && !options.integrations.some(i => i.name === 'SpanStreaming')) { + if ( + options.traceLifecycle !== 'static' && + !options.integrations.some(i => i.name === SPAN_STREAMING_INTEGRATION_NAME) + ) { options.integrations.push(spanStreamingIntegration()); } diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 06004521e417..c12f7b728f88 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -46,7 +46,7 @@ import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanEnd } from './logSpans'; import { timedEventsToMeasurements } from './measurement'; import { getSegmentSpanCaptureStrategy, type SegmentSpanCaptureConvertOptions } from './segmentSpanCaptureStrategy'; -import { isStreamedBeforeSendSpanCallback } from './spans/beforeSendSpan'; +import { isStaticBeforeSendSpanCallback } from './spans/beforeSendSpan'; import { captureSpan, captureStandaloneSpanWithStaticCallback } from './spans/captureSpan'; import { createStreamedSpanEnvelope } from './spans/envelope'; import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled'; @@ -559,11 +559,12 @@ function isStandaloneSpan(span: Span): boolean { 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. + // A user who opted out of span streaming wraps `beforeSendSpan` with `withStaticSpan` and writes it 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)) { + if (beforeSendSpan && isStaticBeforeSendSpanCallback(beforeSendSpan)) { const serializedSpan = captureStandaloneSpanWithStaticCallback(span, client, beforeSendSpan); const dsc = getDynamicSamplingContextFromSpan(span); // sendEnvelope should not throw diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 39ad44585c5f..be87d5627f21 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -21,7 +21,7 @@ import { streamedSpanJsonToSerializedSpan, } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; -import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; +import { isStaticBeforeSendSpanCallback } from './beforeSendSpan'; import { spanJsonToSerializedStreamedSpan } from './spanJsonToStreamedSpan'; import { scopeContextsToSpanAttributes } from './scopeContextAttributes'; import { DEFAULT_ENVIRONMENT } from '../../constants'; @@ -77,7 +77,7 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW const { beforeSendSpan } = client.getOptions(); const processedSpan = - beforeSendSpan && isStreamedBeforeSendSpanCallback(beforeSendSpan) + beforeSendSpan && !isStaticBeforeSendSpanCallback(beforeSendSpan) ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) : spanJSON; diff --git a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts index 083a30246603..de664b802138 100644 --- a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts +++ b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts @@ -1,24 +1,12 @@ import type { RawAttributes } from '../../attributes'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; -import type { SpanLinkJSON } from '../../types/link'; -import type { SerializedStreamedSpan, SpanAttributes, SpanJSON, SpanOrigin, StreamedSpanJSON } from '../../types/span'; +import type { SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../types/span'; import { streamedSpanJsonToSerializedSpan } from '../../utils/spanUtils'; /** - * Converts a v1 SpanJSON (from a legacy transaction) to an intermediate v2 StreamedSpanJSON. + * Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan. */ -export function spanJsonToStreamedSpanJson(span: SpanJSON): StreamedSpanJSON { - // `op` and `origin` are dedicated fields on a v1 span but plain attributes on a v2 span. Copy them - // back so that writes to the v1 fields (e.g. in a `withStaticSpan` callback) aren't dropped. - const attributes = { ...span.data } as RawAttributes>; - if (span.op !== undefined) { - attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = span.op; - } - if (span.origin !== undefined) { - attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = span.origin; - } - - return { +export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStreamedSpan { + const streamedSpan: StreamedSpanJSON = { trace_id: span.trace_id, span_id: span.span_id, parent_span_id: span.parent_span_id, @@ -26,43 +14,10 @@ export function spanJsonToStreamedSpanJson(span: SpanJSON): StreamedSpanJSON { start_timestamp: span.start_timestamp, end_timestamp: span.timestamp || span.start_timestamp, status: !span.status || span.status === 'ok' || span.status === 'cancelled' ? 'ok' : 'error', - is_segment: span.is_segment ?? false, - attributes, + is_segment: false, + attributes: { ...(span.data as RawAttributes>) }, 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)); -} -/** - * Converts an intermediate v2 StreamedSpanJSON back to a v1 SpanJSON. - * - * The inverse of {@link spanJsonToStreamedSpanJson}, used to hand a `withStaticSpan` `beforeSendSpan` - * callback the v1 format it expects for spans that are streamed despite the static trace lifecycle. - * - * v1 fields that have no v2 counterpart (`measurements`, `segment_id`) are not restored. - * `profile_id` and `exclusive_time` stay readable through `data`. - */ -export function streamedSpanJsonToSpanJson(span: StreamedSpanJSON): SpanJSON { - const data = { ...span.attributes } as SpanAttributes; - - return { - trace_id: span.trace_id, - span_id: span.span_id, - parent_span_id: span.parent_span_id, - description: span.name, - start_timestamp: span.start_timestamp, - timestamp: span.end_timestamp, - status: span.status, - is_segment: span.is_segment, - data, - op: data[SEMANTIC_ATTRIBUTE_SENTRY_OP], - origin: data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined, - links: span.links as SpanLinkJSON[] | undefined, - }; + return streamedSpanJsonToSerializedSpan(streamedSpan); } diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index aedd1b7d006a..9211f62749d0 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { Contexts, SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../../../src'; +import type { Contexts, StreamedSpanJSON } from '../../../../src'; import { captureSpan, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, @@ -20,7 +20,6 @@ import { } from '../../../../src'; import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; import { scopeContextsToSpanAttributes } from '../../../../src/tracing/spans/scopeContextAttributes'; -import type { TestClientOptions } from '../../../mocks/client'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; import { SENTRY_SEGMENT_ID, @@ -481,7 +480,6 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', - traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -494,7 +492,7 @@ describe('captureSpan', () => { expect(beforeSendSpan).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId })); }); - it("doesn't apply beforeSendSpan if it is marked as static by default", () => { + it("doesn't apply beforeSendSpan if it is marked as static", () => { const beforeSendSpan = withStaticSpan(vi.fn(span => span)); const client = new TestClient( @@ -503,7 +501,6 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', - traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -526,7 +523,6 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', - traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -542,106 +538,6 @@ describe('captureSpan', () => { consoleWarnSpy.mockRestore(); }); - - // Standalone spans (INP web vital spans) are streamed even with the static trace lifecycle, - // so a `withStaticSpan` callback has to see them in the v1 format it expects. - describe('with the static trace lifecycle', () => { - // Captures a child span so that ending it doesn't also send a transaction (which would apply the - // static callback a second time, via the transaction pipeline). - function captureStaticChildSpan( - beforeSendSpan: TestClientOptions['beforeSendSpan'], - attributes: Record = { 'sentry.op': 'http.client' }, - ): SerializedStreamedSpan { - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://dsn@ingest.f00.f00/1', - tracesSampleRate: 1, - traceLifecycle: 'static', - beforeSendSpan, - }), - ); - - const span = withScope(scope => { - scope.setClient(client); - const rootSpan = startInactiveSpan({ name: 'root' }); - const span = startInactiveSpan({ name: 'my-span', attributes, parentSpan: rootSpan }); - span.end(); - return span; - }); - - return captureSpan(span, client); - } - - it('applies a static beforeSendSpan callback and hands it the v1 span format', () => { - const beforeSendSpan = vi.fn((span: SpanJSON) => span); - - captureStaticChildSpan(withStaticSpan(beforeSendSpan)); - - expect(beforeSendSpan).toHaveBeenCalledTimes(1); - expect(beforeSendSpan).toHaveBeenCalledWith( - expect.objectContaining({ - description: 'my-span', - op: 'http.client', - data: expect.objectContaining({ 'sentry.op': 'http.client' }), - timestamp: expect.any(Number), - }), - ); - }); - - it('carries modifications from a static beforeSendSpan callback into the serialized span', () => { - const serialized = captureStaticChildSpan( - withStaticSpan((span: SpanJSON) => { - span.description = 'redacted'; - span.data['http.url'] = '[Filtered]'; - span.op = 'http.other'; - return span; - }), - { 'sentry.op': 'http.client', 'http.url': 'https://example.com/secret' }, - ); - - expect(serialized.name).toBe('redacted'); - expect(serialized.attributes['http.url']).toEqual({ type: 'string', value: '[Filtered]' }); - expect(serialized.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.other' }); - }); - - it('preserves span fields the callback leaves untouched', () => { - const serialized = captureStaticChildSpan(withStaticSpan((span: SpanJSON) => span)); - - expect(serialized).toEqual( - expect.objectContaining({ - name: 'my-span', - is_segment: false, - status: 'ok', - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - span_id: expect.stringMatching(/^[\da-f]{16}$/), - trace_id: expect.stringMatching(/^[\da-f]{32}$/), - }), - ); - expect(serialized.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); - }); - - it("doesn't a default beforeSendSpan callback without the withStaticSpan wrapper", () => { - const beforeSendSpan = vi.fn(span => span); - - const serialized = captureStaticChildSpan(beforeSendSpan); - - expect(beforeSendSpan).not.toHaveBeenCalled(); - expect(serialized.name).toBe('my-span'); - }); - - it('keeps the span if a static beforeSendSpan callback returns null', () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - - const serialized = captureStaticChildSpan(withStaticSpan(vi.fn(() => null as unknown as SpanJSON))); - - // The drop warning itself is only logged once per process, so it is asserted in the streamed test above. - expect(serialized.name).toBe('my-span'); - expect(serialized.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); - - consoleWarnSpy.mockRestore(); - }); - }); }); }); diff --git a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts index 22758f4879d0..94b25c590d19 100644 --- a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { SpanJSON, StreamedSpanJSON } from '../../../../src/types/span'; -import { - spanJsonToSerializedStreamedSpan, - spanJsonToStreamedSpanJson, - streamedSpanJsonToSpanJson, -} from '../../../../src/tracing/spans/spanJsonToStreamedSpan'; +import type { SpanJSON } from '../../../../src/types/span'; +import { spanJsonToSerializedStreamedSpan } from '../../../../src/tracing/spans/spanJsonToStreamedSpan'; function makeSpanJSON(overrides: Partial = {}): SpanJSON { return { @@ -94,99 +90,4 @@ describe('spanJsonToSerializedStreamedSpan', () => { { trace_id: 'aabb', span_id: 'ccdd', sampled: true, attributes: { foo: { type: 'string', value: 'bar' } } }, ]); }); - - it('preserves is_segment', () => { - expect(spanJsonToStreamedSpanJson(makeSpanJSON({ is_segment: true })).is_segment).toBe(true); - expect(spanJsonToStreamedSpanJson(makeSpanJSON({ is_segment: undefined })).is_segment).toBe(false); - }); - - it('copies op and origin into attributes so edits to the v1 fields are kept', () => { - const result = spanJsonToStreamedSpanJson( - makeSpanJSON({ op: 'http.other', origin: 'manual.http', data: { 'sentry.op': 'http.client' } }), - ); - - expect(result.attributes?.['sentry.op']).toBe('http.other'); - expect(result.attributes?.['sentry.origin']).toBe('manual.http'); - }); -}); - -describe('streamedSpanJsonToSpanJson', () => { - function makeStreamedSpanJSON(overrides: Partial = {}): StreamedSpanJSON { - return { - span_id: 'abc123def456789a', - trace_id: '00112233445566778899aabbccddeeff', - name: 'GET /users', - start_timestamp: 1000, - end_timestamp: 1005, - status: 'ok', - is_segment: false, - attributes: { 'sentry.op': 'http.client', 'sentry.origin': 'auto.http', 'http.url': 'https://example.com' }, - ...overrides, - }; - } - - it('maps StreamedSpanJSON fields to the v1 SpanJSON fields', () => { - const result = streamedSpanJsonToSpanJson(makeStreamedSpanJSON({ parent_span_id: 'parent00deadbeef' })); - - expect(result).toEqual({ - span_id: 'abc123def456789a', - trace_id: '00112233445566778899aabbccddeeff', - parent_span_id: 'parent00deadbeef', - description: 'GET /users', - start_timestamp: 1000, - timestamp: 1005, - status: 'ok', - is_segment: false, - op: 'http.client', - origin: 'auto.http', - links: undefined, - data: { 'sentry.op': 'http.client', 'sentry.origin': 'auto.http', 'http.url': 'https://example.com' }, - }); - }); - - it('copies attributes rather than aliasing them', () => { - const streamedSpan = makeStreamedSpanJSON(); - const result = streamedSpanJsonToSpanJson(streamedSpan); - - result.data['http.url'] = '[Filtered]'; - - expect(streamedSpan.attributes?.['http.url']).toBe('https://example.com'); - }); - - it('handles a span without attributes', () => { - const result = streamedSpanJsonToSpanJson(makeStreamedSpanJSON({ attributes: undefined })); - - expect(result.data).toEqual({}); - expect(result.op).toBeUndefined(); - expect(result.origin).toBeUndefined(); - }); - - it.each([ - ['a segment span', true], - ['a child span', false], - ])('round-trips %s unchanged', (_, is_segment) => { - const streamedSpan = makeStreamedSpanJSON({ - is_segment, - status: 'error', - parent_span_id: 'parent00deadbeef', - links: [{ trace_id: 'aabb', span_id: 'ccdd', sampled: true, attributes: { foo: 'bar' } }], - }); - - expect(spanJsonToStreamedSpanJson(streamedSpanJsonToSpanJson(streamedSpan))).toEqual(streamedSpan); - }); - - it('round-trips modifications made in the v1 format back into the streamed format', () => { - const streamedSpan = makeStreamedSpanJSON(); - - const spanJson = streamedSpanJsonToSpanJson(streamedSpan); - spanJson.description = 'redacted'; - spanJson.data['http.url'] = '[Filtered]'; - spanJson.op = 'http.other'; - - const result = spanJsonToStreamedSpanJson(spanJson); - - expect(result.name).toBe('redacted'); - expect(result.attributes?.['http.url']).toBe('[Filtered]'); - expect(result.attributes?.['sentry.op']).toBe('http.other'); - }); }); From 1f53fc1b1233bb7615a5dd0a1eee40b5947bcc8a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 3 Aug 2026 15:58:19 +0200 Subject: [PATCH 10/13] fix integration test, harden captureStandaloneSpanWithStaticCallback --- .../tracing/metrics/web-vitals-inp-before-send-span/init.js | 4 ++-- packages/core/src/tracing/sentrySpan.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 64cd4ccff9d7..5ea3f7b57bf2 100644 --- 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 @@ -17,14 +17,14 @@ Sentry.init({ 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 => { + beforeSendSpan: Sentry.withStaticSpan(span => { if (span.op === 'ui.interaction.click') { span.description = 'scrubbed'; span.data['custom.attribute'] = 'from-before-send-span'; } return span; - }, + }), debug: true, }); diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index c12f7b728f88..b9eb44216fa5 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -557,14 +557,14 @@ 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(); + const { beforeSendSpan, traceLifecycle } = client.getOptions(); // A user who opted out of span streaming wraps `beforeSendSpan` with `withStaticSpan` and writes it 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 && isStaticBeforeSendSpanCallback(beforeSendSpan)) { + if (traceLifecycle === 'static' && isStaticBeforeSendSpanCallback(beforeSendSpan)) { const serializedSpan = captureStandaloneSpanWithStaticCallback(span, client, beforeSendSpan); const dsc = getDynamicSamplingContextFromSpan(span); // sendEnvelope should not throw From 596c2a2b73283e478fb27cf0a277e5a7ce25c334 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 4 Aug 2026 11:22:30 +0200 Subject: [PATCH 11/13] account for unmigrated beforeSendSpan with INP spans --- packages/core/src/tracing/spans/captureSpan.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index be87d5627f21..77da8f433820 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -75,9 +75,12 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW // This also invokes the `processSpan` hook of all integrations client.emit('processSpan', spanJSON); - const { beforeSendSpan } = client.getOptions(); + const { beforeSendSpan, traceLifecycle } = client.getOptions(); const processedSpan = - beforeSendSpan && !isStaticBeforeSendSpanCallback(beforeSendSpan) + // check for traceLifecycle here because in static lifecycle, + // captureSpan is called for INP spans. If an unmigrated beforeSendSpan + // callback is run on these spans, it will throw an error. + traceLifecycle === 'stream' && beforeSendSpan && !isStaticBeforeSendSpanCallback(beforeSendSpan) ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) : spanJSON; From 9af0cc2384346bd84f10db513db1ab884dedb363 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 4 Aug 2026 15:32:26 +0200 Subject: [PATCH 12/13] fix unit tests --- packages/core/src/tracing/spans/captureSpan.ts | 2 +- packages/core/test/lib/tracing/spans/captureSpan.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 77da8f433820..16aee9c8bb65 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -80,7 +80,7 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW // check for traceLifecycle here because in static lifecycle, // captureSpan is called for INP spans. If an unmigrated beforeSendSpan // callback is run on these spans, it will throw an error. - traceLifecycle === 'stream' && beforeSendSpan && !isStaticBeforeSendSpanCallback(beforeSendSpan) + traceLifecycle !== 'static' && beforeSendSpan && !isStaticBeforeSendSpanCallback(beforeSendSpan) ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) : spanJSON; diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 9211f62749d0..90c4c8c8d1ea 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -480,6 +480,7 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', + traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -523,6 +524,7 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', + traceLifecycle: 'stream', beforeSendSpan, }), ); From 4d63933be9feb6873c3352d672ed1d132207c7ac Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 4 Aug 2026 15:46:51 +0200 Subject: [PATCH 13/13] fix newly added tests --- packages/cloudflare/test/instrumentCloudflareAgent.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts index 5f0211fdc739..618377156fd1 100644 --- a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts +++ b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts @@ -10,6 +10,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import { CloudflareClient, type CloudflareClientOptions } from '../src/client'; +import { withStaticSpan } from '../src/index'; import { instrumentCloudflareAgent } from '../src/instrumentations/agents'; import { resetSdk } from './testUtils'; @@ -62,10 +63,10 @@ describe('instrumentCloudflareAgent', () => { transactions.push(event); return event; }, - beforeSendSpan: span => { + beforeSendSpan: withStaticSpan(span => { childSpans.push(span); return span; - }, + }), }; client = new CloudflareClient(options);