diff --git a/MIGRATION.md b/MIGRATION.md index e939dd14dc04..b528bc00d193 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -82,6 +82,31 @@ 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 spans you create through `@opentelemetry/api` are not captured. Set it to `false` to have Sentry register its own `SentryTracerProvider` as the global OpenTelemetry tracer provider, so those `@opentelemetry/api` spans become Sentry spans: + +```js +Sentry.init({ + dsn: '__DSN__', + // Register Sentry's OpenTelemetry tracer provider so spans created via `@opentelemetry/api` are captured + skipOpenTelemetrySetup: false, +}); +``` + +Note that `skipOpenTelemetrySetup: false` makes Sentry the OpenTelemetry tracer provider. If you run your own tracer provider, keep `skipOpenTelemetrySetup: true` so Sentry does not register a competing provider. The SDK no longer ships a `SentrySpanProcessor` or other components to route your OpenTelemetry spans into Sentry, so spans from your own provider stay in your OpenTelemetry pipeline and are not sent to Sentry. + +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/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/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, }); diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index ef00fd80da8d..25539bc63ad9 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -96,6 +96,9 @@ export function initWithDefaultIntegrations( 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, }; @@ -109,14 +112,9 @@ export function initWithDefaultIntegrations( } /*! 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/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/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-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts index 8ce1f7fc224f..57875e28129c 100644 --- a/packages/node-native/src/event-loop-block-integration.ts +++ b/packages/node-native/src/event-loop-block-integration.ts @@ -79,7 +79,11 @@ function startPolling( ): IntegrationInternal | undefined { if (client.asyncLocalStorageLookup) { const { asyncLocalStorage, contextSymbol } = client.asyncLocalStorageLookup; - registerThread({ asyncLocalStorage, stateLookup: ['_currentContext', contextSymbol] }); + // With the OpenTelemetry context strategy, scopes live under `contextSymbol` on the OTel context + // (`store._currentContext[contextSymbol]`). The pure AsyncLocalStorage strategy omits it because + // its store already is the `{ scope, isolationScope }` object, so no traversal is needed. + const stateLookup = contextSymbol ? ['_currentContext', contextSymbol] : []; + registerThread({ asyncLocalStorage, stateLookup }); } else { registerThread(); } 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..23ac92604997 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'; @@ -40,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. @@ -171,7 +172,18 @@ 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) { + // The ALS store already is the `{ scope, isolationScope }` object, so no `contextSymbol` is needed + // to reach it (unlike the OTel context strategy, where it is nested under the OTel context). + const asyncLocalStorage = setAsyncLocalStorageAsyncContextStrategy(); + asyncLocalStorageLookup = { asyncLocalStorage }; + } else { + asyncLocalStorageLookup = setOpenTelemetryContextAsyncContextStrategy(); + } const scope = getCurrentScope(); scope.update(clientOptions.initialScope); @@ -202,8 +214,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) { @@ -213,8 +223,16 @@ function _init( }); } - // Add Node SDK specific OpenTelemetry setup + // 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); } @@ -249,6 +267,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/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 5213fc37bbab..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; } @@ -120,8 +120,26 @@ 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 { +export function setupOtel(): SentryTracerProvider | undefined { const provider = new SentryTracerProvider(); if (!registerGlobalTracerProvider(provider)) { @@ -134,13 +152,5 @@ 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); - } - return provider; } diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 839db1b7547a..7dc6c6f34302 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -8,10 +8,19 @@ 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 its own 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 spans created through `@opentelemetry/api` are not captured. + * + * When `false`, the SDK registers its own `SentryTracerProvider` (and `SentryPropagator`) as the + * global OpenTelemetry tracer provider, so spans created through `@opentelemetry/api` become Sentry + * spans. This is the default for the Next.js and SvelteKit SDKs. If you run your own tracer provider, + * keep this `true` so the SDK does not register a competing provider; note the SDK no longer feeds + * spans into a user-owned provider, so those spans stay in your OpenTelemetry pipeline. + * + * @default true */ skipOpenTelemetrySetup?: boolean; } 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/integration/transactions.test.ts b/packages/node/test/integration/transactions.test.ts index 40feb7cc96ec..c9ebfdf43763 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()!; @@ -298,7 +299,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(); @@ -446,7 +447,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/opentelemetry/src/asyncLocalStorageContextManager.ts b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts index e2b1d05899d8..2943147c565c 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; + /** + * The OpenTelemetry context key under which the `{ scope, isolationScope }` object is stored, for + * native threads that read scope out of the AsyncLocalStorage (e.g. `@sentry/node-native`). Omitted + * for the pure AsyncLocalStorage strategy, whose store already is that object. + */ + contextSymbol?: symbol; }; type ListenerFn = (...args: unknown[]) => unknown; 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; } 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, }; 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), );