From 5d99eac23ab07632640c419d887d21e257da21f6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 24 Jul 2026 18:32:38 +0200 Subject: [PATCH 1/7] 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 | 11 ++-- .../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, 199 insertions(+), 83 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 cb157ec9007e..b12070bdf9ec 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -173,6 +173,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 30f597757b40..8870c7ca67d0 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -160,6 +160,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 4a6c25eed27d..beb8a365839c 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -178,6 +178,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 a95da03e25ec..178ded35b681 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -110,6 +110,8 @@ export { growthbookIntegration, logger, metrics, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, spanStreamingIntegration, instrumentStateGraph, diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 6076b065cf62..61b4edf28d34 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'; @@ -1654,7 +1654,7 @@ function processBeforeSend( hint: EventHint, ): PromiseLike | Event | null { const { beforeSend, beforeSendTransaction, ignoreSpans } = 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 700e65d2a974..a36e15105ec6 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -86,8 +86,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 e5f938082509..758a874b4acc 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -630,14 +630,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 @@ -676,12 +676,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 5b0cb752f2c2..aa284ca91692 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'; @@ -1093,7 +1094,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); @@ -1312,14 +1313,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); @@ -1427,10 +1430,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); @@ -1509,7 +1514,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 7522b7061234..33da904954b8 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,10 +296,10 @@ describe('SentrySpan', () => { expect(mockSend).toHaveBeenCalled(); }); - test('ignores a non-streamed `beforeSendSpan` for standalone spans', () => { - // Standalone spans are sent as v2 streamed spans, which only honor a `beforeSendSpan` wrapped - // with `withStreamedSpan`. A plain callback is ignored, so the span is sent unmodified. - const beforeSendSpan = vi.fn(() => null as unknown as SpanJSON); + test('ignores a static `beforeSendSpan` for standalone spans', () => { + // Standalone spans are sent as v2 streamed spans, which only honor an unwrapped (streamed) + // `beforeSendSpan`. A callback wrapped with `withStaticSpan` is ignored, so the span is sent unmodified. + const beforeSendSpan = withStaticSpan(vi.fn(() => null as unknown as SpanJSON)); 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 0560ebd2f702..34c35868d20b 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'; @@ -475,8 +475,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({ @@ -496,8 +496,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({ @@ -519,8 +519,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 52a04b18aeb7..505217df5ee7 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 715c2484296e..d5939487ea8d 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -155,6 +155,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 d07490563212..2a9978672951 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -160,6 +160,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 bf8dd5d7533e..40657f84f72b 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -174,7 +174,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 4fd2331b34fa..e21dffa78807 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -133,6 +133,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 fd5fbb2e0e39..e46cd2744e49 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/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/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 5ebd934e9e88..a6defb62c39d 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -137,6 +137,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 d8e277f9fea0..7fef86995fee 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -105,6 +105,8 @@ export { featureFlagsIntegration, logger, metrics, + withStaticSpan, + // eslint-disable-next-line typescript/no-deprecated withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; From 3380bc9969cad6e4e7d987c2b320f1d35e34f718 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 28 Jul 2026 14:05:56 +0200 Subject: [PATCH 2/7] 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 b12070bdf9ec..f503915569af 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -174,7 +174,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 8870c7ca67d0..991ef239696a 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -161,7 +161,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 beb8a365839c..353d16545358 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -179,7 +179,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 178ded35b681..46e8effb6c34 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -111,7 +111,7 @@ export { logger, metrics, withStaticSpan, - // eslint-disable-next-line typescript/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, spanStreamingIntegration, instrumentStateGraph, diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 61b4edf28d34..eeecc515eac6 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -233,7 +233,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 = {}; @@ -249,6 +255,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 a36e15105ec6..d48c7f075e33 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -88,7 +88,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 758a874b4acc..2417cb1d12f6 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -674,12 +674,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 aa284ca91692..84009eb726e5 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('getOptions()', () => { @@ -125,6 +199,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 505217df5ee7..4ff878591009 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 d5939487ea8d..74257f226a90 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -156,7 +156,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 2a9978672951..191c79a6756b 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -161,7 +161,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 40657f84f72b..2821400dac90 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -174,7 +174,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 e21dffa78807..88350f5e593d 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -134,7 +134,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 e46cd2744e49..dc8640b965e8 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/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/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 a6defb62c39d..07a8147cefe8 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -138,7 +138,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 7fef86995fee..29831c013835 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/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'; From bf75bf376fcb3071b61603282cc07abb074a1afa Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 28 Jul 2026 16:10:55 +0200 Subject: [PATCH 3/7] 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 065b623967c131a7105596c34911c0b2a0307187 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 29 Jul 2026 11:09:23 +0200 Subject: [PATCH 4/7] 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 438b2572f682..29d3e2a25f51 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -124,11 +124,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 d48c7f075e33..daec7c8a6a88 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -91,7 +91,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 2417cb1d12f6..afe2bd41f4ab 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -628,10 +628,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. * @@ -676,17 +679,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 2821400dac90..bdc31ef809ab 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -174,8 +174,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 ae953cfe8538f3d2af0be14e01bb7d5eb1540312 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 29 Jul 2026 12:44:10 +0200 Subject: [PATCH 5/7] 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 +++ .../core/src/tracing/spans/captureSpan.ts | 46 ++- .../tracing/spans/spanJsonToStreamedSpan.ts | 59 +++- .../core/test/lib/tracing/sentrySpan.test.ts | 299 +++++++++++------- .../lib/tracing/spans/captureSpan.test.ts | 110 ++++++- .../spans/spanJsonToStreamedSpan.test.ts | 103 +++++- 8 files changed, 589 insertions(+), 133 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/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index b65f85b02d46..47c6ee6ac021 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -11,7 +11,7 @@ import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; -import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types/span'; +import type { SerializedStreamedSpan, Span, SpanJSON, StreamedSpanJSON } from '../../types/span'; import { getCombinedScopeData } from '../../utils/scopeData'; import { INTERNAL_getSegmentSpan, @@ -20,8 +20,10 @@ import { streamedSpanJsonToSerializedSpan, } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; -import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; +import { isStaticBeforeSendSpanCallback, isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; +import { hasSpanStreamingEnabled } from './hasSpanStreamingEnabled'; import { scopeContextsToSpanAttributes } from './scopeContextAttributes'; +import { spanJsonToStreamedSpanJson, streamedSpanJsonToSpanJson } from './spanJsonToStreamedSpan'; import { DEFAULT_ENVIRONMENT } from '../../constants'; import { SENTRY_SDK_NAME, @@ -73,11 +75,7 @@ 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 processedSpan = - beforeSendSpan && isStreamedBeforeSendSpanCallback(beforeSendSpan) - ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan) - : spanJSON; + const processedSpan = applyBeforeSendSpan(spanJSON, client); const spanNameSource = processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; if (spanJSON.is_segment && spanNameSource) { @@ -152,13 +150,39 @@ function applyCommonSpanAttributes( }); } +/** + * Apply the configured `beforeSendSpan` callback to a span JSON, in whichever span format the + * callback expects. + * + * Standalone spans (INP web vital spans) are sent as v2 spans even when the static trace + * lifecycle is configured, so a `withStaticSpan` callback would otherwise never see them. For those, + * the span is converted to the v1 format for the callback and converted back afterwards. + * + * TODO(v12): Remove the v1 and static callback conversion shenanigans once we drop transactions. + */ +function applyBeforeSendSpan(span: StreamedSpanJSON, client: Client): StreamedSpanJSON { + const { beforeSendSpan } = client.getOptions(); + + if (!beforeSendSpan) { + return span; + } + + if (hasSpanStreamingEnabled(client)) { + return isStreamedBeforeSendSpanCallback(beforeSendSpan) ? applyBeforeSendSpanCallback(span, beforeSendSpan) : span; + } + + return isStaticBeforeSendSpanCallback(beforeSendSpan) + ? spanJsonToStreamedSpanJson(applyBeforeSendSpanCallback(streamedSpanJsonToSpanJson(span), beforeSendSpan)) + : span; +} + /** * Apply a user-provided beforeSendSpan callback to a span JSON. */ -export function applyBeforeSendSpanCallback( - span: StreamedSpanJSON, - beforeSendSpan: (span: StreamedSpanJSON) => StreamedSpanJSON, -): StreamedSpanJSON { +export function applyBeforeSendSpanCallback( + span: T, + beforeSendSpan: (span: T) => T, +): T { const modifedSpan = beforeSendSpan(span); if (!modifedSpan) { showSpanDropWarning(); 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/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 33da904954b8..c5c3b792ccaa 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -239,128 +239,207 @@ describe('SentrySpan', () => { expect(spanToJSON(span).timestamp).toBe(endTime / 1000); }); - test('uses sampled config for standalone span', () => { - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://username@domain/123', - enableSend: true, - }), - ); - setCurrentClient(client); - - // @ts-expect-error Accessing private transport API - const mockSend = vi.spyOn(client._transport, 'send'); - - const notSampledSpan = new SentrySpan({ - name: 'not-sampled', - isStandalone: true, - startTimestamp: 1, - endTimestamp: 2, - sampled: false, + describe('standalone spans', () => { + test('uses sampled config for standalone span', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + }), + ); + setCurrentClient(client); + + // @ts-expect-error Accessing private transport API + const mockSend = vi.spyOn(client._transport, 'send'); + + const notSampledSpan = new SentrySpan({ + name: 'not-sampled', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: false, + }); + notSampledSpan.end(); + expect(mockSend).not.toHaveBeenCalled(); + + const sampledSpan = new SentrySpan({ + name: 'is-sampled', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + sampledSpan.end(); + expect(mockSend).toHaveBeenCalledTimes(1); }); - notSampledSpan.end(); - expect(mockSend).not.toHaveBeenCalled(); - const sampledSpan = new SentrySpan({ - name: 'is-sampled', - isStandalone: true, - startTimestamp: 1, - endTimestamp: 2, - sampled: true, + test('sends the span if `beforeSendSpan` does not modify the span', () => { + const beforeSendSpan = withStaticSpan(vi.fn(span => span)); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + beforeSendSpan, + }), + ); + setCurrentClient(client); + + // @ts-expect-error Accessing private transport API + const mockSend = vi.spyOn(client._transport, 'send'); + const span = new SentrySpan({ + name: 'test', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + span.end(); + expect(mockSend).toHaveBeenCalled(); }); - sampledSpan.end(); - expect(mockSend).toHaveBeenCalledTimes(1); - }); - test('sends the span if `beforeSendSpan` does not modify the span', () => { - const beforeSendSpan = withStaticSpan(vi.fn(span => span)); - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://username@domain/123', - enableSend: true, - beforeSendSpan, - }), - ); - setCurrentClient(client); + test('applies a static `beforeSendSpan` to standalone spans in the static trace lifecycle', () => { + // Standalone spans are sent as v2 streamed spans even in the static trace lifecycle, so a + // `withStaticSpan` callback still has to see them — in the v1 format it expects. + // The callback mutates in place, so snapshot what it received before modifying it + let received: Partial | undefined; + const beforeSendSpan = vi.fn((span: SpanJSON) => { + received = { ...span }; + span.description = 'renamed-by-beforeSendSpan'; + return span; + }); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + traceLifecycle: 'static', + beforeSendSpan: withStaticSpan(beforeSendSpan), + }), + ); + setCurrentClient(client); + + // @ts-expect-error Accessing private transport API + const mockSend = vi.spyOn(client._transport, 'send'); + const span = new SentrySpan({ + name: 'test', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + span.end(); + + expect(beforeSendSpan).toHaveBeenCalledTimes(1); + // the v1 span format, not the streamed one + expect(received).toEqual( + expect.objectContaining({ + description: 'test', + timestamp: 2, + start_timestamp: 1, + origin: 'manual', + data: expect.objectContaining({ 'sentry.origin': 'manual' }), + }), + ); - // @ts-expect-error Accessing private transport API - const mockSend = vi.spyOn(client._transport, 'send'); - const span = new SentrySpan({ - name: 'test', - isStandalone: true, - startTimestamp: 1, - endTimestamp: 2, - sampled: true, + expect(mockSend).toHaveBeenCalled(); + const sentSpan = mockSend.mock.calls[0]![0]![1][0][1].items[0]; + expect(sentSpan.name).toBe('renamed-by-beforeSendSpan'); }); - span.end(); - expect(mockSend).toHaveBeenCalled(); - }); - test('ignores a static `beforeSendSpan` for standalone spans', () => { - // Standalone spans are sent as v2 streamed spans, which only honor an unwrapped (streamed) - // `beforeSendSpan`. A callback wrapped with `withStaticSpan` is ignored, so the span is sent unmodified. - const beforeSendSpan = withStaticSpan(vi.fn(() => null as unknown as SpanJSON)); - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://username@domain/123', - enableSend: true, - beforeSendSpan, - }), - ); - setCurrentClient(client); - - const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); - // @ts-expect-error Accessing private transport API - const mockSend = vi.spyOn(client._transport, 'send'); - const span = new SentrySpan({ - name: 'test', - isStandalone: true, - startTimestamp: 1, - endTimestamp: 2, - sampled: true, + test('ignores an unwrapped (streamed) `beforeSendSpan` for standalone spans in the static trace lifecycle', () => { + const beforeSendSpan = vi.fn(() => null as unknown as SpanJSON); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + traceLifecycle: 'static', + // @ts-expect-error - an unwrapped callback expects the streamed format + beforeSendSpan, + }), + ); + setCurrentClient(client); + + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + // @ts-expect-error Accessing private transport API + const mockSend = vi.spyOn(client._transport, 'send'); + const span = new SentrySpan({ + name: 'test', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + span.end(); + + expect(beforeSendSpan).not.toHaveBeenCalled(); + expect(mockSend).toHaveBeenCalled(); + expect(recordDroppedEventSpy).not.toHaveBeenCalled(); }); - span.end(); - - expect(beforeSendSpan).not.toHaveBeenCalled(); - expect(mockSend).toHaveBeenCalled(); - expect(recordDroppedEventSpy).not.toHaveBeenCalled(); - }); - - test('sends a standalone span on its own and excludes it from the parent transaction', async () => { - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://username@domain/123', - enableSend: true, - tracesSampleRate: 1, - }), - ); - setCurrentClient(client); - const envelopes: Envelope[] = []; - client.on('beforeEnvelope', envelope => { - envelopes.push(envelope); + test('ignores a static `beforeSendSpan` for standalone spans when span streaming is enabled', () => { + const beforeSendSpan = withStaticSpan(vi.fn(() => null as unknown as SpanJSON)); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + traceLifecycle: 'stream', + beforeSendSpan, + }), + ); + setCurrentClient(client); + + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + // @ts-expect-error Accessing private transport API + const mockSend = vi.spyOn(client._transport, 'send'); + const span = new SentrySpan({ + name: 'test', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + span.end(); + + expect(beforeSendSpan).not.toHaveBeenCalled(); + expect(mockSend).toHaveBeenCalled(); + expect(recordDroppedEventSpy).not.toHaveBeenCalled(); }); - startSpan({ name: 'root' }, () => { - const standaloneChild = startInactiveSpan({ name: 'inp', experimental: { standalone: true } }); - standaloneChild.end(); + test('sends a standalone span on its own and excludes it from the parent transaction', async () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + tracesSampleRate: 1, + }), + ); + setCurrentClient(client); + + const envelopes: Envelope[] = []; + client.on('beforeEnvelope', envelope => { + envelopes.push(envelope); + }); + + startSpan({ name: 'root' }, () => { + const standaloneChild = startInactiveSpan({ name: 'inp', experimental: { standalone: true } }); + standaloneChild.end(); + }); + + await client.flush(); + + const items = envelopes.flatMap( + envelope => envelope[1] as Array<[{ type?: string; content_type?: string }, any]>, + ); + + // The standalone child is sent on its own as a v2 streamed span... + const streamedItem = items.find(item => item[0]?.content_type === 'application/vnd.sentry.items.span.v2+json'); + expect(streamedItem?.[1]?.items?.[0]?.name).toBe('inp'); + + // ...and is NOT folded into the root transaction (no double-send). + const transactionItem = items.find(item => item[0]?.type === 'transaction'); + expect(transactionItem?.[1]?.spans?.map((span: { description?: string }) => span.description)).not.toContain( + 'inp', + ); }); - - await client.flush(); - - const items = envelopes.flatMap( - envelope => envelope[1] as Array<[{ type?: string; content_type?: string }, any]>, - ); - - // The standalone child is sent on its own as a v2 streamed span... - const streamedItem = items.find(item => item[0]?.content_type === 'application/vnd.sentry.items.span.v2+json'); - expect(streamedItem?.[1]?.items?.[0]?.name).toBe('inp'); - - // ...and is NOT folded into the root transaction (no double-send). - const transactionItem = items.find(item => item[0]?.type === 'transaction'); - expect(transactionItem?.[1]?.spans?.map((span: { description?: string }) => span.description)).not.toContain( - 'inp', - ); }); test('build TransactionEvent for basic root span', () => { diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 34c35868d20b..fc2aab1a3e2c 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, @@ -475,7 +476,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( @@ -484,6 +485,7 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', + traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -496,7 +498,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( @@ -505,6 +507,7 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', + traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -527,6 +530,7 @@ describe('captureSpan', () => { tracesSampleRate: 1, release: '1.0.0', environment: 'staging', + traceLifecycle: 'stream', beforeSendSpan, }), ); @@ -542,6 +546,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 fd99c4b9c48ef1c67c68e8c7f7293a56b3441c28 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 29 Jul 2026 12:44:25 +0200 Subject: [PATCH 6/7] reword migration --- MIGRATION.md | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 29d3e2a25f51..13a18f12df15 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -120,13 +120,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: @@ -137,7 +140,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 @@ -179,7 +181,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 @@ -189,7 +191,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` @@ -215,9 +217,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` @@ -235,11 +237,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' } }], }); ``` @@ -252,6 +254,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 9176c4b6dbe142da81d0980602e588d3d3eca080 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 29 Jul 2026 17:32:06 +0200 Subject: [PATCH 7/7] size limit --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index d589cfccdc65..6ef49dad2033 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -286,7 +286,7 @@ module.exports = [ path: createCDNPath('bundle.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '100 KB', + limit: '101 KB', disablePlugins: ['@size-limit/esbuild'], }, {