From dcefe76097fa1a0c91b6917ea11b02dc0286d413 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 14:45:17 +0200 Subject: [PATCH 01/15] feat(node)!: Default most SDKs to a no-tracer-provider setup Flip the default OpenTelemetry setup for server SDKs: most now run without a Sentry tracer provider, using an AsyncLocalStorage context strategy for scope isolation. Reuses the existing `skipOpenTelemetrySetup` option with a flipped default (true for node/cloudflare, false for nextjs/sveltekit). Co-Authored-By: Claude Opus 4.8 --- MIGRATION.md | 23 +++++++++++++ packages/cloudflare/src/client.ts | 14 ++++---- packages/cloudflare/src/sdk.ts | 14 ++++---- .../cloudflare/test/opentelemetry.test.ts | 4 +++ packages/nextjs/src/server/index.ts | 3 ++ packages/node/src/integrations/http/index.ts | 6 ++-- .../node/src/integrations/node-fetch/index.ts | 6 ++-- .../node/src/integrations/node-fetch/types.ts | 2 +- packages/node/src/sdk/index.ts | 14 +++++++- packages/node/src/types.ts | 14 +++++--- .../test/integration/transactions.test.ts | 5 +-- packages/node/test/sdk/init.test.ts | 33 +++++++++++++------ packages/sveltekit/src/server/sdk.ts | 3 ++ 13 files changed, 102 insertions(+), 39 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index dbae6511e3a5..d9d484a941e1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -82,6 +82,29 @@ Only `@sentry/nextjs` and `@sentry/sveltekit` still set up an OpenTelemetry comp This means you can run your own OpenTelemetry setup cleanly alongside Sentry without having Sentry spans leak into your pipeline anymore. Your OpenTelemetry setup will no longer be required to use Sentry components for exporting, context management and trace propagation. +This behavior is controlled by the existing `skipOpenTelemetrySetup` option, whose default was flipped in v11. It now defaults to `true` for most server SDKs (including `@sentry/node`, `@sentry/bun`, the serverless SDKs, and `@sentry/cloudflare`) and to `false` for `@sentry/nextjs` and `@sentry/sveltekit`. When `true`, the SDK skips the tracer provider and isolates scopes with a native AsyncLocalStorage strategy; it still emits its own spans, but does not pick up spans created through `@opentelemetry/api`. Set it to `false` to have Sentry register its tracer provider and surface your OpenTelemetry spans: + +```js +Sentry.init({ + dsn: '__DSN__', + // Register the Sentry OpenTelemetry tracer provider to surface OTel spans in Sentry + skipOpenTelemetrySetup: false, +}); +``` + +In v10, setting `skipOpenTelemetrySetup: true` also turned Sentry's own HTTP and fetch spans off by default, on the assumption that your own OpenTelemetry `HttpInstrumentation` would emit them instead. That is no longer the case: Sentry now emits HTTP and fetch spans whenever tracing is enabled, regardless of `skipOpenTelemetrySetup`. If you run your own OpenTelemetry HTTP instrumentation alongside Sentry, disable Sentry's spans to avoid duplicates: + +```js +Sentry.init({ + dsn: '__DSN__', + integrations: [ + // Let your own OpenTelemetry HttpInstrumentation own HTTP & fetch spans + Sentry.httpIntegration({ spans: false }), + Sentry.nativeNodeFetchIntegration({ spans: false }), + ], +}); +``` + With this, we also heavily reduced our OpenTelemetry dependencies, with `@opentelemetry/api` being the only remaining package we abide by. These changes also mean `@sentry/node-core` no longer serves any purpose and was [merged back into `@sentry/node`](#sentrynode-core-was-merged-back-into-sentrynode). For most users, day-to-day tracing is **unchanged**. diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 84d0cbf52522..5ec2f3439222 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -175,15 +175,15 @@ interface BaseCloudflareOptions { enableDedupe?: boolean; /** - * The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility - * via a custom trace provider. - * This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry. - * HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope. - * This should be good enough for many, but not all integrations. + * The Cloudflare SDK is not OpenTelemetry native. By default (`true`) it does not set up a tracer + * provider; spans are emitted via the SDK's own instrumentation and scopes are isolated with + * AsyncLocalStorage. * - * If you want to opt-out of setting up the OpenTelemetry compatibility tracer, set this to `true`. + * Set this to `false` to opt into the OpenTelemetry compatibility tracer, which captures spans + * emitted via `@opentelemetry/api`. Big caveat: it does not handle custom context, always working + * off the current scope. This is good enough for many, but not all, integrations. * - * @default false + * @default true */ skipOpenTelemetrySetup?: boolean; diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 87ee09e12176..0bf821139ff4 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -82,6 +82,9 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined { stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations: getIntegrationsToSetup(options), transport: options.transport || makeCloudflareTransport, + // Like most Node-based SDKs, Cloudflare defaults to running without a Sentry OpenTelemetry tracer + // provider. Scope isolation is handled by the entrypoint wrappers' AsyncLocalStorage strategy. + skipOpenTelemetrySetup: options.skipOpenTelemetrySetup ?? true, flushLock, }; @@ -95,14 +98,9 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined { } /*! rollup-include-development-only-end */ - /** - * The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility - * via a custom trace provider. - * This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry. - * HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope. - * This should be good enough for many, but not all integrations. - */ - if (!options.skipOpenTelemetrySetup) { + // Opt-in only: when `skipOpenTelemetrySetup` is `false`, set up a custom trace provider so spans + // emitted via `@opentelemetry/api` are captured by Sentry. See the option's docs for the caveats. + if (!clientOptions.skipOpenTelemetrySetup) { setupOpenTelemetryTracer(); } diff --git a/packages/cloudflare/test/opentelemetry.test.ts b/packages/cloudflare/test/opentelemetry.test.ts index 7f87a3499825..c4e53f41b7e2 100644 --- a/packages/cloudflare/test/opentelemetry.test.ts +++ b/packages/cloudflare/test/opentelemetry.test.ts @@ -48,6 +48,7 @@ describe('opentelemetry compatibility', () => { dsn: 'https://username@domain/123', tracesSampleRate: 1, traceLifecycle: 'static', + skipOpenTelemetrySetup: false, beforeSendTransaction: event => { transactionEvents.push(event); return null; @@ -109,6 +110,7 @@ describe('opentelemetry compatibility', () => { dsn: 'https://username@domain/123', tracesSampleRate: 1, traceLifecycle: 'static', + skipOpenTelemetrySetup: false, beforeSendTransaction: event => { transactionEvents.push(event); return null; @@ -153,6 +155,7 @@ describe('opentelemetry compatibility', () => { dsn: 'https://username@domain/123', tracesSampleRate: 1, traceLifecycle: 'static', + skipOpenTelemetrySetup: false, beforeSendTransaction: event => { transactionEvents.push(event); return null; @@ -181,6 +184,7 @@ describe('opentelemetry compatibility', () => { dsn: 'https://username@domain/123', tracesSampleRate: 1, traceLifecycle: 'static', + skipOpenTelemetrySetup: false, beforeSendTransaction: event => { transactionEvents.push(event); return null; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 70c1dce74817..aba16667f246 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -145,6 +145,9 @@ export function init(options: NodeOptions): NodeClient | undefined { environment: options.environment || process.env.SENTRY_ENVIRONMENT || getVercelEnv(false) || process.env.NODE_ENV, release: process.env._sentryRelease || globalWithInjectedValues._sentryRelease, defaultIntegrations: customDefaultIntegrations, + // Next.js emits its own OpenTelemetry spans, so it defaults to registering the Sentry tracer + // provider (unlike most Node-based SDKs). A user-provided value still overrides this via `...options`. + skipOpenTelemetrySetup: false, ...options, // Override runtime to 'cloudflare' when running on OpenNext/Cloudflare ...cloudflareConfig, diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index a9bb3d69eae2..610680874fa4 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -32,7 +32,7 @@ interface HttpOptions { * This will ensure that the default HttpInstrumentation from OpenTelemetry is not setup, * only the Sentry-specific instrumentation for request isolation is applied. * - * If `skipOpenTelemetrySetup: true` is configured, this defaults to `false`, otherwise it defaults to `true`. + * Defaults to `true` when tracing is enabled. */ spans?: boolean; @@ -55,8 +55,8 @@ interface HttpOptions { * Whether to inject trace propagation headers (sentry-trace, baggage, traceparent) into outgoing HTTP requests. * * When set to `false`, Sentry will not inject any trace propagation headers, but will still create breadcrumbs - * (if `breadcrumbs` is enabled). This is useful when `skipOpenTelemetrySetup: true` is configured and you want - * to avoid duplicate trace headers being injected by both Sentry and OpenTelemetry's HttpInstrumentation. + * (if `breadcrumbs` is enabled). This is useful when you run your own OpenTelemetry `HttpInstrumentation` and + * want to avoid duplicate trace headers being injected by both Sentry and OpenTelemetry. * * @default `true` */ diff --git a/packages/node/src/integrations/node-fetch/index.ts b/packages/node/src/integrations/node-fetch/index.ts index 79010e54ffa5..653606b52db9 100644 --- a/packages/node/src/integrations/node-fetch/index.ts +++ b/packages/node/src/integrations/node-fetch/index.ts @@ -24,7 +24,7 @@ const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { export const nativeNodeFetchIntegration = defineIntegration(_nativeNodeFetchIntegration); function _shouldInstrumentSpans(options: NodeFetchOptions, clientOptions: Partial = {}): boolean { - // If `spans` is passed in, it takes precedence - // Else, we by default emit spans, unless `skipOpenTelemetrySetup` is set to `true` or spans are not enabled - return options.spans ?? (!clientOptions.skipOpenTelemetrySetup && hasSpansEnabled(clientOptions)); + // If `spans` is passed in, it takes precedence. Otherwise emit spans whenever tracing is enabled; + // fetch instrumentation is channel-based and does not depend on a Sentry OpenTelemetry tracer provider. + return options.spans ?? hasSpansEnabled(clientOptions); } diff --git a/packages/node/src/integrations/node-fetch/types.ts b/packages/node/src/integrations/node-fetch/types.ts index b79e5a5fc3cf..4a5d38c5bc86 100644 --- a/packages/node/src/integrations/node-fetch/types.ts +++ b/packages/node/src/integrations/node-fetch/types.ts @@ -106,7 +106,7 @@ export interface NodeFetchOptions extends UndiciInstrumentationConfig { * If set to false, do not emit any spans. * Breadcrumbs and trace propagation for outgoing fetch requests are still applied. * - * If `skipOpenTelemetrySetup: true` is configured, this defaults to `false`, otherwise it defaults to `true`. + * Defaults to `true` when tracing is enabled. */ spans?: boolean; diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 0c234cede832..d687cc5a3295 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -16,6 +16,7 @@ import { stackParserFromStackParserOptions, } from '@sentry/core'; import { setOpenTelemetryContextAsyncContextStrategy, setupEventContextTrace } from '@sentry/opentelemetry'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import { isMainThread, parentPort } from 'node:worker_threads'; import { detectOrchestrionSetup } from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; @@ -171,7 +172,15 @@ function _init( const clientOptions = getClientOptions({ ...options, defaultIntegrations }, getDefaultIntegrationsImpl); - const asyncLocalStorageLookup = setOpenTelemetryContextAsyncContextStrategy(); + // When Sentry does not own an OpenTelemetry tracer provider, scope isolation runs on a pure + // AsyncLocalStorage strategy instead of the OpenTelemetry context strategy. Instrumentation still + // emits spans via core `startSpan`; there is just no OTel provider or propagator behind them. + let asyncLocalStorageLookup: ReturnType | undefined; + if (clientOptions.skipOpenTelemetrySetup) { + setAsyncLocalStorageAsyncContextStrategy(); + } else { + asyncLocalStorageLookup = setOpenTelemetryContextAsyncContextStrategy(); + } const scope = getCurrentScope(); scope.update(clientOptions.initialScope); @@ -249,6 +258,9 @@ function getClientOptions( tracesSampleRate, spotlight, traceLifecycle, + // Most Node-based SDKs default to running without a Sentry OpenTelemetry tracer provider. SDKs + // that need OTel spans surfaced in Sentry (nextjs, sveltekit) opt back in by passing `false`. + skipOpenTelemetrySetup: options.skipOpenTelemetrySetup ?? true, debug: envToBool(options.debug ?? process.env.SENTRY_DEBUG), }; diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 839db1b7547a..1f30f9fb880b 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -8,10 +8,16 @@ import type { NodeTransportOptions } from './transports'; */ export interface OpenTelemetryServerRuntimeOptions extends ServerRuntimeOptions { /** - * If this is set to true, the SDK will not set up OpenTelemetry automatically. - * In this case, you _have_ to ensure to set it up correctly yourself, including: - * * The `SentryPropagator` - * * The `SentryContextManager` + * Controls whether the SDK registers a Sentry OpenTelemetry tracer provider. + * + * When `true` (the default for most SDKs), no tracer provider is set up. The SDK isolates scopes + * with a native AsyncLocalStorage context strategy and still emits spans via its own + * instrumentation, but OpenTelemetry spans created through `@opentelemetry/api` are not picked up. + * + * When `false`, the SDK registers the `SentryTracerProvider` and `SentryPropagator` so that + * OpenTelemetry spans are surfaced in Sentry. This is the default for the Next.js and SvelteKit SDKs. + * + * @default true */ skipOpenTelemetrySetup?: boolean; } diff --git a/packages/node/test/integration/transactions.test.ts b/packages/node/test/integration/transactions.test.ts index 572132fb6244..97d022c25310 100644 --- a/packages/node/test/integration/transactions.test.ts +++ b/packages/node/test/integration/transactions.test.ts @@ -23,6 +23,7 @@ describe('Integration | Transactions', () => { tracesSampleRate: 1, beforeSendTransaction, release: '8.0.0', + skipOpenTelemetrySetup: false, }); const client = Sentry.getClient()!; @@ -309,7 +310,7 @@ describe('Integration | Transactions', () => { it('correctly creates concurrent transaction & spans when using native OTEL tracer', async () => { const beforeSendTransaction = vi.fn(() => null); - mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction, skipOpenTelemetrySetup: false }); const client = Sentry.getClient(); @@ -457,7 +458,7 @@ describe('Integration | Transactions', () => { traceFlags: TraceFlags.SAMPLED, }; - mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction, skipOpenTelemetrySetup: false }); const client = Sentry.getClient()!; diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index c6c845ac4eba..ff9f086bf8c2 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -1,6 +1,7 @@ import type { Integration } from '@sentry/core'; import { debug, SDK_VERSION } from '@sentry/core'; import * as SentryOpentelemetry from '@sentry/opentelemetry'; +import * as SentryServerUtils from '@sentry/server-utils'; import { afterEach, beforeEach, describe, expect, it, type Mock, type MockInstance, vi } from 'vitest'; import { getClient, NodeClient } from '../../src/'; import * as auto from '../../src/integrations/tracing'; @@ -200,30 +201,42 @@ describe('init()', () => { }); describe('OpenTelemetry', () => { - it('sets up OpenTelemetry by default', () => { + it('does not set up a tracer provider by default', () => { init({ dsn: PUBLIC_DSN }); const client = getClient(); - expect(client?.traceProvider).toBeDefined(); + expect(client?.traceProvider).not.toBeDefined(); }); - it('allows to opt-out of OpenTelemetry setup', () => { - init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: true }); + it('uses the AsyncLocalStorage context strategy by default', () => { + const alsStrategySpy = vi.spyOn(SentryServerUtils, 'setAsyncLocalStorageAsyncContextStrategy'); + const otelStrategySpy = vi.spyOn(SentryOpentelemetry, 'setOpenTelemetryContextAsyncContextStrategy'); - const client = getClient(); + init({ dsn: PUBLIC_DSN }); - expect(client?.traceProvider).not.toBeDefined(); + expect(alsStrategySpy).toHaveBeenCalledTimes(1); + expect(otelStrategySpy).not.toHaveBeenCalled(); }); - it('uses the minimal Sentry trace provider by default', () => { - init({ dsn: PUBLIC_DSN }); + it('allows to opt-in to OpenTelemetry setup', () => { + init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: false }); const client = getClient(); expect(client?.traceProvider).toBeInstanceOf(SentryOpentelemetry.SentryTracerProvider); }); + it('uses the OpenTelemetry context strategy when opting in', () => { + const alsStrategySpy = vi.spyOn(SentryServerUtils, 'setAsyncLocalStorageAsyncContextStrategy'); + const otelStrategySpy = vi.spyOn(SentryOpentelemetry, 'setOpenTelemetryContextAsyncContextStrategy'); + + init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: false }); + + expect(otelStrategySpy).toHaveBeenCalledTimes(1); + expect(alsStrategySpy).not.toHaveBeenCalled(); + }); + it('carries non-Sentry slots of a version-mismatched OTel API registry over into the recreated one', () => { // Must be a complete DiagLogger: once carried over, the SDK's api copy resolves it and // calls it for its own diag output. @@ -237,7 +250,7 @@ describe('init()', () => { propagation: propagator, }; - init({ dsn: PUBLIC_DSN }); + init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: false }); const registry = global[OTEL_API_GLOBAL_KEY]; @@ -253,7 +266,7 @@ describe('init()', () => { const existingRegistry = { version: '0.0.1', trace: existingProvider }; global[OTEL_API_GLOBAL_KEY] = existingRegistry; - init({ dsn: PUBLIC_DSN }); + init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: false }); const client = getClient(); diff --git a/packages/sveltekit/src/server/sdk.ts b/packages/sveltekit/src/server/sdk.ts index fb7a5dbbb471..6d2fa1439921 100644 --- a/packages/sveltekit/src/server/sdk.ts +++ b/packages/sveltekit/src/server/sdk.ts @@ -19,6 +19,9 @@ export function init(options: NodeOptions): NodeClient | undefined { const opts = { defaultIntegrations, + // SvelteKit emits its own OpenTelemetry spans, so it defaults to registering the Sentry tracer + // provider (unlike most Node-based SDKs). A user-provided value still overrides this via `...options`. + skipOpenTelemetrySetup: false, ...options, }; From 2db921cec2c61346a5a61644017e776a8e2a7781 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 15:42:21 +0200 Subject: [PATCH 02/15] Gate setupEventContextTrace on the tracer-provider mode In the no-provider default, setupEventContextTrace read the OpenTelemetry active span before scope data was applied, so a user's own OTel span could override the Sentry trace on error events. Only set up this hook when Sentry owns the provider. --- packages/node/src/sdk/index.ts | 7 ++++--- packages/node/test/sdk/init.test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index d687cc5a3295..47986dd7081b 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -211,8 +211,6 @@ function _init( updateScopeFromEnvVariables(); - setupEventContextTrace(client); - // Ensure we flush events when vercel functions are ended // See: https://vercel.com/docs/functions/functions-api-reference#sigterm-signal if (process.env.VERCEL) { @@ -222,8 +220,11 @@ function _init( }); } - // Add Node SDK specific OpenTelemetry setup + // Add Node SDK specific OpenTelemetry setup. `setupEventContextTrace` reads the active span from the + // OpenTelemetry context, so it only belongs here: without a Sentry tracer provider a foreign OTel + // span could otherwise override the Sentry trace on error events. if (!clientOptions.skipOpenTelemetrySetup) { + setupEventContextTrace(client); initOpenTelemetry(client); } diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index ff9f086bf8c2..13b299ebf70c 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -237,6 +237,24 @@ describe('init()', () => { expect(alsStrategySpy).not.toHaveBeenCalled(); }); + it('does not set up the OpenTelemetry event trace hook by default', () => { + const eventContextTraceSpy = vi.spyOn(SentryOpentelemetry, 'setupEventContextTrace'); + + init({ dsn: PUBLIC_DSN }); + + // Without a tracer provider this hook would read a foreign OTel active span and could override + // the Sentry trace on error events. + expect(eventContextTraceSpy).not.toHaveBeenCalled(); + }); + + it('sets up the OpenTelemetry event trace hook when opting in', () => { + const eventContextTraceSpy = vi.spyOn(SentryOpentelemetry, 'setupEventContextTrace'); + + init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: false }); + + expect(eventContextTraceSpy).toHaveBeenCalledTimes(1); + }); + it('carries non-Sentry slots of a version-mismatched OTel API registry over into the recreated one', () => { // Must be a complete DiagLogger: once carried over, the SDK's api copy resolves it and // calls it for its own diag output. From 13d89f3449e2338128bb20720c72b807b3fa5072 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 15:54:21 +0200 Subject: [PATCH 03/15] Replace event-trace spy tests with a behavioral test Assert the actual user-visible effect: a foreign OpenTelemetry active span must not override the Sentry trace on error events in the no-provider default, and is adopted when the tracer provider is enabled. Verified to fail without the gating. --- .../integration/eventContextTrace.test.ts | 71 +++++++++++++++++++ packages/node/test/sdk/init.test.ts | 18 ----- 2 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 packages/node/test/integration/eventContextTrace.test.ts diff --git a/packages/node/test/integration/eventContextTrace.test.ts b/packages/node/test/integration/eventContextTrace.test.ts new file mode 100644 index 000000000000..2e6880b4404c --- /dev/null +++ b/packages/node/test/integration/eventContextTrace.test.ts @@ -0,0 +1,71 @@ +import type { Span } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import { getCurrentScope } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as Sentry from '../../src/'; +import type { NodeClient } from '../../src/sdk/client'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; + +const FOREIGN_TRACE_ID = 'a'.repeat(32); +const FOREIGN_SPAN_ID = 'b'.repeat(16); + +// A span owned by the user's own OpenTelemetry SDK, not by Sentry. +const foreignOtelSpan = { + spanContext: () => ({ traceId: FOREIGN_TRACE_ID, spanId: FOREIGN_SPAN_ID, traceFlags: 1 }), +} as unknown as Span; + +describe('setupEventContextTrace gating', () => { + afterEach(() => { + cleanupOtel(); + vi.restoreAllMocks(); + }); + + it('does not let a foreign OpenTelemetry span override the Sentry trace on errors in the no-provider default', async () => { + // Simulate a user running their own OpenTelemetry instrumentation alongside Sentry: their context + // manager surfaces an active span via `@opentelemetry/api`. + vi.spyOn(trace, 'getActiveSpan').mockReturnValue(foreignOtelSpan); + + const beforeSend = vi.fn(() => null); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient() as NodeClient; + + const sentryTraceId = getCurrentScope().getPropagationContext().traceId; + expect(sentryTraceId).not.toBe(FOREIGN_TRACE_ID); + + const error = new Error('boom'); + Sentry.captureException(error); + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ trace_id: sentryTraceId }), + }), + }), + expect.objectContaining({ originalException: error }), + ); + }); + + it('links errors to the active OpenTelemetry span when the tracer provider is enabled', async () => { + vi.spyOn(trace, 'getActiveSpan').mockReturnValue(foreignOtelSpan); + + const beforeSend = vi.fn(() => null); + mockSdkInit({ beforeSend, skipOpenTelemetrySetup: false }); + const client = Sentry.getClient() as NodeClient; + + const error = new Error('boom'); + Sentry.captureException(error); + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ trace_id: FOREIGN_TRACE_ID, span_id: FOREIGN_SPAN_ID }), + }), + }), + expect.objectContaining({ originalException: error }), + ); + }); +}); diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index 13b299ebf70c..ff9f086bf8c2 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -237,24 +237,6 @@ describe('init()', () => { expect(alsStrategySpy).not.toHaveBeenCalled(); }); - it('does not set up the OpenTelemetry event trace hook by default', () => { - const eventContextTraceSpy = vi.spyOn(SentryOpentelemetry, 'setupEventContextTrace'); - - init({ dsn: PUBLIC_DSN }); - - // Without a tracer provider this hook would read a foreign OTel active span and could override - // the Sentry trace on error events. - expect(eventContextTraceSpy).not.toHaveBeenCalled(); - }); - - it('sets up the OpenTelemetry event trace hook when opting in', () => { - const eventContextTraceSpy = vi.spyOn(SentryOpentelemetry, 'setupEventContextTrace'); - - init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: false }); - - expect(eventContextTraceSpy).toHaveBeenCalledTimes(1); - }); - it('carries non-Sentry slots of a version-mismatched OTel API registry over into the recreated one', () => { // Must be a complete DiagLogger: once carried over, the SDK's api copy resolves it and // calls it for its own diag output. From 6f6f1d02fabdfb15ee994f68d7b4c004c929c42d Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 16:30:22 +0200 Subject: [PATCH 04/15] Backfill Sentry span data in no-provider mode Channel-based instrumentation stamps OTel semantic attributes on native spans but leaves the Sentry-convention fields (e.g. sentry.op) to be inferred by the provider pipeline. Without a tracer provider that inference never ran, so outgoing http/fetch and other channel spans were half-formed. Run the same applyOtelSpanData / backfillStreamedSpanDataFromOtel hooks via the client in no-provider mode. --- packages/node/src/sdk/index.ts | 6 +++++- packages/node/src/sdk/initOtel.ts | 26 +++++++++++++++++++------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 47986dd7081b..bf5e952c19c7 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -41,7 +41,7 @@ import { getEntryPointType } from '../utils/entry-point'; import { getSpotlightConfig } from '../utils/spotlight'; import { defaultStackParser, getSentryRelease } from './api'; import { NodeClient } from './client'; -import { initOpenTelemetry } from './initOtel'; +import { initOpenTelemetry, setupSpanDataBackfill } from './initOtel'; /** * Get the base default integrations shared by all Node SDK default-integration sets. @@ -226,6 +226,10 @@ function _init( if (!clientOptions.skipOpenTelemetrySetup) { setupEventContextTrace(client); initOpenTelemetry(client); + } else { + // Without a tracer provider, channel-based instrumentation still emits spans via core `startSpan`. + // Backfill the Sentry-convention span data (e.g. `sentry.op`) the provider pipeline would derive. + setupSpanDataBackfill(client); } // Warn about missing or doubled channel injection. Runs after the client diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 3107449582c3..51f6d767a8c4 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -121,6 +121,24 @@ function getPreloadMethods(integrationNames?: string[]): ((() => void) & { id: s }); } +/** + * Backfill Sentry span data (op, source, name, status) from OpenTelemetry semantic attributes. + * + * Channel-based instrumentation stamps OTel semantic attributes on native Sentry spans but leaves the + * Sentry-convention fields (e.g. `sentry.op`) to be inferred. On the OTel SDK provider that inference + * runs in the span processor/exporter; here it runs via client hooks so it happens whether or not a + * Sentry tracer provider is set up. + */ +export function setupSpanDataBackfill(client: NodeClient): void { + client.on('spanEnd', span => { + applyOtelSpanData(span, { finalizeStatus: true }); + }); + + if (hasSpanStreamingEnabled(client)) { + client.on('preprocessSpan', backfillStreamedSpanDataFromOtel); + } +} + /** Just exported for tests. */ export function setupOtel(client: NodeClient): SentryTracerProvider | undefined { const provider = new SentryTracerProvider({ resource: getSentryResource('node') }); @@ -135,13 +153,7 @@ export function setupOtel(client: NodeClient): SentryTracerProvider | undefined propagation.setGlobalPropagator(new SentryPropagator()); - client.on('spanEnd', span => { - applyOtelSpanData(span, { finalizeStatus: true }); - }); - - if (hasSpanStreamingEnabled(client)) { - client.on('preprocessSpan', backfillStreamedSpanDataFromOtel); - } + setupSpanDataBackfill(client); client.on('preprocessEvent', event => { if (event.type !== 'transaction') { From 1a2fdb0c52d8d41ee5e7278fdb71e9c64cea65d3 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 16:35:45 +0200 Subject: [PATCH 05/15] Pin OTel-tracer integration tests to skipOpenTelemetrySetup: false tracer-start-active-span-error drives the raw OpenTelemetry tracer, and http-otel-double-instrumentation exercises coexistence with a user-owned OTel HttpInstrumentation whose spans reach Sentry via the tracer provider. Both require the provider, so they opt into it explicitly under the new no-provider default. --- .../http-otel-double-instrumentation/instrument-mitigation.mjs | 2 ++ .../http-otel-double-instrumentation/instrument.mjs | 3 +++ .../tracing/tracer-start-active-span-error/instrument.mjs | 3 +++ 3 files changed, 8 insertions(+) diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/instrument-mitigation.mjs b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/instrument-mitigation.mjs index 2998ee573a0d..10272f85e4d7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/instrument-mitigation.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/instrument-mitigation.mjs @@ -7,6 +7,8 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, + // The user-owned OTel HttpInstrumentation's spans reach Sentry through the tracer provider. + skipOpenTelemetrySetup: false, integrations: [ // Disable Sentry's span creation so that OTel HttpInstrumentation // is the only source of http.client spans. Breadcrumbs and diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/instrument.mjs index f6ded72c4aa0..647b2cb602e6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-otel-double-instrumentation/instrument.mjs @@ -8,6 +8,9 @@ Sentry.init({ tracesSampleRate: 1.0, transport: loggingTransport, debug: true, + // This suite exercises coexistence with a user-owned OTel HttpInstrumentation whose spans reach + // Sentry through the tracer provider, so it must run with the provider enabled. + skipOpenTelemetrySetup: false, }); // Simulate a user who independently sets up OTel HttpInstrumentation diff --git a/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/instrument.mjs index a631fcf954ed..431b976b31b6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/instrument.mjs @@ -6,4 +6,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, transport: loggingTransport, + // This suite drives the raw OpenTelemetry tracer (`client.tracer.startActiveSpan`), which only + // produces spans when Sentry owns the tracer provider. + skipOpenTelemetrySetup: false, }); From 35ec3af7827f7fb5593e1c8391db4a5e2066e2df Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 16:37:41 +0200 Subject: [PATCH 06/15] Propagate sample_rand from a continued trace's frozen empty DSC A trace continued without incoming baggage freezes an empty DSC. Reading it back short-circuited before sample_rand was added, so downstream requests in no-provider mode propagated baggage without sentry-sample_rand. Backfill it from the captured scope's propagation context, matching what the OTel span sampler writes to trace state. --- packages/core/src/tracing/dynamicSamplingContext.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 8d428ddaa1d4..db868c12c921 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -103,6 +103,16 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly Date: Mon, 3 Aug 2026 16:45:56 +0200 Subject: [PATCH 07/15] Do not activate ignored spans in the tracing-channel binding In no-provider mode the AsyncLocalStorage tracing-channel binding planted every channel span as the active span, including ignored ones. Children and outgoing requests then propagated from the ignored span, dropping the continued positive sampling decision. Skip ignored spans so propagation falls back to the nearest emitted parent, matching the OTel context manager. --- packages/core/src/asyncContext/tracing-channel-binding.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/asyncContext/tracing-channel-binding.ts b/packages/core/src/asyncContext/tracing-channel-binding.ts index d692616870b7..d480a20fe2fa 100644 --- a/packages/core/src/asyncContext/tracing-channel-binding.ts +++ b/packages/core/src/asyncContext/tracing-channel-binding.ts @@ -1,5 +1,6 @@ import { getMainCarrier } from '../carrier'; import type { Scope } from '../scope'; +import { spanIsIgnored } from '../tracing/trace'; import { _setSpanForScope } from '../utils/spanOnScope'; import { safeUnref } from '../utils/timer'; import { getAsyncContextStrategy } from './index'; @@ -51,7 +52,12 @@ export function _INTERNAL_createTracingChannelBinding( getStoreWithActiveSpan: span => { const { scope, isolationScope } = getScopes(); const activeScope = scope.clone(); - _setSpanForScope(activeScope, span); + // Do not make an ignored span the active span: no span is emitted for it, so its children and + // outgoing requests must propagate from the nearest emitted parent instead. Mirrors the OTel + // context manager, which likewise skips ignored spans. + if (!spanIsIgnored(span)) { + _setSpanForScope(activeScope, span); + } return { scope: activeScope, isolationScope }; }, From 2a7b5b05fe049a7241267c5bcff118303ae9d487 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 17:16:15 +0200 Subject: [PATCH 08/15] Only fold sample_rand into an empty continued DSC at freeze time The previous approach backfilled sample_rand in getDynamicSamplingContextFromSpan, which also ran in OTel mode and wrongly added sample_rand to remote-parent DSCs (breaking opentelemetry unit tests). Move the backfill to the root-span freeze site and gate it on a genuinely empty DSC, so only continued traces without incoming baggage get sample_rand and populated frozen DSCs are left untouched. Also opt the Cloudflare Vercel AI v6 integration test into the tracer provider: the AI SDK emits spans via @opentelemetry/api, which need the provider to be captured. --- .../suites/tracing/vercelai/v6/index.ts | 3 +++ packages/core/src/tracing/dynamicSamplingContext.ts | 10 ---------- packages/core/src/tracing/trace.ts | 10 ++++++++-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/vercelai/v6/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/vercelai/v6/index.ts index f6129e046cf6..7e5e613f388f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/vercelai/v6/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/vercelai/v6/index.ts @@ -11,6 +11,9 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1, + // The Vercel AI SDK emits its spans through `@opentelemetry/api`, so they are only picked up when + // the Cloudflare OpenTelemetry tracer provider is set up. + skipOpenTelemetrySetup: false, }), { async fetch(_request, _env, _ctx) { diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index db868c12c921..8d428ddaa1d4 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -103,16 +103,6 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly Date: Mon, 3 Aug 2026 17:24:40 +0200 Subject: [PATCH 09/15] Drop otel context assertion from astro tracing e2e tests The `otel` transaction context carries the OpenTelemetry SDK resource attributes, which are only set when Sentry owns the tracer provider. Under the no-provider default these Astro server SDKs no longer emit it, so the assertion is removed. --- .../test-applications/astro-4/tests/tracing.dynamic.test.ts | 1 - .../test-applications/astro-5/tests/tracing.dynamic.test.ts | 1 - .../test-applications/astro-6/tests/tracing.dynamic.test.ts | 1 - .../test-applications/astro-7/tests/tracing.dynamic.test.ts | 1 - 4 files changed, 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts index 6eff51ea9829..6c84e505f023 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts @@ -70,7 +70,6 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { culture: expect.any(Object), device: expect.any(Object), os: expect.any(Object), - otel: expect.any(Object), runtime: expect.any(Object), trace: { data: { diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts index 2b9d7b27750f..26dbcb223683 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts @@ -71,7 +71,6 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { culture: expect.any(Object), device: expect.any(Object), os: expect.any(Object), - otel: expect.any(Object), runtime: expect.any(Object), trace: { data: { diff --git a/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.dynamic.test.ts index f0bf78387f78..a8411e716a27 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-6/tests/tracing.dynamic.test.ts @@ -71,7 +71,6 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { culture: expect.any(Object), device: expect.any(Object), os: expect.any(Object), - otel: expect.any(Object), runtime: expect.any(Object), trace: { data: { diff --git a/dev-packages/e2e-tests/test-applications/astro-7/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-7/tests/tracing.dynamic.test.ts index 97a451094932..0865b6afe17b 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-7/tests/tracing.dynamic.test.ts @@ -68,7 +68,6 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { culture: expect.any(Object), device: expect.any(Object), os: expect.any(Object), - otel: expect.any(Object), runtime: expect.any(Object), trace: { data: { From aaec8955ffa30465f13b3efce0ba842b9cb5e3b8 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 17:36:14 +0200 Subject: [PATCH 10/15] Expose the ALS scope store to node-native in no-provider mode The event-loop-block watchdog reads the active scope out of the client's `asyncLocalStorageLookup`, which was only populated when the OpenTelemetry context strategy was set up. Without a tracer provider it was undefined, so ANR events fell back to the global scope and dropped per-isolation-scope user data and breadcrumbs. Return the AsyncLocalStorage from `setAsyncLocalStorageAsyncContextStrategy` and set `asyncLocalStorageLookup` in the no-provider branch. The lookup now carries a generic `stateLookup` key path (empty for the ALS store, which already is the scopes object; `['_currentContext', ...]` for the OTel context) instead of an OTel-specific symbol. --- packages/node-native/src/event-loop-block-integration.ts | 4 ++-- packages/node/src/sdk/index.ts | 5 ++++- .../opentelemetry/src/asyncLocalStorageContextManager.ts | 9 +++++++-- packages/server-utils/src/async-context.ts | 7 ++++++- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/node-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts index 8ce1f7fc224f..bc9d61f29574 100644 --- a/packages/node-native/src/event-loop-block-integration.ts +++ b/packages/node-native/src/event-loop-block-integration.ts @@ -78,8 +78,8 @@ function startPolling( integrationOptions: Partial, ): IntegrationInternal | undefined { if (client.asyncLocalStorageLookup) { - const { asyncLocalStorage, contextSymbol } = client.asyncLocalStorageLookup; - registerThread({ asyncLocalStorage, stateLookup: ['_currentContext', contextSymbol] }); + const { asyncLocalStorage, stateLookup } = client.asyncLocalStorageLookup; + registerThread({ asyncLocalStorage, stateLookup }); } else { registerThread(); } diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index bf5e952c19c7..d6b4996f774f 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -177,7 +177,10 @@ function _init( // emits spans via core `startSpan`; there is just no OTel provider or propagator behind them. let asyncLocalStorageLookup: ReturnType | undefined; if (clientOptions.skipOpenTelemetrySetup) { - setAsyncLocalStorageAsyncContextStrategy(); + // The ALS store already is the `{ scope, isolationScope }` object, so no key path is needed to + // reach it (unlike the OTel context strategy, where it is nested under the OTel context). + const asyncLocalStorage = setAsyncLocalStorageAsyncContextStrategy(); + asyncLocalStorageLookup = { asyncLocalStorage, stateLookup: [] }; } else { asyncLocalStorageLookup = setOpenTelemetryContextAsyncContextStrategy(); } diff --git a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts index e2b1d05899d8..cdae0e209fab 100644 --- a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts +++ b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts @@ -30,7 +30,12 @@ import { buildContextWithSentryScopes } from './utils/buildContextWithSentryScop export type AsyncLocalStorageLookup = { asyncLocalStorage: AsyncLocalStorage; - contextSymbol: symbol; + /** + * Key path traversed through the store to reach the `{ scope, isolationScope }` object, for native + * threads that read scope out of the AsyncLocalStorage (e.g. `@sentry/node-native`). Empty when the + * store already is that object. + */ + stateLookup: Array; }; type ListenerFn = (...args: unknown[]) => unknown; @@ -99,7 +104,7 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { public getAsyncLocalStorageLookup(): AsyncLocalStorageLookup { return { asyncLocalStorage: this._asyncLocalStorage, - contextSymbol: SENTRY_SCOPES_CONTEXT_KEY, + stateLookup: ['_currentContext', SENTRY_SCOPES_CONTEXT_KEY], }; } diff --git a/packages/server-utils/src/async-context.ts b/packages/server-utils/src/async-context.ts index ba7e0009c167..93b72f5173bd 100644 --- a/packages/server-utils/src/async-context.ts +++ b/packages/server-utils/src/async-context.ts @@ -16,8 +16,11 @@ type ScopeStore = { scope: Scope; isolationScope: Scope }; /** * Sets the async context strategy to use AsyncLocalStorage. + * + * Returns the underlying `AsyncLocalStorage` whose store is the `{ scope, isolationScope }` object, so + * callers (e.g. `@sentry/node-native`) can read scope out of it from a native thread. */ -export function setAsyncLocalStorageAsyncContextStrategy(): void { +export function setAsyncLocalStorageAsyncContextStrategy(): AsyncLocalStorage { // Re-use the AsyncLocalStorage of an already-installed strategy, if any. Otherwise a repeated // setup (e.g. a second `Sentry.init()`) would swap in a new store while integrations that captured // the previous one (via `getTracingChannelBinding().asyncLocalStorage`) keep reading the old one, @@ -101,4 +104,6 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { getIsolationScope: () => getScopes().isolationScope, getTracingChannelBinding: () => _INTERNAL_createTracingChannelBinding(asyncStorage, getScopes), }); + + return asyncStorage; } From 6d64b0156f5e692e73010a8c4bffd75e113de4a2 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 19:53:04 +0200 Subject: [PATCH 11/15] Update DSC continuation assertions for propagated sample_rand Continuing a trace without an incoming Sentry DSC now folds the scope's sample_rand into the propagated (otherwise empty) DSC so downstream sampling stays consistent. Update the sveltekit handle and browser tracing tests that asserted a strictly empty DSC in that case. --- .../browser/test/tracing/browserTracingIntegration.test.ts | 4 +++- packages/sveltekit/test/server-common/handle.test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index ee8585ef53bd..769d8963586f 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -1132,7 +1132,9 @@ describe('browserTracingIntegration', () => { expect(spanIsSampled(idleSpan)).toBe(false); expect(dynamicSamplingContext).toBeDefined(); - expect(dynamicSamplingContext).toStrictEqual({}); + // Continuing a trace without an incoming Sentry DSC does not populate a new one, but the + // `sample_rand` is still propagated so downstream sampling decisions stay consistent. + expect(dynamicSamplingContext).toStrictEqual({ sample_rand: expect.stringMatching(/^0(\.\d+)?$/) }); // Propagation context keeps the meta tag trace data for later events on the same route to add them to the trace expect(propagationContext.traceId).toEqual('12312012123120121231201212312012'); diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index bdfc39703da7..dab43d3b938d 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -265,7 +265,9 @@ describe('sentryHandle', () => { expect(_span!.spanContext().traceId).toEqual('1234567890abcdef1234567890abcdef'); expect(spanToJSON(_span!).parent_span_id).toEqual('1234567890abcdef'); expect(spanIsSampled(_span!)).toEqual(true); - expect(envelopeHeaders!.trace).toEqual({}); + // Continuing a trace without incoming baggage does not populate a new DSC, but the `sample_rand` + // is still propagated so downstream sampling decisions stay consistent across the trace. + expect(envelopeHeaders!.trace).toEqual({ sample_rand: expect.stringMatching(/^0(\.\d+)?$/) }); }); it('creates a transaction with dynamic sampling context from baggage header', async () => { From 842a0c009592a7c686e568a7d293da775b13b297 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 20:19:36 +0200 Subject: [PATCH 12/15] Key react-router middleware counter by request, not OTel context The server middleware index counter was stored on the OpenTelemetry context, so it reset to 0 on every middleware under the no-provider default (no context propagation without a tracer provider), producing indices like [0,0,0]. Key it by the incoming Request in a WeakMap instead, mirroring the client instrumentation, so it works in both modes. --- .../src/server/createServerInstrumentation.ts | 120 +++++++++--------- .../createServerInstrumentation.test.ts | 31 +---- 2 files changed, 59 insertions(+), 92 deletions(-) diff --git a/packages/react-router/src/server/createServerInstrumentation.ts b/packages/react-router/src/server/createServerInstrumentation.ts index 98d8c98ee46f..c36071c11357 100644 --- a/packages/react-router/src/server/createServerInstrumentation.ts +++ b/packages/react-router/src/server/createServerInstrumentation.ts @@ -1,4 +1,3 @@ -import { context, createContextKey } from '@opentelemetry/api'; import { HTTP_REQUEST_METHOD, HTTP_ROUTE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import { debug, @@ -19,7 +18,10 @@ import { captureInstrumentationError, getPathFromRequest, getPattern, normalizeR import { getMiddlewareName } from './serverBuild'; import { markInstrumentationApiUsed } from './serverGlobals'; -const MIDDLEWARE_COUNTER_KEY = createContextKey('sentry_react_router_middleware_counter'); +// Per-request middleware counters, keyed by the incoming `Request` (shared across a request's handler +// and middleware hooks). This mirrors the client instrumentation and works whether or not a Sentry +// OpenTelemetry tracer provider is set up, unlike storing the counter on the OTel context. +const middlewareCountersByRequest = new WeakMap>(); // Re-export for backward compatibility and external use export { isInstrumentationApiUsed } from './serverGlobals'; @@ -55,63 +57,58 @@ export function createSentryServerInstrumentation( const activeSpan = getActiveSpan(); const existingRootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - const counterStore = { counters: {} as Record }; - const ctx = context.active().setValue(MIDDLEWARE_COUNTER_KEY, counterStore); + if (existingRootSpan) { + updateSpanName(existingRootSpan, `${info.request.method} ${pathname}`); + existingRootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [URL_FULL]: info.request.url, + [URL_PATH]: pathname, + }); - await context.with(ctx, async () => { - if (existingRootSpan) { - updateSpanName(existingRootSpan, `${info.request.method} ${pathname}`); - existingRootSpan.setAttributes({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [URL_FULL]: info.request.url, - [URL_PATH]: pathname, - }); - - try { - const result = await handleRequest(); - if (result.status === 'error' && result.error instanceof Error) { - existingRootSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureInstrumentationError(result, captureErrors, 'react_router.request_handler', { - 'http.method': info.request.method, - [URL_FULL]: pathname, - }); - } - } finally { - await flushIfServerless(); + try { + const result = await handleRequest(); + if (result.status === 'error' && result.error instanceof Error) { + existingRootSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureInstrumentationError(result, captureErrors, 'react_router.request_handler', { + 'http.method': info.request.method, + [URL_FULL]: pathname, + }); } - } else { - await startSpan( - { - name: `${info.request.method} ${pathname}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [HTTP_REQUEST_METHOD]: info.request.method, - [URL_PATH]: pathname, - [URL_FULL]: info.request.url, - }, + } finally { + await flushIfServerless(); + } + } else { + await startSpan( + { + name: `${info.request.method} ${pathname}`, + forceTransaction: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [HTTP_REQUEST_METHOD]: info.request.method, + [URL_PATH]: pathname, + [URL_FULL]: info.request.url, }, - async span => { - try { - const result = await handleRequest(); - if (result.status === 'error' && result.error instanceof Error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureInstrumentationError(result, captureErrors, 'react_router.request_handler', { - 'http.method': info.request.method, - [URL_FULL]: pathname, - }); - } - } finally { - await flushIfServerless(); + }, + async span => { + try { + const result = await handleRequest(); + if (result.status === 'error' && result.error instanceof Error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureInstrumentationError(result, captureErrors, 'react_router.request_handler', { + 'http.method': info.request.method, + [URL_FULL]: pathname, + }); } - }, - ); - } - }); + } finally { + await flushIfServerless(); + } + }, + ); + } }, }); }, @@ -183,14 +180,13 @@ export function createSentryServerInstrumentation( updateRootSpanWithRoute(info.request.method, pattern, urlPath); - const counterStore = context.active().getValue(MIDDLEWARE_COUNTER_KEY) as - | { counters: Record } - | undefined; - let middlewareIndex = 0; - if (counterStore) { - middlewareIndex = counterStore.counters[routeId] ?? 0; - counterStore.counters[routeId] = middlewareIndex + 1; + let counters = middlewareCountersByRequest.get(info.request); + if (!counters) { + counters = {}; + middlewareCountersByRequest.set(info.request, counters); } + const middlewareIndex = counters[routeId] ?? 0; + counters[routeId] = middlewareIndex + 1; const middlewareName = getMiddlewareName(routeId, middlewareIndex); diff --git a/packages/react-router/test/server/createServerInstrumentation.test.ts b/packages/react-router/test/server/createServerInstrumentation.test.ts index ad2219deb18b..7ebecbd9897b 100644 --- a/packages/react-router/test/server/createServerInstrumentation.test.ts +++ b/packages/react-router/test/server/createServerInstrumentation.test.ts @@ -1,4 +1,3 @@ -import * as otelApi from '@opentelemetry/api'; import { URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import * as core from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -29,21 +28,6 @@ vi.mock('../../src/server/serverBuild', () => ({ getMiddlewareName: vi.fn(), })); -vi.mock('@opentelemetry/api', async () => { - const actual = await vi.importActual('@opentelemetry/api'); - return { - ...actual, - context: { - active: vi.fn(() => ({ - getValue: vi.fn(), - setValue: vi.fn(), - })), - with: vi.fn((ctx, fn) => fn()), - }, - createContextKey: actual.createContextKey, - }; -}); - describe('createSentryServerInstrumentation', () => { beforeEach(() => { vi.clearAllMocks(); @@ -425,19 +409,8 @@ describe('createSentryServerInstrumentation', () => { it('should increment middleware index for multiple middleware calls on same route', async () => { const mockCallMiddleware = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); const mockInstrument = vi.fn(); - const mockSetAttributes = vi.fn(); - const mockRootSpan = { setAttributes: mockSetAttributes }; const routeId = 'routes/multi-middleware'; - // Simulate counter store that would be created by handler and stored in OTel context - const counterStore = { counters: {} as Record }; - - // eslint-disable-next-line @typescript-eslint/unbound-method - vi.mocked(otelApi.context.active).mockReturnValue({ - getValue: vi.fn(() => counterStore), - setValue: vi.fn(), - } as any); - vi.mocked(serverBuildModule.getMiddlewareName).mockReturnValue(undefined); const startSpanCalls: any[] = []; @@ -445,8 +418,6 @@ describe('createSentryServerInstrumentation', () => { startSpanCalls.push(opts); return fn(); }); - (core.getActiveSpan as any).mockReturnValue({}); - (core.getRootSpan as any).mockReturnValue(mockRootSpan); const instrumentation = createSentryServerInstrumentation(); instrumentation.route?.({ @@ -457,6 +428,7 @@ describe('createSentryServerInstrumentation', () => { }); const hooks = mockInstrument.mock.calls[0]![0]; + // The per-request middleware counter is keyed by this shared `request`, so the 3 calls increment. const requestInfo = { request: { method: 'GET', url: 'http://example.com/multi-middleware', headers: { get: () => null } }, params: {}, @@ -464,7 +436,6 @@ describe('createSentryServerInstrumentation', () => { context: undefined, }; - // Call middleware 3 times (simulating 3 middlewares on same route) await hooks.middleware(mockCallMiddleware, requestInfo); await hooks.middleware(mockCallMiddleware, requestInfo); await hooks.middleware(mockCallMiddleware, requestInfo); From 8e12acb755ffc9eaafdea1e3854f191eef19002c Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 20:42:34 +0200 Subject: [PATCH 13/15] Key react-router middleware counter by root span Keying the per-request middleware counter by the incoming Request did not increment in the real server (each middleware hook sees a distinct request object), so indices stayed [0,0,0]. Key by the request's root span instead, which is the single stable transaction all of a request's middlewares run under, in both provider modes. --- .../src/server/createServerInstrumentation.ts | 26 ++++++++++++------- .../createServerInstrumentation.test.ts | 5 +++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/react-router/src/server/createServerInstrumentation.ts b/packages/react-router/src/server/createServerInstrumentation.ts index c36071c11357..47c701ba377f 100644 --- a/packages/react-router/src/server/createServerInstrumentation.ts +++ b/packages/react-router/src/server/createServerInstrumentation.ts @@ -18,10 +18,11 @@ import { captureInstrumentationError, getPathFromRequest, getPattern, normalizeR import { getMiddlewareName } from './serverBuild'; import { markInstrumentationApiUsed } from './serverGlobals'; -// Per-request middleware counters, keyed by the incoming `Request` (shared across a request's handler -// and middleware hooks). This mirrors the client instrumentation and works whether or not a Sentry -// OpenTelemetry tracer provider is set up, unlike storing the counter on the OTel context. -const middlewareCountersByRequest = new WeakMap>(); +// Per-request middleware counters, keyed by the request's root span (the one transaction all of a +// request's middlewares run under). The root span is the same instance across those middleware hooks +// whether or not a Sentry OpenTelemetry tracer provider is set up, unlike the OTel context the counter +// used to live on (which does not propagate without a provider). +const middlewareCountersByRootSpan = new WeakMap>(); // Re-export for backward compatibility and external use export { isInstrumentationApiUsed } from './serverGlobals'; @@ -180,13 +181,18 @@ export function createSentryServerInstrumentation( updateRootSpanWithRoute(info.request.method, pattern, urlPath); - let counters = middlewareCountersByRequest.get(info.request); - if (!counters) { - counters = {}; - middlewareCountersByRequest.set(info.request, counters); + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + let middlewareIndex = 0; + if (rootSpan) { + let counters = middlewareCountersByRootSpan.get(rootSpan); + if (!counters) { + counters = {}; + middlewareCountersByRootSpan.set(rootSpan, counters); + } + middlewareIndex = counters[routeId] ?? 0; + counters[routeId] = middlewareIndex + 1; } - const middlewareIndex = counters[routeId] ?? 0; - counters[routeId] = middlewareIndex + 1; const middlewareName = getMiddlewareName(routeId, middlewareIndex); diff --git a/packages/react-router/test/server/createServerInstrumentation.test.ts b/packages/react-router/test/server/createServerInstrumentation.test.ts index 7ebecbd9897b..12d01c86d15c 100644 --- a/packages/react-router/test/server/createServerInstrumentation.test.ts +++ b/packages/react-router/test/server/createServerInstrumentation.test.ts @@ -418,6 +418,10 @@ describe('createSentryServerInstrumentation', () => { startSpanCalls.push(opts); return fn(); }); + // The per-request middleware counter is keyed by the (stable) root span, so the 3 calls increment. + const mockRootSpan = { setAttributes: vi.fn() }; + (core.getActiveSpan as any).mockReturnValue(mockRootSpan); + (core.getRootSpan as any).mockReturnValue(mockRootSpan); const instrumentation = createSentryServerInstrumentation(); instrumentation.route?.({ @@ -428,7 +432,6 @@ describe('createSentryServerInstrumentation', () => { }); const hooks = mockInstrument.mock.calls[0]![0]; - // The per-request middleware counter is keyed by this shared `request`, so the 3 calls increment. const requestInfo = { request: { method: 'GET', url: 'http://example.com/multi-middleware', headers: { get: () => null } }, params: {}, From 8f5fb88ddde42a002fcd43709ac58b4a88dbf4e8 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 4 Aug 2026 16:26:58 +0200 Subject: [PATCH 14/15] Always run span-data backfill, independent of the tracer provider Channel-based spans need the Sentry-convention backfill in every mode, so run it unconditionally in _init instead of only in the no-provider branch. This also covers the case where the provider fails to register (setupOtel early-returns), which previously skipped backfill. setupOtel no longer needs the client argument. --- packages/node/src/sdk/index.ts | 9 +++++---- packages/node/src/sdk/initOtel.ts | 6 ++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index d6b4996f774f..3632c19b09ce 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -223,16 +223,17 @@ function _init( }); } + // Channel-based instrumentation emits spans via core `startSpan` in every mode, so always backfill + // the Sentry-convention span data (e.g. `sentry.op`) the OTel provider pipeline would otherwise + // derive. It is idempotent, so it is a no-op for spans the provider already enriches. + setupSpanDataBackfill(client); + // Add Node SDK specific OpenTelemetry setup. `setupEventContextTrace` reads the active span from the // OpenTelemetry context, so it only belongs here: without a Sentry tracer provider a foreign OTel // span could otherwise override the Sentry trace on error events. if (!clientOptions.skipOpenTelemetrySetup) { setupEventContextTrace(client); initOpenTelemetry(client); - } else { - // Without a tracer provider, channel-based instrumentation still emits spans via core `startSpan`. - // Backfill the Sentry-convention span data (e.g. `sentry.op`) the provider pipeline would derive. - setupSpanDataBackfill(client); } // Warn about missing or doubled channel injection. Runs after the client diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 2958e5a6af8a..8a155189e09e 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -75,7 +75,7 @@ export function initOpenTelemetry(client: NodeClient): void { setupOpenTelemetryLogger(); } - const provider = setupOtel(client); + const provider = setupOtel(); client.traceProvider = provider; } @@ -139,7 +139,7 @@ export function setupSpanDataBackfill(client: NodeClient): void { } /** Just exported for tests. */ -export function setupOtel(client: NodeClient): SentryTracerProvider | undefined { +export function setupOtel(): SentryTracerProvider | undefined { const provider = new SentryTracerProvider(); if (!registerGlobalTracerProvider(provider)) { @@ -152,7 +152,5 @@ export function setupOtel(client: NodeClient): SentryTracerProvider | undefined propagation.setGlobalPropagator(new SentryPropagator()); - setupSpanDataBackfill(client); - return provider; } From acc3fe9e8a0cc205e3af72d4ec8f6ff6c23e7ae6 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 4 Aug 2026 16:43:32 +0200 Subject: [PATCH 15/15] Opt SvelteKit Cloudflare into the tracer provider initCloudflareSentryHandle inherited Cloudflare's new no-provider default, so Kit-emitted OpenTelemetry spans (and svelteKitSpansIntegration) were no longer captured on Cloudflare. Default skipOpenTelemetrySetup to false here, matching the Node SvelteKit SDK. --- packages/sveltekit/src/worker/cloudflare.ts | 4 ++++ packages/sveltekit/test/worker/cloudflare.test.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 4f489496876e..4ac812502876 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -22,6 +22,10 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { rewriteFramesIntegration(), svelteKitSpansIntegration(), ], + // SvelteKit emits its own OpenTelemetry spans (Kit tracing), so — like the Node SvelteKit SDK — it + // defaults to registering the tracer provider instead of inheriting Cloudflare's no-provider default. + // A user-provided value still overrides this via `...options`. + skipOpenTelemetrySetup: false, ...options, }; diff --git a/packages/sveltekit/test/worker/cloudflare.test.ts b/packages/sveltekit/test/worker/cloudflare.test.ts index 75fb9e8727d8..b78a13929833 100644 --- a/packages/sveltekit/test/worker/cloudflare.test.ts +++ b/packages/sveltekit/test/worker/cloudflare.test.ts @@ -52,7 +52,14 @@ describe('initCloudflareSentryHandle', () => { expect(wrapRequestHandler).toHaveBeenCalledTimes(1); expect(wrapRequestHandler).toHaveBeenCalledWith( - { options: expect.objectContaining({ dsn: options.dsn }), request, context, captureErrors: false }, + { + // SvelteKit emits its own OpenTelemetry spans, so it opts into the tracer provider rather than + // inheriting Cloudflare's no-provider default. + options: expect.objectContaining({ dsn: options.dsn, skipOpenTelemetrySetup: false }), + request, + context, + captureErrors: false, + }, expect.any(Function), );