From 9c8a46f7ee4eb28f4b719395e1df9387bc6a6dca Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 9 Jun 2026 14:36:30 +0200 Subject: [PATCH 01/12] feat(node): Wire up SentryTracerProvider Add the SentryTracerProvider under an experimental `useSentryTracerProvider` flag and update the node setup path to register the new TracerProvider and its async context strategy instead of the full OTel SDK tracer provider when enabled. --- packages/core/src/types/options.ts | 8 +++ .../http/httpServerSpansIntegration.ts | 6 +- packages/node-core/src/sdk/client.ts | 9 ++- packages/node-core/src/sdk/index.ts | 6 +- packages/node/src/sdk/initOtel.ts | 72 ++++++++++++++++++- packages/node/test/sdk/init.test.ts | 48 +++++++++++++ 6 files changed, 140 insertions(+), 9 deletions(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 3d55c5f17498..c0aa851cdd04 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -466,6 +466,14 @@ export interface ClientOptions { - public traceProvider: BasicTracerProvider | undefined; + public traceProvider: OpenTelemetryTraceProvider | undefined; public asyncLocalStorageLookup: AsyncLocalStorageLookup | undefined; private _tracer: Tracer | undefined; diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index 31493a273d4a..f7dfc1a34376 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -168,7 +168,9 @@ export function validateOpenTelemetrySetup(): void { const required: ReturnType = ['SentryContextManager', 'SentryPropagator']; - if (hasSpansEnabled()) { + const hasSentryTracerProvider = setup.includes('SentryTracerProvider'); + + if (hasSpansEnabled() && !hasSentryTracerProvider) { required.push('SentrySpanProcessor'); } @@ -180,7 +182,7 @@ export function validateOpenTelemetrySetup(): void { } } - if (!setup.includes('SentrySampler')) { + if (!hasSentryTracerProvider && !setup.includes('SentrySampler')) { debug.warn( 'You have to set up the SentrySampler. Without this, the OpenTelemetry & Sentry integration may still work, but sample rates set for the Sentry SDK will not be respected. If you use a custom sampler, make sure to use `wrapSamplingDecision`.', ); diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index e3794097b2b7..4c3470576740 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -9,11 +9,16 @@ import { setupOpenTelemetryLogger, } from '@sentry/node-core'; import { + applyOtelSpanData, type AsyncLocalStorageLookup, getSentryResource, + type OpenTelemetryTraceProvider, SentryPropagator, SentrySampler, SentrySpanProcessor, + SentryTracerProvider, + setIsSetup, + setOpenTelemetryContextAsyncContextStrategy, } from '@sentry/opentelemetry'; import { DEBUG_BUILD } from '../debug-build'; import { getOpenTelemetryInstrumentationToPreload } from '../integrations/tracing'; @@ -86,7 +91,12 @@ function getPreloadMethods(integrationNames?: string[]): ((() => void) & { id: s export function setupOtel( client: NodeClient, options: AdditionalOpenTelemetryOptions = {}, -): [BasicTracerProvider, AsyncLocalStorageLookup] { +): [OpenTelemetryTraceProvider | undefined, AsyncLocalStorageLookup | undefined] { + if (client.getOptions()._experiments?.useSentryTracerProvider) { + setOpenTelemetryContextAsyncContextStrategy(); + return setupSentryTracerProvider(client, options); + } + // Create and configure NodeTracerProvider const provider = new BasicTracerProvider({ sampler: new SentrySampler(client), @@ -111,6 +121,66 @@ export function setupOtel( return [provider, ctxManager.getAsyncLocalStorageLookup()]; } +function setupSentryTracerProvider( + client: NodeClient, + options: AdditionalOpenTelemetryOptions = {}, +): [SentryTracerProvider | undefined, AsyncLocalStorageLookup | undefined] { + if (options.spanProcessors?.length) { + DEBUG_BUILD && + coreDebug.warn( + 'Ignoring `openTelemetrySpanProcessors` because `_experiments.useSentryTracerProvider` is enabled.', + ); + } + + const provider = new SentryTracerProvider({ resource: getSentryResource('node') }); + + if (!trace.setGlobalTracerProvider(provider)) { + DEBUG_BUILD && + coreDebug.warn( + 'Could not register SentryTracerProvider because another OpenTelemetry tracer provider is already registered.', + ); + return [undefined, undefined]; + } + + // Only mark the provider as set up once it is actually the registered global + // tracer provider, so setup validation doesn't skip required checks when + // registration failed. + setIsSetup('SentryTracerProvider'); + + propagation.setGlobalPropagator(new SentryPropagator()); + + const ctxManager = new SentryContextManager(); + context.setGlobalContextManager(ctxManager); + + client.on('spanEnd', span => { + applyOtelSpanData(span, { finalizeStatus: true }); + }); + + client.on('preprocessEvent', event => { + if (event.type !== 'transaction' || client.getOptions().traceLifecycle === 'stream') { + return; + } + + event.contexts = { + ...event.contexts, + ...(typeof event.contexts?.trace?.data?.['http.response.status_code'] === 'number' + ? { + response: { + ...event.contexts.response, + status_code: event.contexts.trace.data['http.response.status_code'], + }, + } + : undefined), + otel: { + resource: provider.resource?.attributes, + ...event.contexts?.otel, + }, + }; + }); + + return [provider, ctxManager.getAsyncLocalStorageLookup()]; +} + /** Just exported for tests. */ export function _clampSpanProcessorTimeout(maxSpanWaitDuration: number | undefined): number | undefined { if (maxSpanWaitDuration == null) { diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index 26fe2d9933e6..04458e0beb7f 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -1,3 +1,4 @@ +import { trace } from '@opentelemetry/api'; import type { Integration } from '@sentry/core'; import { debug, SDK_VERSION } from '@sentry/core'; import * as SentryOpentelemetry from '@sentry/opentelemetry'; @@ -194,6 +195,53 @@ describe('init()', () => { expect(client?.traceProvider).not.toBeDefined(); }); + + it('uses the minimal Sentry trace provider when the experiment is enabled', () => { + init({ dsn: PUBLIC_DSN, _experiments: { useSentryTracerProvider: true } }); + + const client = getClient(); + + expect(client?.traceProvider).toBeInstanceOf(SentryOpentelemetry.SentryTracerProvider); + }); + + it('warns and ignores additional span processors when the minimal Sentry trace provider is enabled', () => { + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + + init({ + dsn: PUBLIC_DSN, + _experiments: { useSentryTracerProvider: true }, + openTelemetrySpanProcessors: [ + { + forceFlush: () => Promise.resolve(), + onStart: () => undefined, + onEnd: () => undefined, + shutdown: () => Promise.resolve(), + }, + ], + }); + + expect(warnSpy).toHaveBeenCalledWith( + 'Ignoring `openTelemetrySpanProcessors` because `_experiments.useSentryTracerProvider` is enabled.', + ); + }); + + it('does not mark SentryTracerProvider as set up when global registration fails', () => { + // Simulate another OpenTelemetry tracer provider already being registered. + const setGlobalSpy = vi.spyOn(trace, 'setGlobalTracerProvider').mockReturnValue(false); + const setIsSetupSpy = vi.spyOn(SentryOpentelemetry, 'setIsSetup'); + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + + init({ dsn: PUBLIC_DSN, _experiments: { useSentryTracerProvider: true } }); + + expect(getClient()?.traceProvider).not.toBeDefined(); + expect(setIsSetupSpy).not.toHaveBeenCalledWith('SentryTracerProvider'); + expect(warnSpy).toHaveBeenCalledWith( + 'Could not register SentryTracerProvider because another OpenTelemetry tracer provider is already registered.', + ); + + setGlobalSpy.mockRestore(); + setIsSetupSpy.mockRestore(); + }); }); it('returns initialized client', () => { From 0bd45500349278c0c8f2bfec53a985d4d1d7f99a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 22 Jun 2026 11:14:05 +0200 Subject: [PATCH 02/12] Add e2e SentryTracerProvider variants --- .../nestjs-basic-with-graphql/package.json | 9 +++++++++ .../nestjs-basic-with-graphql/src/instrument.ts | 7 +++++++ .../nestjs-distributed-tracing/package.json | 9 +++++++++ .../nestjs-distributed-tracing/src/instrument.ts | 7 +++++++ .../test-applications/nextjs-16/package.json | 5 +++++ .../nextjs-16/sentry.server.config.ts | 7 +++++++ .../test-applications/node-connect/package.json | 9 +++++++++ .../test-applications/node-connect/src/app.ts | 7 +++++++ .../test-applications/node-express/package.json | 9 +++++++++ .../test-applications/node-express/src/app.ts | 7 +++++++ .../e2e-tests/test-applications/nuxt-4/package.json | 11 ++++++++++- .../test-applications/nuxt-4/sentry.server.config.ts | 7 +++++++ 12 files changed, 93 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json index e429f8cbb328..26136ba16cc5 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json @@ -45,5 +45,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", + "assert-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert", + "label": "nestjs-basic-with-graphql (sentry-tracer-provider)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/instrument.ts index f1f4de865435..629d820ec982 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/instrument.ts @@ -5,4 +5,11 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, + ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' + ? { + _experiments: { + useSentryTracerProvider: true, + }, + } + : {}), }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json index c8fe82cff563..e3648403dca7 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json @@ -42,5 +42,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", + "assert-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert", + "label": "nestjs-distributed-tracing (sentry-tracer-provider)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts index 1cf7b8ee1f76..bf1ca045416b 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts @@ -5,6 +5,13 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, + ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' + ? { + _experiments: { + useSentryTracerProvider: true, + }, + } + : {}), tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], transportOptions: { // We expect the app to send a lot of events in a short time diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index beda2252d915..762a08894dc7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -62,6 +62,11 @@ { "build-command": "pnpm test:build-latest", "label": "nextjs-16 (latest, turbopack)" + }, + { + "build-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", + "assert-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert", + "label": "nextjs-16 (sentry-tracer-provider)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts index 8b9eaa651f6d..88b452b01aa7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts @@ -7,6 +7,13 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, dataCollection: { userInfo: true }, + ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' + ? { + _experiments: { + useSentryTracerProvider: true, + }, + } + : {}), // debug: true, integrations: [Sentry.vercelAIIntegration(), Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 })], streamGenAiSpans: true, diff --git a/dev-packages/e2e-tests/test-applications/node-connect/package.json b/dev-packages/e2e-tests/test-applications/node-connect/package.json index 729cfbe6c095..aa0edc10aa9e 100644 --- a/dev-packages/e2e-tests/test-applications/node-connect/package.json +++ b/dev-packages/e2e-tests/test-applications/node-connect/package.json @@ -24,5 +24,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", + "assert-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert", + "label": "node-connect (sentry-tracer-provider)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/node-connect/src/app.ts b/dev-packages/e2e-tests/test-applications/node-connect/src/app.ts index 375554845d6f..b72134b3b9f7 100644 --- a/dev-packages/e2e-tests/test-applications/node-connect/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-connect/src/app.ts @@ -6,6 +6,13 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, integrations: [], tracesSampleRate: 1, + ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' + ? { + _experiments: { + useSentryTracerProvider: true, + }, + } + : {}), tunnel: 'http://localhost:3031/', // proxy server tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], }); diff --git a/dev-packages/e2e-tests/test-applications/node-express/package.json b/dev-packages/e2e-tests/test-applications/node-express/package.json index 4d2ad1833a58..7492975213ab 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express/package.json @@ -31,5 +31,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", + "assert-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert", + "label": "node-express (sentry-tracer-provider)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts index dc755f95d062..4455861160a7 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts @@ -14,6 +14,13 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, enableLogs: true, + ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' + ? { + _experiments: { + useSentryTracerProvider: true, + }, + } + : {}), integrations: [ Sentry.nativeNodeFetchIntegration({ headersToSpanAttributes: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 02477111483d..016cf6488513 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -14,8 +14,10 @@ "test:prod": "TEST_ENV=production playwright test", "test:dev": "bash ./nuxt-start-dev-server.bash && TEST_ENV=development playwright test environment", "test:build": "pnpm install && pnpm build", + "test:build:sentry-tracer-provider": "E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev" + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:assert:sentry-tracer-provider": "E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert" }, "dependencies": { "@pinia/nuxt": "^0.5.5", @@ -36,6 +38,13 @@ "build-command": "pnpm test:build-canary", "label": "nuxt-4 (canary)" } + ], + "variants": [ + { + "build-command": "pnpm test:build:sentry-tracer-provider", + "assert-command": "pnpm test:assert:sentry-tracer-provider", + "label": "nuxt-4 (sentry-tracer-provider)" + } ] } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index 26519911072b..df55180a3ceb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -3,5 +3,12 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions + ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' + ? { + _experiments: { + useSentryTracerProvider: true, + }, + } + : {}), tunnel: 'http://localhost:3031/', // proxy server }); From 34f4bddf145689aaf3558b268c66debbceee4a0c Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 22 Jun 2026 14:09:23 +0200 Subject: [PATCH 03/12] Set the `response` context in httpServerSpansIntegration --- .../http/httpServerSpansIntegration.ts | 16 ++++-- .../httpServerSpansIntegration.test.ts | 51 ++++++++++++++++++- packages/node/src/sdk/initOtel.ts | 8 --- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index 913be8d88d1d..b99eeb2bf918 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -225,15 +225,25 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions }); }, processEvent(event) { - // Drop transaction if it has a status code that should be ignored if (event.type === 'transaction') { const statusCode = event.contexts?.trace?.data?.['http.response.status_code']; if (typeof statusCode === 'number') { - const shouldDrop = shouldFilterStatusCode(statusCode, ignoreStatusCodes); - if (shouldDrop) { + // Drop transaction if it has a status code that should be ignored + if (shouldFilterStatusCode(statusCode, ignoreStatusCodes)) { DEBUG_BUILD && debug.log('Dropping transaction due to status code', statusCode); return null; } + + // Surface the HTTP status as the top-level `response` context. The OTel SDK span + // exporter already does this on its path; doing it here covers transactions produced + // by the `SentryTracerProvider`, which bypasses that exporter. + event.contexts = { + ...event.contexts, + response: { + ...event.contexts?.response, + status_code: statusCode, + }, + }; } } diff --git a/packages/node-core/test/integrations/httpServerSpansIntegration.test.ts b/packages/node-core/test/integrations/httpServerSpansIntegration.test.ts index 5603310db108..f1b5af564d79 100644 --- a/packages/node-core/test/integrations/httpServerSpansIntegration.test.ts +++ b/packages/node-core/test/integrations/httpServerSpansIntegration.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { isStaticAssetRequest } from '../../src/integrations/http/httpServerSpansIntegration'; +import { + httpServerSpansIntegration, + isStaticAssetRequest, +} from '../../src/integrations/http/httpServerSpansIntegration'; describe('httpIntegration', () => { describe('isStaticAssetRequest', () => { @@ -31,4 +34,50 @@ describe('httpIntegration', () => { expect(isStaticAssetRequest(urlPath)).toBe(expected); }); }); + + describe('processEvent', () => { + function runProcessEvent(event: Record, options = {}): any { + const integration = httpServerSpansIntegration(options); + return (integration as any).processEvent(event, {}, {}); + } + + it('lifts the HTTP response status code into the top-level `response` context', () => { + const event = runProcessEvent( + { type: 'transaction', contexts: { trace: { data: { 'http.response.status_code': 200 } } } }, + { ignoreStatusCodes: [] }, + ); + + expect(event.contexts.response).toEqual({ status_code: 200 }); + }); + + it('preserves existing `response` context fields', () => { + const event = runProcessEvent( + { + type: 'transaction', + contexts: { response: { body_size: 42 }, trace: { data: { 'http.response.status_code': 201 } } }, + }, + { ignoreStatusCodes: [] }, + ); + + expect(event.contexts.response).toEqual({ body_size: 42, status_code: 201 }); + }); + + it('does not add a `response` context when there is no HTTP status code', () => { + const event = runProcessEvent( + { type: 'transaction', contexts: { trace: { data: {} } } }, + { ignoreStatusCodes: [] }, + ); + + expect(event.contexts.response).toBeUndefined(); + }); + + it('drops transactions whose status code is in `ignoreStatusCodes`', () => { + const event = runProcessEvent( + { type: 'transaction', contexts: { trace: { data: { 'http.response.status_code': 404 } } } }, + { ignoreStatusCodes: [404] }, + ); + + expect(event).toBeNull(); + }); + }); }); diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 4c3470576740..2811f291fb69 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -163,14 +163,6 @@ function setupSentryTracerProvider( event.contexts = { ...event.contexts, - ...(typeof event.contexts?.trace?.data?.['http.response.status_code'] === 'number' - ? { - response: { - ...event.contexts.response, - status_code: event.contexts.trace.data['http.response.status_code'], - }, - } - : undefined), otel: { resource: provider.resource?.attributes, ...event.contexts?.otel, From f805512b2f0a6536916ff02a80c68dde5770d3f3 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 23 Jun 2026 00:47:12 +0200 Subject: [PATCH 04/12] Fix imports --- packages/node-core/src/sdk/client.ts | 4 ++-- packages/node/src/sdk/initOtel.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts index 1bb035d178d3..69bdb226edf9 100644 --- a/packages/node-core/src/sdk/client.ts +++ b/packages/node-core/src/sdk/client.ts @@ -14,7 +14,7 @@ import { import { type AsyncLocalStorageLookup, getTraceContextForScope, - type OpenTelemetryTraceProvider, + type OpenTelemetryTracerProvider, } from '@sentry/opentelemetry'; import { isMainThread, threadId } from 'worker_threads'; import { DEBUG_BUILD } from '../debug-build'; @@ -24,7 +24,7 @@ const DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS = 60_000; // 60s was chosen arbitr /** A client for using Sentry with Node & OpenTelemetry. */ export class NodeClient extends ServerRuntimeClient { - public traceProvider: OpenTelemetryTraceProvider | undefined; + public traceProvider: OpenTelemetryTracerProvider | undefined; public asyncLocalStorageLookup: AsyncLocalStorageLookup | undefined; private _tracer: Tracer | undefined; diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 2811f291fb69..1d8ae5f2a452 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -12,7 +12,7 @@ import { applyOtelSpanData, type AsyncLocalStorageLookup, getSentryResource, - type OpenTelemetryTraceProvider, + type OpenTelemetryTracerProvider, SentryPropagator, SentrySampler, SentrySpanProcessor, @@ -91,7 +91,7 @@ function getPreloadMethods(integrationNames?: string[]): ((() => void) & { id: s export function setupOtel( client: NodeClient, options: AdditionalOpenTelemetryOptions = {}, -): [OpenTelemetryTraceProvider | undefined, AsyncLocalStorageLookup | undefined] { +): [OpenTelemetryTracerProvider | undefined, AsyncLocalStorageLookup | undefined] { if (client.getOptions()._experiments?.useSentryTracerProvider) { setOpenTelemetryContextAsyncContextStrategy(); return setupSentryTracerProvider(client, options); From 66e2a88610f1f9711dc3188f46181115479a4eb5 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 23 Jun 2026 01:23:23 +0200 Subject: [PATCH 05/12] Remove the redundant setOpenTelemetryContextAsyncContextStrategy calls --- packages/node/src/sdk/initOtel.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 1d8ae5f2a452..15b382d23576 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -18,7 +18,6 @@ import { SentrySpanProcessor, SentryTracerProvider, setIsSetup, - setOpenTelemetryContextAsyncContextStrategy, } from '@sentry/opentelemetry'; import { DEBUG_BUILD } from '../debug-build'; import { getOpenTelemetryInstrumentationToPreload } from '../integrations/tracing'; @@ -93,7 +92,6 @@ export function setupOtel( options: AdditionalOpenTelemetryOptions = {}, ): [OpenTelemetryTracerProvider | undefined, AsyncLocalStorageLookup | undefined] { if (client.getOptions()._experiments?.useSentryTracerProvider) { - setOpenTelemetryContextAsyncContextStrategy(); return setupSentryTracerProvider(client, options); } From 9a22e5b0896167fd4b4ede6423d2c58c4b93b5c7 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 23 Jun 2026 17:40:14 +0200 Subject: [PATCH 06/12] Fix node-connect tests --- .../node-connect/tests/transactions.test.ts | 74 ++++++++++--------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts index 9b06ad052f58..f04a5691badc 100644 --- a/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts @@ -1,6 +1,8 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +const useSentryTracerProvider = process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1'; + test('Sends an API route transaction', async ({ baseURL }) => { const pageloadTransactionEventPromise = waitForTransaction('node-connect', transactionEvent => { return ( @@ -54,41 +56,47 @@ test('Sends an API route transaction', async ({ baseURL }) => { origin: 'auto.http.otel.http', }); + const manualSpanExpectation = { + data: { + 'sentry.origin': 'manual', + }, + description: 'test-span', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + }; + + const connectSpanExpectation = { + data: { + 'sentry.origin': 'auto.http.otel.connect', + 'sentry.op': 'request_handler.connect', + 'http.route': '/test-transaction', + 'connect.type': 'request_handler', + 'connect.name': '/test-transaction', + }, + op: 'request_handler.connect', + description: '/test-transaction', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.http.otel.connect', + }; + expect(transactionEvent).toEqual( expect.objectContaining({ - spans: [ - { - data: { - 'sentry.origin': 'manual', - }, - description: 'test-span', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'manual', - }, - { - data: { - 'sentry.origin': 'auto.http.otel.connect', - 'sentry.op': 'request_handler.connect', - 'http.route': '/test-transaction', - 'connect.type': 'request_handler', - 'connect.name': '/test-transaction', - }, - op: 'request_handler.connect', - description: '/test-transaction', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.http.otel.connect', - }, - ], + // The SentryTracerProvider serializes native child spans in start/tree order, so the + // Connect handler span appears before the manual span created inside it. The legacy + // OTel exporter path emits them in finish order, where the manual span comes first. + spans: useSentryTracerProvider + ? [connectSpanExpectation, manualSpanExpectation] + : [manualSpanExpectation, connectSpanExpectation], transaction: 'GET /test-transaction', type: 'transaction', transaction_info: { From 052d27b9c482adb0932eeaa2994c48f0ab65c273 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 24 Jun 2026 00:14:40 +0200 Subject: [PATCH 07/12] Make SentryTracerProvider the default for @sentry/node --- .../nestjs-basic-with-graphql/package.json | 9 ------- .../src/instrument.ts | 7 ------ .../nestjs-distributed-tracing/package.json | 9 ------- .../src/instrument.ts | 7 ------ .../test-applications/nextjs-16/package.json | 5 ---- .../nextjs-16/sentry.server.config.ts | 7 ------ .../node-connect/package.json | 9 ------- .../test-applications/node-connect/src/app.ts | 7 ------ .../node-connect/tests/transactions.test.ts | 9 ++----- .../node-express/package.json | 9 ------- .../test-applications/node-express/src/app.ts | 7 ------ .../test-applications/nuxt-4/package.json | 11 +-------- .../nuxt-4/sentry.server.config.ts | 7 ------ packages/core/src/types/options.ts | 8 ------- packages/node-core/src/types.ts | 14 +++++++++++ packages/node/src/sdk/initOtel.ts | 19 +++++++-------- packages/node/test/helpers/mockSdkInit.ts | 11 +++++---- packages/node/test/integration/scope.test.ts | 9 ++++++- .../test/integration/transactions.test.ts | 11 +++++++-- packages/node/test/sdk/init.test.ts | 24 ++++++++++++------- packages/opentelemetry/README.md | 15 ++++++------ 21 files changed, 71 insertions(+), 143 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json index 26136ba16cc5..e429f8cbb328 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json @@ -45,14 +45,5 @@ }, "volta": { "extends": "../../package.json" - }, - "sentryTest": { - "variants": [ - { - "build-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", - "assert-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert", - "label": "nestjs-basic-with-graphql (sentry-tracer-provider)" - } - ] } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/instrument.ts index 629d820ec982..f1f4de865435 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/instrument.ts @@ -5,11 +5,4 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, - ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' - ? { - _experiments: { - useSentryTracerProvider: true, - }, - } - : {}), }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json index e3648403dca7..c8fe82cff563 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json @@ -42,14 +42,5 @@ }, "volta": { "extends": "../../package.json" - }, - "sentryTest": { - "variants": [ - { - "build-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", - "assert-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert", - "label": "nestjs-distributed-tracing (sentry-tracer-provider)" - } - ] } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts index bf1ca045416b..1cf7b8ee1f76 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts @@ -5,13 +5,6 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, - ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' - ? { - _experiments: { - useSentryTracerProvider: true, - }, - } - : {}), tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], transportOptions: { // We expect the app to send a lot of events in a short time diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 762a08894dc7..beda2252d915 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -62,11 +62,6 @@ { "build-command": "pnpm test:build-latest", "label": "nextjs-16 (latest, turbopack)" - }, - { - "build-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", - "assert-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert", - "label": "nextjs-16 (sentry-tracer-provider)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts index 88b452b01aa7..8b9eaa651f6d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts @@ -7,13 +7,6 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, dataCollection: { userInfo: true }, - ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' - ? { - _experiments: { - useSentryTracerProvider: true, - }, - } - : {}), // debug: true, integrations: [Sentry.vercelAIIntegration(), Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 })], streamGenAiSpans: true, diff --git a/dev-packages/e2e-tests/test-applications/node-connect/package.json b/dev-packages/e2e-tests/test-applications/node-connect/package.json index aa0edc10aa9e..729cfbe6c095 100644 --- a/dev-packages/e2e-tests/test-applications/node-connect/package.json +++ b/dev-packages/e2e-tests/test-applications/node-connect/package.json @@ -24,14 +24,5 @@ }, "volta": { "extends": "../../package.json" - }, - "sentryTest": { - "variants": [ - { - "build-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", - "assert-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert", - "label": "node-connect (sentry-tracer-provider)" - } - ] } } diff --git a/dev-packages/e2e-tests/test-applications/node-connect/src/app.ts b/dev-packages/e2e-tests/test-applications/node-connect/src/app.ts index b72134b3b9f7..375554845d6f 100644 --- a/dev-packages/e2e-tests/test-applications/node-connect/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-connect/src/app.ts @@ -6,13 +6,6 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, integrations: [], tracesSampleRate: 1, - ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' - ? { - _experiments: { - useSentryTracerProvider: true, - }, - } - : {}), tunnel: 'http://localhost:3031/', // proxy server tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], }); diff --git a/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts index f04a5691badc..f6991ed7a75a 100644 --- a/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts @@ -1,8 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -const useSentryTracerProvider = process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1'; - test('Sends an API route transaction', async ({ baseURL }) => { const pageloadTransactionEventPromise = waitForTransaction('node-connect', transactionEvent => { return ( @@ -92,11 +90,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent).toEqual( expect.objectContaining({ // The SentryTracerProvider serializes native child spans in start/tree order, so the - // Connect handler span appears before the manual span created inside it. The legacy - // OTel exporter path emits them in finish order, where the manual span comes first. - spans: useSentryTracerProvider - ? [connectSpanExpectation, manualSpanExpectation] - : [manualSpanExpectation, connectSpanExpectation], + // Connect handler span appears before the manual span created inside it. + spans: [connectSpanExpectation, manualSpanExpectation], transaction: 'GET /test-transaction', type: 'transaction', transaction_info: { diff --git a/dev-packages/e2e-tests/test-applications/node-express/package.json b/dev-packages/e2e-tests/test-applications/node-express/package.json index 7492975213ab..4d2ad1833a58 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express/package.json @@ -31,14 +31,5 @@ }, "volta": { "extends": "../../package.json" - }, - "sentryTest": { - "variants": [ - { - "build-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", - "assert-command": "env E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert", - "label": "node-express (sentry-tracer-provider)" - } - ] } } diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts index 4455861160a7..dc755f95d062 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts @@ -14,13 +14,6 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, enableLogs: true, - ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' - ? { - _experiments: { - useSentryTracerProvider: true, - }, - } - : {}), integrations: [ Sentry.nativeNodeFetchIntegration({ headersToSpanAttributes: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 016cf6488513..02477111483d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -14,10 +14,8 @@ "test:prod": "TEST_ENV=production playwright test", "test:dev": "bash ./nuxt-start-dev-server.bash && TEST_ENV=development playwright test environment", "test:build": "pnpm install && pnpm build", - "test:build:sentry-tracer-provider": "E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:build", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev", - "test:assert:sentry-tracer-provider": "E2E_USE_SENTRY_TRACER_PROVIDER=1 pnpm test:assert" + "test:assert": "pnpm test:prod && pnpm test:dev" }, "dependencies": { "@pinia/nuxt": "^0.5.5", @@ -38,13 +36,6 @@ "build-command": "pnpm test:build-canary", "label": "nuxt-4 (canary)" } - ], - "variants": [ - { - "build-command": "pnpm test:build:sentry-tracer-provider", - "assert-command": "pnpm test:assert:sentry-tracer-provider", - "label": "nuxt-4 (sentry-tracer-provider)" - } ] } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index df55180a3ceb..26519911072b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -3,12 +3,5 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions - ...(process.env.E2E_USE_SENTRY_TRACER_PROVIDER === '1' - ? { - _experiments: { - useSentryTracerProvider: true, - }, - } - : {}), tunnel: 'http://localhost:3031/', // proxy server }); diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index c0aa851cdd04..3d55c5f17498 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -466,14 +466,6 @@ export interface ClientOptions) { export function cleanupOtel(_provider?: BasicTracerProvider): void { const provider = getProvider(_provider); - if (!provider) { - return; + // `getProvider` only resolves the OpenTelemetry SDK `BasicTracerProvider`; the default + // `SentryTracerProvider` is not an instance of it. Flush/shutdown only apply to the SDK provider, + // but the global APIs must always be disabled so the next test can register its own provider. + if (provider) { + void provider.forceFlush(); + void provider.shutdown(); } - void provider.forceFlush(); - void provider.shutdown(); - // Disable all globally registered APIs trace.disable(); context.disable(); diff --git a/packages/node/test/integration/scope.test.ts b/packages/node/test/integration/scope.test.ts index 6f2acaf267ee..20b01d6fce47 100644 --- a/packages/node/test/integration/scope.test.ts +++ b/packages/node/test/integration/scope.test.ts @@ -41,7 +41,14 @@ describe('Integration | Scope', () => { scope2.setTag('tag3', 'val3'); Sentry.startSpan({ name: 'outer' }, span => { - expect(getCapturedScopesOnSpan(span).scope).toBe(tracingEnabled ? scope2 : undefined); + // The SentryTracerProvider captures a snapshot (clone) of the active scope at span + // start — for both sampled and non-recording spans — rather than the live instance, so + // assert the captured scope's data instead of instance identity. + expect(getCapturedScopesOnSpan(span).scope?.getScopeData().tags).toEqual({ + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }); spanId = span.spanContext().spanId; traceId = span.spanContext().traceId; diff --git a/packages/node/test/integration/transactions.test.ts b/packages/node/test/integration/transactions.test.ts index 7b13a400dedb..e15ee6f89dac 100644 --- a/packages/node/test/integration/transactions.test.ts +++ b/packages/node/test/integration/transactions.test.ts @@ -97,7 +97,9 @@ describe('Integration | Transactions', () => { origin: 'auto.test', }); - expect(transaction.sdkProcessingMetadata?.sampleRate).toEqual(1); + // The sample rate is carried by the dynamic sampling context (asserted below). The + // `SentryTracerProvider` builds transactions via core's span capture, which does not write the + // (unused) `sdkProcessingMetadata.sampleRate` field the OpenTelemetry SDK exporter does. expect(transaction.sdkProcessingMetadata?.dynamicSamplingContext).toEqual({ environment: 'production', public_key: expect.any(String), @@ -558,7 +560,9 @@ describe('Integration | Transactions', () => { const logs: unknown[] = []; vi.spyOn(debug, 'log').mockImplementation(msg => logs.push(msg)); - mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + // This test inspects the `SentrySpanProcessor`/exporter buffering, which only exists on the + // OpenTelemetry SDK provider, so opt out of the default `SentryTracerProvider`. + mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction, openTelemetryBasicTracerProvider: true }); const spanProcessor = getSpanProcessor(); @@ -630,10 +634,13 @@ describe('Integration | Transactions', () => { const logs: unknown[] = []; vi.spyOn(debug, 'log').mockImplementation(msg => logs.push(msg)); + // `maxSpanWaitDuration` configures the `SentrySpanProcessor` timeout, which only exists on the + // OpenTelemetry SDK provider, so opt out of the default `SentryTracerProvider`. mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction, maxSpanWaitDuration: 100 * 60, + openTelemetryBasicTracerProvider: true, }); Sentry.startSpanManual({ name: 'test name' }, rootSpan => { diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index 04458e0beb7f..1dd01361a2ab 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -1,4 +1,5 @@ import { trace } from '@opentelemetry/api'; +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import type { Integration } from '@sentry/core'; import { debug, SDK_VERSION } from '@sentry/core'; import * as SentryOpentelemetry from '@sentry/opentelemetry'; @@ -196,20 +197,25 @@ describe('init()', () => { expect(client?.traceProvider).not.toBeDefined(); }); - it('uses the minimal Sentry trace provider when the experiment is enabled', () => { - init({ dsn: PUBLIC_DSN, _experiments: { useSentryTracerProvider: true } }); + it('uses the minimal Sentry trace provider by default', () => { + init({ dsn: PUBLIC_DSN }); const client = getClient(); expect(client?.traceProvider).toBeInstanceOf(SentryOpentelemetry.SentryTracerProvider); }); - it('warns and ignores additional span processors when the minimal Sentry trace provider is enabled', () => { - const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + it('uses the OpenTelemetry SDK tracer provider when opted in via `openTelemetryBasicTracerProvider`', () => { + init({ dsn: PUBLIC_DSN, openTelemetryBasicTracerProvider: true }); + + const client = getClient(); + + expect(client?.traceProvider).toBeInstanceOf(BasicTracerProvider); + }); + it('uses the OpenTelemetry SDK tracer provider when custom span processors are provided', () => { init({ dsn: PUBLIC_DSN, - _experiments: { useSentryTracerProvider: true }, openTelemetrySpanProcessors: [ { forceFlush: () => Promise.resolve(), @@ -220,9 +226,9 @@ describe('init()', () => { ], }); - expect(warnSpy).toHaveBeenCalledWith( - 'Ignoring `openTelemetrySpanProcessors` because `_experiments.useSentryTracerProvider` is enabled.', - ); + const client = getClient(); + + expect(client?.traceProvider).toBeInstanceOf(BasicTracerProvider); }); it('does not mark SentryTracerProvider as set up when global registration fails', () => { @@ -231,7 +237,7 @@ describe('init()', () => { const setIsSetupSpy = vi.spyOn(SentryOpentelemetry, 'setIsSetup'); const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); - init({ dsn: PUBLIC_DSN, _experiments: { useSentryTracerProvider: true } }); + init({ dsn: PUBLIC_DSN }); expect(getClient()?.traceProvider).not.toBeDefined(); expect(setIsSetupSpy).not.toHaveBeenCalledWith('SentryTracerProvider'); diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index 265a761c9a0b..3fc8413e6144 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -85,9 +85,9 @@ function setupSentry() { A full setup example can be found in [node-experimental](https://github.com/getsentry/sentry-javascript/blob/develop/packages/node-experimental). -## Experimental Sentry Tracer Provider +## Sentry Tracer Provider -`SentryTracerProvider` is an experimental minimal OpenTelemetry tracer provider which creates native Sentry spans directly. +`SentryTracerProvider` is a minimal OpenTelemetry tracer provider which creates native Sentry spans directly. It is useful when code uses the global OpenTelemetry API and you do not need the full OpenTelemetry SDK span processor and exporter pipeline. @@ -101,19 +101,18 @@ const span = trace.getTracer('example').startSpan('work'); span.end(); ``` -In `@sentry/node`, this provider can be enabled with the experimental option: +In `@sentry/node`, this is the default tracer provider. To use the full OpenTelemetry SDK `BasicTracerProvider` +instead, opt out with: ```js Sentry.init({ dsn: 'xxx', - _experiments: { - useSentryTracerProvider: true, - }, + openTelemetryBasicTracerProvider: true, }); ``` -When this provider is enabled, additional OpenTelemetry span processors are ignored because Sentry spans are created -directly. OpenTelemetry logs and metrics are not handled by this provider. +Providing `openTelemetrySpanProcessors` also falls back to the full OpenTelemetry SDK provider, since custom span +processors require the SDK span pipeline. The `SentryTracerProvider` does not handle OpenTelemetry logs and metrics. ## Links From 8aafa1765ccf098b572c8c96af795d1d9788deeb Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 24 Jun 2026 10:39:07 +0200 Subject: [PATCH 08/12] Drop orphan http.client fetch spans in the fetch instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outside of span streaming, an outgoing fetch (`http.client`) span with no local parent is no longer recorded as a standalone transaction — the downstream sampling decision is left to the server. This is enforced via `onlyIfParent`, which still creates a non-recording span so trace propagation headers are injected. This rule already lives in `SentrySampler`, but that only runs when an OpenTelemetry SDK tracer provider is set up. Enforcing it in the instrumentation makes it hold for the `SentryTracerProvider` and for SDKs that don't use an OpenTelemetry tracer provider at all. The sampler rule is kept for OpenTelemetry SDK / custom OpenTelemetry setups. --- .../scenario-fetch.mjs | 1 + .../no-parent-span-client-report/test.ts | 23 ++++++++++++++++++- .../node-fetch/vendored/undici.ts | 12 ++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/scenario-fetch.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/scenario-fetch.mjs b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/scenario-fetch.mjs new file mode 100644 index 000000000000..a122330366e4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/scenario-fetch.mjs @@ -0,0 +1 @@ +fetch('http://localhost:9999/external').catch(() => {}); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts index 699dec65ddcf..4ad1b3150f2c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts @@ -7,7 +7,28 @@ describe('no_parent_span client report', () => { }); createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('records no_parent_span outcome for http.client span without a local parent', async () => { + test('records no_parent_span outcome for an outgoing http request without a local parent', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: report => { + expect(report.discarded_events).toEqual([ + { + category: 'span', + quantity: 1, + reason: 'no_parent_span', + }, + ]); + }, + }) + .start(); + + await runner.completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-fetch.mjs', 'instrument.mjs', (createRunner, test) => { + test('records no_parent_span outcome for an outgoing fetch request without a local parent', async () => { const runner = createRunner() .unignore('client_report') .expect({ diff --git a/packages/node/src/integrations/node-fetch/vendored/undici.ts b/packages/node/src/integrations/node-fetch/vendored/undici.ts index 814613921a20..bee0ba2f303f 100644 --- a/packages/node/src/integrations/node-fetch/vendored/undici.ts +++ b/packages/node/src/integrations/node-fetch/vendored/undici.ts @@ -9,6 +9,9 @@ * - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs * - Dropped the OTel metrics (no MeterProvider is wired up) and the dead * `requireParentforSpans` code path (the SDK always passes `false`) + * - An orphan `http.client` span (no local parent) is created suppressed/non-recording outside of + * span streaming, so it isn't emitted as a standalone transaction. It is still created so trace + * propagation headers are injected. * - Dropped the `@opentelemetry/instrumentation` base (undici reports via `diagnostics_channel`, * so no module patching was needed) — now a plain class wired up directly by the integration */ @@ -21,6 +24,7 @@ import { debug, getClient, getTraceData, + hasSpanStreamingEnabled, LRUMap, shouldPropagateTraceForUrl, SPAN_KIND, @@ -242,10 +246,18 @@ export class UndiciInstrumentation { }); } + // Outside of span streaming, only record an `http.client` span when it has a parent. An orphan + // one (no local parent) is left to the server for the downstream sampling decision: `onlyIfParent` + // still creates a non-recording span so trace propagation headers are injected, but it isn't + // emitted as a standalone transaction. This rule also lives in `SentrySampler`, but that only runs + // when an OpenTelemetry SDK tracer provider is set up, so we enforce it here too, which covers + // SDKs that don't use an OpenTelemetry tracer provider at all. + const client = getClient(); const span = startInactiveSpan({ name: requestMethod === '_OTHER' ? 'HTTP' : requestMethod, kind: SPAN_KIND.CLIENT, attributes, + onlyIfParent: !client || !hasSpanStreamingEnabled(client), }); // Execute the request hook if defined From 61b89130bdb0f52fc65cc575da8111738ad1d405 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 24 Jun 2026 15:30:51 +0200 Subject: [PATCH 09/12] Drop redundant stream-lifecycle guard in the otel.resource preprocessEvent hook --- packages/node/src/sdk/initOtel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 4c3589bbd61b..fc0e7f45ccf0 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -154,7 +154,7 @@ function setupSentryTracerProvider( }); client.on('preprocessEvent', event => { - if (event.type !== 'transaction' || client.getOptions().traceLifecycle === 'stream') { + if (event.type !== 'transaction') { return; } From 31d0343c88bcd4dda6322d05cacc649e1f60a704 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 24 Jun 2026 19:20:44 +0200 Subject: [PATCH 10/12] Resolve outgoing fetch span status from the HTTP response status code --- .../src/integrations/node-fetch/vendored/undici.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/node/src/integrations/node-fetch/vendored/undici.ts b/packages/node/src/integrations/node-fetch/vendored/undici.ts index bee0ba2f303f..48a514347b44 100644 --- a/packages/node/src/integrations/node-fetch/vendored/undici.ts +++ b/packages/node/src/integrations/node-fetch/vendored/undici.ts @@ -23,6 +23,7 @@ import type { Span, SpanAttributes } from '@sentry/core'; import { debug, getClient, + getSpanStatusFromHttpCode, getTraceData, hasSpanStreamingEnabled, LRUMap, @@ -356,10 +357,13 @@ export class UndiciInstrumentation { span.setAttributes(spanAttributes); - // The Sentry pipeline infers `ok` / `not_found` / etc. from `http.response.status_code` when the - // status is left unset, so we only need to flag erroneous responses explicitly. + // Resolve the HTTP status code to a Sentry span status here (like the raw http client/server + // instrumentation does) instead of setting a bare error and deferring to downstream inference. + // The SentryTracerProvider's status finalization reads the already-stringified span status, which + // can no longer be inferred back to `not_found` etc. the way the OpenTelemetry SDK exporter's + // `mapStatus` does from the raw `{ code, message }`. if (response.statusCode >= 400) { - span.setStatus({ code: SPAN_STATUS_ERROR }); + span.setStatus(getSpanStatusFromHttpCode(response.statusCode)); } } From b23285773b5360f2b2207e0e9324608dbf526746 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 24 Jun 2026 22:58:43 +0200 Subject: [PATCH 11/12] Expect a custom source after span.updateName in the streamed test --- .../public-api/startSpan/updateName-method-streamed/test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts index f9d15cf60e30..258c37d65b4c 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts @@ -15,7 +15,9 @@ test('updates the span name when calling `span.updateName` (streamed)', async () name: 'new name', is_segment: true, attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' }, + // `updateName` marks the name as explicitly chosen, so the source becomes `custom`, + // overriding the `url` source set at span start (a stale `url` no longer describes the name). + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, }, }, ], From 796a13eac1ea224634e36e51395b09212e1f4aa9 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 25 Jun 2026 00:31:58 +0200 Subject: [PATCH 12/12] Await the non-streamed updateName-method test and expect a custom source --- .../suites/public-api/startSpan/updateName-method/test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method/test.ts index c46efa9a7fc3..74c0f5b8f7ea 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method/test.ts @@ -7,16 +7,18 @@ afterAll(() => { }); test('updates the span name when calling `span.updateName`', async () => { - createRunner(__dirname, 'scenario.ts') + await createRunner(__dirname, 'scenario.ts') .expect({ transaction: { transaction: 'new name', - transaction_info: { source: 'url' }, + // `updateName` marks the name as explicitly chosen, so the source becomes `custom`, + // overriding the `url` source set at span start (a stale `url` no longer describes the name). + transaction_info: { source: 'custom' }, contexts: { trace: { span_id: expect.any(String), trace_id: expect.any(String), - data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }, + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' }, }, }, },