From d4fb98ae97974f342164206fbade54ffc19d43b6 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 6 Aug 2026 15:16:43 +0200 Subject: [PATCH 01/10] feat(server-utils): Add `otlpIntegration` to connect Sentry to an existing OpenTelemetry setup Adds `otlpIntegration()` and `getOtlpTracesEndpoint()` to `@sentry/server-utils`, re-exported from the server SDKs so no extra install or import is needed. Co-Authored-By: Claude Opus 5 --- .../node-express-otlp/.gitignore | 1 + .../node-express-otlp/package.json | 31 ++++ .../node-express-otlp/playwright.config.mjs | 7 + .../node-express-otlp/src/app.ts | 91 ++++++++++++ .../node-express-otlp/start-event-proxy.mjs | 6 + .../node-express-otlp/tests/otlp.test.ts | 52 +++++++ .../node-express-otlp/tsconfig.json | 11 ++ packages/aws-serverless/src/index.ts | 2 + packages/bun/src/index.ts | 2 + packages/cloudflare/src/index.ts | 2 + packages/deno/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 2 + packages/node/src/index.ts | 2 + packages/server-utils/package.json | 1 + packages/server-utils/src/exports.ts | 1 + packages/server-utils/src/otlp.ts | 59 ++++++++ packages/server-utils/test/otlp.test.ts | 133 ++++++++++++++++++ packages/vercel-edge/src/index.ts | 2 + 18 files changed, 406 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-express-otlp/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-express-otlp/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-otlp/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-otlp/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-otlp/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-otlp/tsconfig.json create mode 100644 packages/server-utils/src/otlp.ts create mode 100644 packages/server-utils/test/otlp.test.ts diff --git a/dev-packages/e2e-tests/test-applications/node-express-otlp/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-otlp/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-otlp/package.json b/dev-packages/e2e-tests/test-applications/node-express-otlp/package.json new file mode 100644 index 000000000000..95ab3c1ebd6b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/package.json @@ -0,0 +1,31 @@ +{ + "name": "node-express-otlp-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@opentelemetry/sdk-trace-node": "^2.9.0", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "express": "^4.21.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-otlp/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-otlp/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-otlp/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-otlp/src/app.ts new file mode 100644 index 000000000000..18e92617e065 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/src/app.ts @@ -0,0 +1,91 @@ +import { trace } from '@opentelemetry/api'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +const dsn = process.env.E2E_TEST_DSN as string; +const appPort = 3030; +const otlpReceiverPort = 3033; + +const otlpTracesEndpoint = Sentry.getOtlpTracesEndpoint(dsn); +if (!otlpTracesEndpoint) { + throw new Error(`Could not derive an OTLP traces endpoint from E2E_TEST_DSN: ${dsn}`); +} + +// The user brings their own OpenTelemetry setup. In production `url` would be +// `otlpTracesEndpoint.url`; here it points at the local receiver below so the test can assert what +// was actually exported. The auth headers are the real DSN-derived ones either way. +const provider = new NodeTracerProvider({ + spanProcessors: [ + new BatchSpanProcessor( + new OTLPTraceExporter({ + url: `http://localhost:${otlpReceiverPort}/v1/traces`, + headers: otlpTracesEndpoint.headers, + }), + { scheduledDelayMillis: 100 }, + ), + ], +}); + +provider.register(); + +Sentry.init({ + dsn, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + integrations: [Sentry.otlpIntegration()], +}); + +interface ExportedTrace { + traceId: string; + spanIds: string[]; + sentryAuthHeader?: string; +} + +const exportedTraces: ExportedTrace[] = []; + +const otlpReceiver = express(); +otlpReceiver.use(express.json({ limit: '10mb' })); + +otlpReceiver.post('/v1/traces', (req, res) => { + const sentryAuthHeader = req.header('x-sentry-auth'); + + for (const resourceSpan of req.body?.resourceSpans ?? []) { + for (const scopeSpan of resourceSpan.scopeSpans ?? []) { + for (const span of scopeSpan.spans ?? []) { + const existing = exportedTraces.find(entry => entry.traceId === span.traceId); + if (existing) { + existing.spanIds.push(span.spanId); + } else { + exportedTraces.push({ traceId: span.traceId, spanIds: [span.spanId], sentryAuthHeader }); + } + } + } + } + + res.json({}); +}); + +otlpReceiver.listen(otlpReceiverPort); + +const app = express(); +const tracer = trace.getTracer('node-express-otlp'); + +app.get('/test-error/:id', (req, res) => { + tracer.startActiveSpan('test-error-handler', span => { + const { traceId, spanId } = span.spanContext(); + + Sentry.captureException(new Error(`This is an exception with id ${req.params.id}`)); + span.end(); + + res.json({ traceId, spanId }); + }); +}); + +app.get('/otlp-exported-traces', (_req, res) => { + res.json(exportedTraces); +}); + +app.listen(appPort); diff --git a/dev-packages/e2e-tests/test-applications/node-express-otlp/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-otlp/start-event-proxy.mjs new file mode 100644 index 000000000000..8994db44efd2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-otlp', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts new file mode 100644 index 000000000000..725696408e4e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +interface ExportedTrace { + traceId: string; + spanIds: string[]; + sentryAuthHeader?: string; +} + +async function waitForExportedTrace(baseURL: string, traceId: string): Promise { + const deadline = Date.now() + 15_000; + + while (Date.now() < deadline) { + const response = await fetch(`${baseURL}/otlp-exported-traces`); + const exportedTraces = (await response.json()) as ExportedTrace[]; + + const match = exportedTraces.find(entry => entry.traceId === traceId); + if (match) { + return match; + } + + await new Promise(resolve => setTimeout(resolve, 200)); + } + + throw new Error(`Trace ${traceId} was never exported over OTLP`); +} + +test('attaches the active OpenTelemetry trace to Sentry errors', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-otlp', event => { + return event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + const response = await fetch(`${baseURL}/test-error/123`); + const { traceId, spanId } = (await response.json()) as { traceId: string; spanId: string }; + + const errorEvent = await errorEventPromise; + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: traceId, + span_id: spanId, + }); +}); + +test('exports spans over OTLP with the DSN-derived auth header', async ({ baseURL }) => { + const response = await fetch(`${baseURL}/test-error/456`); + const { traceId, spanId } = (await response.json()) as { traceId: string; spanId: string }; + + const exportedTrace = await waitForExportedTrace(baseURL as string, traceId); + + expect(exportedTrace.spanIds).toContain(spanId); + expect(exportedTrace.sentryAuthHeader).toMatch(/^Sentry sentry_version=7, sentry_key=\w+$/); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-otlp/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-otlp/tsconfig.json new file mode 100644 index 000000000000..2887ec11a81d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 8ea527a06dde..68d84431d09a 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -116,6 +116,8 @@ export { postgresJsIntegration, processSessionIntegration, prismaIntegration, + otlpIntegration, + getOtlpTracesEndpoint, childProcessIntegration, createSentryWinstonTransport, hapiIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 0545ec23749a..001d4f0481ef 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -135,6 +135,8 @@ export { postgresIntegration, postgresJsIntegration, prismaIntegration, + otlpIntegration, + getOtlpTracesEndpoint, processSessionIntegration, hapiIntegration, setupHapiErrorHandler, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 420fb2773c15..a7741e026c2e 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -122,6 +122,8 @@ export { fetchIntegration } from './integrations/fetch'; export { spotlightIntegration } from './integrations/spotlight'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { + otlpIntegration, + getOtlpTracesEndpoint, prismaIntegration, instrumentOpenAiClient, instrumentAnthropicAiClient, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 285016368e33..b97b96720802 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -143,6 +143,7 @@ export { tediousIntegration, vercelAiIntegration, } from '@sentry/server-utils/orchestrion'; +export { otlpIntegration, getOtlpTracesEndpoint } from '@sentry/server-utils/no-diagnostic-channels'; // Deprecated aliases kept for back-compat. Each forwards to the shared // integration above, so its name is the shared name (e.g. `Mysql`), not the old // `Deno*` name. See each alias's `@deprecated` note. diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index daf67c48c1e8..81e995db203f 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -115,6 +115,8 @@ export { postgresIntegration, postgresJsIntegration, prismaIntegration, + otlpIntegration, + getOtlpTracesEndpoint, processSessionIntegration, hapiIntegration, setupHapiErrorHandler, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index b541679d45d8..4dc13f241bc4 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -31,6 +31,8 @@ export { } from '@sentry/server-utils/orchestrion'; export { redisIntegration } from './integrations/tracing/redis'; export { + otlpIntegration, + getOtlpTracesEndpoint, prismaIntegration, instrumentOpenAiClient, instrumentAnthropicAiClient, diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index c327942b7bc6..7967e4146259 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -102,6 +102,7 @@ "access": "public" }, "dependencies": { + "@opentelemetry/api": "^1.9.1", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.67.0" }, diff --git a/packages/server-utils/src/exports.ts b/packages/server-utils/src/exports.ts index 5c60fa387a9c..9baca0c266f6 100644 --- a/packages/server-utils/src/exports.ts +++ b/packages/server-utils/src/exports.ts @@ -1,4 +1,5 @@ // Shared exports not using diagnostics channels export { setHttpServerSpanRouteAttribute } from './utils/setHttpServerSpanRouteAttribute'; export { setAsyncLocalStorageAsyncContextStrategy } from './async-context'; +export { otlpIntegration, getOtlpTracesEndpoint } from './otlp'; export * from './ai'; diff --git a/packages/server-utils/src/otlp.ts b/packages/server-utils/src/otlp.ts new file mode 100644 index 000000000000..e17f26ac6c4e --- /dev/null +++ b/packages/server-utils/src/otlp.ts @@ -0,0 +1,59 @@ +import { trace } from '@opentelemetry/api'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, dsnFromString, SENTRY_API_VERSION, registerExternalPropagationContext } from '@sentry/core'; + +const INTEGRATION_NAME = 'Otlp' as const; + +const _otlpIntegration = (() => { + return { + name: INTEGRATION_NAME, + + setup(): void { + registerExternalPropagationContext(() => { + const activeSpan = trace.getActiveSpan(); + if (!activeSpan) { + return undefined; + } + + const { traceId, spanId } = activeSpan.spanContext(); + return { traceId, spanId }; + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Connects Sentry to an existing OpenTelemetry setup. + * + * Errors and logs captured by Sentry are attached to the OpenTelemetry span that is active when they + * happen, so they show up on the same trace as the spans your OpenTelemetry SDK exports. Outgoing + * request propagation is left to your OpenTelemetry propagator. + * + * This does not export any spans. Configure your own span exporter and point it at Sentry using + * {@link getOtlpTracesEndpoint}. + */ +export const otlpIntegration = defineIntegration(_otlpIntegration); + +/** + * Builds the URL and auth headers for Sentry's OTLP traces endpoint, to configure an + * `OTLPTraceExporter` with. + * + * Returns `undefined` if the DSN cannot be parsed. + */ +export function getOtlpTracesEndpoint(dsn: string): { url: string; headers: Record } | undefined { + const parsedDsn = dsnFromString(dsn); + if (!parsedDsn) { + return undefined; + } + + const { protocol, host, port, path, projectId, publicKey } = parsedDsn; + const basePath = path ? `/${path}` : ''; + const portSuffix = port ? `:${port}` : ''; + + return { + url: `${protocol}://${host}${portSuffix}${basePath}/api/${projectId}/integration/otlp/v1/traces/`, + headers: { + 'X-Sentry-Auth': `Sentry sentry_version=${SENTRY_API_VERSION}, sentry_key=${publicKey}`, + }, + }; +} diff --git a/packages/server-utils/test/otlp.test.ts b/packages/server-utils/test/otlp.test.ts new file mode 100644 index 000000000000..904f9394781d --- /dev/null +++ b/packages/server-utils/test/otlp.test.ts @@ -0,0 +1,133 @@ +import type { Context, ContextManager } from '@opentelemetry/api'; +import { context, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + getCurrentScope, + getGlobalScope, + getIsolationScope, + registerExternalPropagationContext, + setCurrentClient, +} from '@sentry/core'; +import { getOtlpTracesEndpoint, otlpIntegration } from '../src/otlp'; +import { getDefaultTestClientOptions, TestClient } from './mocks/client'; + +const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +const OTEL_TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const OTEL_SPAN_ID = 'bbbbbbbbbbbbbbbb'; + +/** + * Synchronous context manager, so that `trace.getActiveSpan()` resolves inside `context.with()`. + * The OpenTelemetry API ships only a no-op manager; a real runtime installs one via its SDK. + */ +class SyncContextManager implements ContextManager { + private _activeContext: Context = ROOT_CONTEXT; + + public active(): Context { + return this._activeContext; + } + + public with ReturnType>( + activeContext: Context, + fn: F, + thisArg?: ThisParameterType, + ...args: A + ): ReturnType { + const previousContext = this._activeContext; + this._activeContext = activeContext; + try { + return fn.call(thisArg, ...args); + } finally { + this._activeContext = previousContext; + } + } + + public bind(_activeContext: Context, target: T): T { + return target; + } + + public enable(): this { + return this; + } + + public disable(): this { + this._activeContext = ROOT_CONTEXT; + return this; + } +} + +function withActiveOtelSpan(callback: () => T): T { + const otelSpan = trace.wrapSpanContext({ + traceId: OTEL_TRACE_ID, + spanId: OTEL_SPAN_ID, + traceFlags: TraceFlags.SAMPLED, + }); + + return context.with(trace.setSpan(context.active(), otelSpan), callback); +} + +function setupClientWithOtlpIntegration(): TestClient { + const client = new TestClient( + getDefaultTestClientOptions({ dsn: DSN, integrations: [otlpIntegration()], stackParser: () => [] }), + ); + setCurrentClient(client); + client.init(); + return client; +} + +describe('otlpIntegration', () => { + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + context.setGlobalContextManager(new SyncContextManager()); + }); + + afterEach(() => { + registerExternalPropagationContext(() => undefined); + context.disable(); + }); + + it('links captured errors to the active OpenTelemetry span', async () => { + const client = setupClientWithOtlpIntegration(); + + withActiveOtelSpan(() => { + client.captureException(new Error('boom')); + }); + await client.flush(); + + expect(client.event?.contexts?.trace).toEqual({ + trace_id: OTEL_TRACE_ID, + span_id: OTEL_SPAN_ID, + }); + }); + + it('falls back to the Sentry propagation context when no OpenTelemetry span is active', async () => { + const client = setupClientWithOtlpIntegration(); + getCurrentScope().setPropagationContext({ traceId: 'cccccccccccccccccccccccccccccccc', sampleRand: 0.5 }); + + client.captureException(new Error('boom')); + await client.flush(); + + expect(client.event?.contexts?.trace?.trace_id).toBe('cccccccccccccccccccccccccccccccc'); + }); +}); + +describe('getOtlpTracesEndpoint', () => { + it('builds the traces URL and auth header from a DSN', () => { + expect(getOtlpTracesEndpoint(DSN)).toEqual({ + url: 'https://dsn.ingest.sentry.io/api/1337/integration/otlp/v1/traces/', + headers: { 'X-Sentry-Auth': 'Sentry sentry_version=7, sentry_key=public' }, + }); + }); + + it('preserves port and path from a self-hosted DSN', () => { + expect(getOtlpTracesEndpoint('http://public@localhost:9000/sentry/42')?.url).toBe( + 'http://localhost:9000/sentry/api/42/integration/otlp/v1/traces/', + ); + }); + + it('returns undefined for an unparseable DSN', () => { + expect(getOtlpTracesEndpoint('not-a-dsn')).toBeUndefined(); + }); +}); diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 17a35332bc7b..d949ae2c5d77 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -103,6 +103,8 @@ export { spanStreamingIntegration, } from '@sentry/core'; export { + otlpIntegration, + getOtlpTracesEndpoint, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, From 044c37eb6a3b794dd98074dd19e9842f2993a0f2 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 6 Aug 2026 15:31:27 +0200 Subject: [PATCH 02/10] Cover logs and metrics in the OTLP e2e app Logs, metrics and check-ins already flow through the same trace context as errors. Assert it end-to-end and say so in the integration's docs. --- .../node-express-otlp/src/app.ts | 10 +++-- .../node-express-otlp/tests/otlp.test.ts | 43 ++++++++++++++++--- packages/server-utils/src/otlp.ts | 10 +++-- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-express-otlp/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-otlp/src/app.ts index 18e92617e065..f4e0fc622407 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-otlp/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/src/app.ts @@ -73,11 +73,15 @@ otlpReceiver.listen(otlpReceiverPort); const app = express(); const tracer = trace.getTracer('node-express-otlp'); -app.get('/test-error/:id', (req, res) => { - tracer.startActiveSpan('test-error-handler', span => { +app.get('/test-telemetry/:id', (req, res) => { + tracer.startActiveSpan('test-telemetry-handler', span => { const { traceId, spanId } = span.spanContext(); + const { id } = req.params; + + Sentry.logger.info(`This is a log with id ${id}`); + Sentry.metrics.count('otlp.test.count', 1, { attributes: { id } }); + Sentry.captureException(new Error(`This is an exception with id ${id}`)); - Sentry.captureException(new Error(`This is an exception with id ${req.params.id}`)); span.end(); res.json({ traceId, spanId }); diff --git a/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts index 725696408e4e..00180227c9f7 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; +import { waitForEnvelopeItem, waitForError, waitForMetric } from '@sentry-internal/test-utils'; +import type { SerializedLogContainer } from '@sentry/core'; interface ExportedTrace { traceId: string; @@ -25,14 +26,17 @@ async function waitForExportedTrace(baseURL: string, traceId: string): Promise { +async function triggerTelemetry(baseURL: string, id: string): Promise<{ traceId: string; spanId: string }> { + const response = await fetch(`${baseURL}/test-telemetry/${id}`); + return (await response.json()) as { traceId: string; spanId: string }; +} + +test('attaches the active OpenTelemetry trace to errors', async ({ baseURL }) => { const errorEventPromise = waitForError('node-express-otlp', event => { return event.exception?.values?.[0]?.value === 'This is an exception with id 123'; }); - const response = await fetch(`${baseURL}/test-error/123`); - const { traceId, spanId } = (await response.json()) as { traceId: string; spanId: string }; - + const { traceId, spanId } = await triggerTelemetry(baseURL as string, '123'); const errorEvent = await errorEventPromise; expect(errorEvent.contexts?.trace).toEqual({ @@ -41,9 +45,34 @@ test('attaches the active OpenTelemetry trace to Sentry errors', async ({ baseUR }); }); +test('attaches the active OpenTelemetry trace to logs', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('node-express-otlp', envelope => { + return ( + envelope[0].type === 'log' && + (envelope[1] as SerializedLogContainer).items.some(item => item.body === 'This is a log with id 234') + ); + }); + + const { traceId } = await triggerTelemetry(baseURL as string, '234'); + const logEnvelope = await logEnvelopePromise; + + const log = (logEnvelope[1] as SerializedLogContainer).items.find(item => item.body === 'This is a log with id 234'); + expect(log?.trace_id).toBe(traceId); +}); + +test('attaches the active OpenTelemetry trace to metrics', async ({ baseURL }) => { + const metricPromise = waitForMetric('node-express-otlp', metric => { + return metric.name === 'otlp.test.count' && metric.attributes?.id?.value === '345'; + }); + + const { traceId } = await triggerTelemetry(baseURL as string, '345'); + const metric = await metricPromise; + + expect(metric.trace_id).toBe(traceId); +}); + test('exports spans over OTLP with the DSN-derived auth header', async ({ baseURL }) => { - const response = await fetch(`${baseURL}/test-error/456`); - const { traceId, spanId } = (await response.json()) as { traceId: string; spanId: string }; + const { traceId, spanId } = await triggerTelemetry(baseURL as string, '456'); const exportedTrace = await waitForExportedTrace(baseURL as string, traceId); diff --git a/packages/server-utils/src/otlp.ts b/packages/server-utils/src/otlp.ts index e17f26ac6c4e..336927ba4c38 100644 --- a/packages/server-utils/src/otlp.ts +++ b/packages/server-utils/src/otlp.ts @@ -25,9 +25,13 @@ const _otlpIntegration = (() => { /** * Connects Sentry to an existing OpenTelemetry setup. * - * Errors and logs captured by Sentry are attached to the OpenTelemetry span that is active when they - * happen, so they show up on the same trace as the spans your OpenTelemetry SDK exports. Outgoing - * request propagation is left to your OpenTelemetry propagator. + * Everything Sentry sends that carries trace information (errors, logs, metrics and check-ins) is + * attached to the OpenTelemetry span that is active when it happens, so it shows up on the same + * trace as the spans your OpenTelemetry SDK exports. Outgoing request propagation is left to your + * OpenTelemetry propagator. + * + * An active Sentry span still takes precedence, so this only changes what happens when Sentry has no + * span of its own, which is the usual setup when OpenTelemetry owns tracing. * * This does not export any spans. Configure your own span exporter and point it at Sentry using * {@link getOtlpTracesEndpoint}. From c116e785a45554a9becb5cc6e34b4f9f4416c16c Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 6 Aug 2026 15:54:50 +0200 Subject: [PATCH 03/10] Ignore active OpenTelemetry spans with an invalid span context OpenTelemetry returns a span wrapping INVALID_SPAN_CONTEXT when tracing is suppressed or when a span is started before a tracer provider is registered. Its all-zero ids were being stamped onto everything Sentry sends, breaking trace linkage instead of falling back to the Sentry scope. --- packages/server-utils/src/otlp.ts | 12 ++++++++++-- packages/server-utils/test/otlp.test.ts | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/server-utils/src/otlp.ts b/packages/server-utils/src/otlp.ts index 336927ba4c38..00332ac12f3c 100644 --- a/packages/server-utils/src/otlp.ts +++ b/packages/server-utils/src/otlp.ts @@ -1,4 +1,4 @@ -import { trace } from '@opentelemetry/api'; +import { isSpanContextValid, trace } from '@opentelemetry/api'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, dsnFromString, SENTRY_API_VERSION, registerExternalPropagationContext } from '@sentry/core'; @@ -15,7 +15,15 @@ const _otlpIntegration = (() => { return undefined; } - const { traceId, spanId } = activeSpan.spanContext(); + // OpenTelemetry hands out a span wrapping `INVALID_SPAN_CONTEXT` when tracing is suppressed, + // or when a span is started before a tracer provider is registered. Its ids are all zeroes, + // so fall back to the Sentry scope rather than stamping that onto everything we send. + const spanContext = activeSpan.spanContext(); + if (!isSpanContextValid(spanContext)) { + return undefined; + } + + const { traceId, spanId } = spanContext; return { traceId, spanId }; }); }, diff --git a/packages/server-utils/test/otlp.test.ts b/packages/server-utils/test/otlp.test.ts index 904f9394781d..f719f92d157f 100644 --- a/packages/server-utils/test/otlp.test.ts +++ b/packages/server-utils/test/otlp.test.ts @@ -1,5 +1,5 @@ import type { Context, ContextManager } from '@opentelemetry/api'; -import { context, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'; +import { context, INVALID_SPAN_CONTEXT, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getCurrentScope, @@ -102,6 +102,20 @@ describe('otlpIntegration', () => { }); }); + it('ignores an active span with an invalid span context', async () => { + const client = setupClientWithOtlpIntegration(); + getCurrentScope().setPropagationContext({ traceId: 'cccccccccccccccccccccccccccccccc', sampleRand: 0.5 }); + + // OpenTelemetry hands out a span wrapping `INVALID_SPAN_CONTEXT` when tracing is suppressed, or + // when a span is started before a tracer provider is registered. + context.with(trace.setSpan(context.active(), trace.wrapSpanContext(INVALID_SPAN_CONTEXT)), () => { + client.captureException(new Error('boom')); + }); + await client.flush(); + + expect(client.event?.contexts?.trace?.trace_id).toBe('cccccccccccccccccccccccccccccccc'); + }); + it('falls back to the Sentry propagation context when no OpenTelemetry span is active', async () => { const client = setupClientWithOtlpIntegration(); getCurrentScope().setPropagationContext({ traceId: 'cccccccccccccccccccccccccccccccc', sampleRand: 0.5 }); From 6cf656fcc12f33ac319fb0b98e55de891d0bcd5b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 6 Aug 2026 16:07:27 +0200 Subject: [PATCH 04/10] Export otlpIntegration from @sentry/astro Astro's server entry cannot `export * from '@sentry/node'` (Vite moves the exports onto `default` in prod builds), so it enumerates them. The node-exports-test-app E2E check caught the gap. --- packages/astro/src/index.server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 76497914711e..2054a3b318c6 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -98,6 +98,8 @@ export { postgresIntegration, postgresJsIntegration, prismaIntegration, + otlpIntegration, + getOtlpTracesEndpoint, processSessionIntegration, childProcessIntegration, createSentryWinstonTransport, From fe62ac5e148391401ed34be6c19ecf624029ab1a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 6 Aug 2026 16:29:18 +0200 Subject: [PATCH 05/10] Add a usage example to getOtlpTracesEndpoint --- packages/server-utils/src/otlp.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/server-utils/src/otlp.ts b/packages/server-utils/src/otlp.ts index 00332ac12f3c..e0d925788dd7 100644 --- a/packages/server-utils/src/otlp.ts +++ b/packages/server-utils/src/otlp.ts @@ -51,6 +51,27 @@ export const otlpIntegration = defineIntegration(_otlpIntegration); * `OTLPTraceExporter` with. * * Returns `undefined` if the DSN cannot be parsed. + * + * @example + * + * ```javascript + * import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; + * import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; + * import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; + * + * const provider = new NodeTracerProvider({ + * spanProcessors: [ + * new BatchSpanProcessor(new OTLPTraceExporter(Sentry.getOtlpTracesEndpoint('__DSN__'))), + * ], + * }); + * + * provider.register(); + * + * Sentry.init({ + * dsn: '__DSN__', + * integrations: [Sentry.otlpIntegration()], + * }); + * ``` */ export function getOtlpTracesEndpoint(dsn: string): { url: string; headers: Record } | undefined { const parsedDsn = dsnFromString(dsn); From 72c49f1de58a5ec91c07a51a6ea8e8a80ad8d334 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 7 Aug 2026 09:22:53 +0200 Subject: [PATCH 06/10] Document otlpIntegration in the v11 migration guide Fills the TODO left in the OpenTelemetry interoperability section, and covers the migration for users of the v10 `@sentry/node-core/light/otlp` integration, whose options and built-in exporter setup are gone. --- MIGRATION.md | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index cfaa4fa824c0..4a63b4a5efdc 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -76,7 +76,7 @@ Affected SDKs: Server-side SDKs (`@sentry/node` and all dependents). By default, v11 no longer sets up an OpenTelemetry tracer provider for **most** SDKs. SDKs now own the full span lifecycle, producing native Sentry spans. -A new optional OpenTelemetry integration lets you connect Sentry events such as Errors, Logs, Crons and Metrics to your OpenTelemetry traces, if you need to. +A new optional OpenTelemetry integration lets you connect Sentry events such as Errors, Logs, Crons and Metrics to your OpenTelemetry traces, if you need to. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces). Only `@sentry/nextjs` and `@sentry/sveltekit` still set up an OpenTelemetry compatible light tracer provider to capture spans the underlying frameworks emit. @@ -111,8 +111,47 @@ With this, we also heavily reduced our OpenTelemetry dependencies, with `@opente For most users, day-to-day tracing is **unchanged**. -> **TODO(v11):** Document the new optional OpenTelemetry integration once its final name and signature -> are locked in — add the `Sentry.init` example. +#### Connecting Sentry to your OpenTelemetry traces + +`Sentry.otlpIntegration()` attaches everything Sentry sends that carries trace information (errors, logs, metrics and crons) to the OpenTelemetry span that is active when it happens. It takes no options, and is available from every server-side SDK, so there is nothing extra to install or import. + +It does not set up a span exporter, span processor, or tracer provider. You keep full ownership of your OpenTelemetry pipeline, and outgoing request propagation is left to your OpenTelemetry propagator. To send your spans to Sentry, point your own exporter at the URL and auth headers that `Sentry.getOtlpTracesEndpoint()` derives from your DSN: + +```js +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import * as Sentry from '@sentry/node'; + +const provider = new NodeTracerProvider({ + spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter(Sentry.getOtlpTracesEndpoint('__DSN__')))], +}); + +provider.register(); + +Sentry.init({ + dsn: '__DSN__', + integrations: [Sentry.otlpIntegration()], +}); +``` + +An active Sentry span still takes precedence, so this only changes what happens when Sentry has no span of its own, which is the usual setup when OpenTelemetry owns tracing. + +If you used the v10 integration from `@sentry/node-core/light/otlp`, three things changed. It moved to the main export of every server SDK, it no longer sets up an exporter for you, and its options are gone: `setupOtlpTracesExporter` and `collectorUrl` were removed, along with the optional `@opentelemetry/exporter-trace-otlp-http` peer dependency. Configure your own exporter as shown above, pointing it at your collector's URL if you route through one. The integration name also changed from `OtlpIntegration` to `Otlp`, which matters only if you reference it by name. + +```js +// before +import * as Sentry from '@sentry/node-core/light'; +import { otlpIntegration } from '@sentry/node-core/light/otlp'; + +Sentry.init({ dsn: '__DSN__', integrations: [otlpIntegration()] }); + +// after +import * as Sentry from '@sentry/node'; + +// set up your own tracer provider and exporter, then: +Sentry.init({ dsn: '__DSN__', integrations: [Sentry.otlpIntegration()] }); +``` > **TODO(v11):** Link to the upcoming guide covering common use cases with the new OpenTelemetry setup > (running your own OpenTelemetry setup alongside Sentry, connecting Sentry events to OTel traces, etc.). From 9c45d531c6032cf851064745d5627093c58c5d27 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 7 Aug 2026 09:29:49 +0200 Subject: [PATCH 07/10] Add the otlpIntegration breaking changes to the migration guide Adds entries under Removed APIs for the dropped `@sentry/node-core/light/otlp` entry point and the removed `setupOtlpTracesExporter` / `collectorUrl` options, and under Renames for the integration name change. The narrative section now links to both instead of repeating them. --- MIGRATION.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index 4a63b4a5efdc..e05f61044502 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -137,7 +137,7 @@ Sentry.init({ An active Sentry span still takes precedence, so this only changes what happens when Sentry has no span of its own, which is the usual setup when OpenTelemetry owns tracing. -If you used the v10 integration from `@sentry/node-core/light/otlp`, three things changed. It moved to the main export of every server SDK, it no longer sets up an exporter for you, and its options are gone: `setupOtlpTracesExporter` and `collectorUrl` were removed, along with the optional `@opentelemetry/exporter-trace-otlp-http` peer dependency. Configure your own exporter as shown above, pointing it at your collector's URL if you route through one. The integration name also changed from `OtlpIntegration` to `Otlp`, which matters only if you reference it by name. +If you used the v10 integration from `@sentry/node-core/light/otlp`, three things changed: it moved to the main export of every server SDK, it [no longer sets up an exporter for you and lost its options](#3-removed-apis), and it [reports itself as `Otlp` rather than `OtlpIntegration`](#otlpintegration-integration-renamed-to-otlp). Configure your own exporter as shown above, pointing it at your collector's URL if you route through one. ```js // before @@ -664,6 +664,8 @@ Sentry.init({ - (AWS Lambda) The deprecated `startTrace` option was removed. It no longer had any effect; to disable tracing, set `tracesSampleRate` to `0`. - (AWS Lambda) The deprecated `tryPatchHandler` function was removed. It was no longer used. - (Express) The deprecated `patchExpressModule(options)` signature was removed. Use `patchExpressModule(moduleExports, getOptions)` instead. +- The `@sentry/node-core/light/otlp` entry point was removed, along with its optional `@opentelemetry/exporter-trace-otlp-http` peer dependency. `otlpIntegration` is now exported directly from every server-side SDK, so `Sentry.otlpIntegration()` needs no extra import or install. +- The `otlpIntegration` options `setupOtlpTracesExporter` and `collectorUrl` were removed, and the integration no longer sets up a span exporter, span processor, or tracer provider. Configure your own exporter and point it at `Sentry.getOtlpTracesEndpoint(dsn)`, or at your collector's URL if you route through one. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces). ### `@sentry/cloudflare` @@ -946,6 +948,26 @@ Several default integrations were renamed to match the names used by the other S - `DenoMysql` => `Mysql` - `DenoPostgres` => `Postgres` +### `OtlpIntegration` integration renamed to `Otlp` + +Affected SDKs: Server-side SDKs (`@sentry/node` and all dependents). + +The OTLP integration reports itself as `Otlp` rather than `OtlpIntegration`, matching every other integration in the SDKs, none of which carry an `Integration` suffix in their name. The `otlpIntegration()` export itself is unchanged. This only matters if you reference the integration by name: + +```js +// before +Sentry.init({ + integrations: integrations => integrations.filter(integration => integration.name !== 'OtlpIntegration'), +}); + +// after +Sentry.init({ + integrations: integrations => integrations.filter(integration => integration.name !== 'Otlp'), +}); +``` + +The same applies when looking the integration up by name, e.g. via `client.getIntegrationByName('OtlpIntegration')`. + ## 6. Type Changes - Several public types that used `any` now use `unknown` — including `StackFrame`, `SamplingContext`, From 5f3b9027c12f927af3755b4a1f6b1eadddac7520 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 7 Aug 2026 10:48:51 +0200 Subject: [PATCH 08/10] Send no envelope trace header while riding an OpenTelemetry span With an external propagation context active, events are stamped with the OpenTelemetry trace id while the DSC was still built from the Sentry scope, so every envelope header named a trace that appeared nowhere else. There is no transaction semantic to describe the OpenTelemetry trace with, so send no sampling context at all, matching sentry-python. --- .../node-express-otlp/tests/otlp.test.ts | 21 ++++++++- .../src/tracing/dynamicSamplingContext.ts | 15 ++++++- packages/server-utils/test/otlp.test.ts | 45 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts index 00180227c9f7..fdcf53d79e9f 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForEnvelopeItem, waitForError, waitForMetric } from '@sentry-internal/test-utils'; +import { waitForEnvelopeItem, waitForError, waitForMetric, waitForRequest } from '@sentry-internal/test-utils'; import type { SerializedLogContainer } from '@sentry/core'; interface ExportedTrace { @@ -45,6 +45,25 @@ test('attaches the active OpenTelemetry trace to errors', async ({ baseURL }) => }); }); +test('sends no envelope trace header while riding along on an OpenTelemetry span', async ({ baseURL }) => { + const envelopePromise = waitForRequest('node-express-otlp', ({ envelope }) => { + const [, items] = envelope; + return items.some( + item => + (item[1] as { exception?: { values?: { value?: string }[] } })?.exception?.values?.[0]?.value === + 'This is an exception with id 567', + ); + }); + + await triggerTelemetry(baseURL as string, '567'); + const { envelope } = await envelopePromise; + const [envelopeHeaders] = envelope; + + // The Sentry scope's sampling context describes a different trace than the OpenTelemetry one the + // event is stamped with, so no `trace` header is sent rather than one naming the wrong trace. + expect((envelopeHeaders as { trace?: unknown }).trace).toBeUndefined(); +}); + test('attaches the active OpenTelemetry trace to logs', async ({ baseURL }) => { const logEnvelopePromise = waitForEnvelopeItem('node-express-otlp', envelope => { return ( diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 8d428ddaa1d4..97814254bbd7 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -1,6 +1,6 @@ import type { Client } from '../client'; import { DEFAULT_ENVIRONMENT } from '../constants'; -import { getClient } from '../currentScopes'; +import { getClient, getExternalPropagationContext } from '../currentScopes'; import type { Scope } from '../scope'; import { SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE, @@ -63,7 +63,18 @@ export function getDynamicSamplingContextFromClient(trace_id: string, client: Cl /** * Get the dynamic sampling context for the currently active scopes. */ -export function getDynamicSamplingContextFromScope(client: Client, scope: Scope): Partial { +export function getDynamicSamplingContextFromScope( + client: Client, + scope: Scope, +): Partial | undefined { + // While an external propagation context is active (e.g. the OTLP integration riding along on an + // OpenTelemetry span), the SDK is not the head of the trace and has no transaction semantics to + // describe it with, so there is no sampling context to send. The scope's own DSC would name a + // different trace than the one stamped on the event, so send none at all. Matches sentry-python. + if (getExternalPropagationContext()) { + return undefined; + } + const propagationContext = scope.getPropagationContext(); return propagationContext.dsc || getDynamicSamplingContextFromClient(propagationContext.traceId, client); } diff --git a/packages/server-utils/test/otlp.test.ts b/packages/server-utils/test/otlp.test.ts index f719f92d157f..616919363d59 100644 --- a/packages/server-utils/test/otlp.test.ts +++ b/packages/server-utils/test/otlp.test.ts @@ -1,6 +1,7 @@ import type { Context, ContextManager } from '@opentelemetry/api'; import { context, INVALID_SPAN_CONTEXT, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Envelope } from '@sentry/core'; import { getCurrentScope, getGlobalScope, @@ -75,6 +76,23 @@ function setupClientWithOtlpIntegration(): TestClient { return client; } +/** Captures the envelopes the client actually sends, so their headers can be asserted on. */ +function setupClientCapturingEnvelopes(): { client: TestClient; envelopes: Envelope[] } { + const envelopes: Envelope[] = []; + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: DSN, + integrations: [otlpIntegration()], + stackParser: () => [], + enableSend: true, + }), + ); + client.on('beforeEnvelope', envelope => envelopes.push(envelope)); + setCurrentClient(client); + client.init(); + return { client, envelopes }; +} + describe('otlpIntegration', () => { beforeEach(() => { getCurrentScope().clear(); @@ -102,6 +120,33 @@ describe('otlpIntegration', () => { }); }); + it('sends no envelope trace header while riding along on an OpenTelemetry span', async () => { + const { client, envelopes } = setupClientCapturingEnvelopes(); + getCurrentScope().setPropagationContext({ traceId: 'cccccccccccccccccccccccccccccccc', sampleRand: 0.5 }); + + withActiveOtelSpan(() => { + client.captureException(new Error('boom')); + }); + await client.flush(); + + // The scope's DSC would name a different trace than the event, and we have no transaction + // semantics to describe the OpenTelemetry one with, so no sampling context is sent at all. + const [envelopeHeaders] = envelopes[0] ?? []; + expect(envelopeHeaders).toBeDefined(); + expect(envelopeHeaders?.trace).toBeUndefined(); + }); + + it('still sends an envelope trace header when no OpenTelemetry span is active', async () => { + const { client, envelopes } = setupClientCapturingEnvelopes(); + getCurrentScope().setPropagationContext({ traceId: 'cccccccccccccccccccccccccccccccc', sampleRand: 0.5 }); + + client.captureException(new Error('boom')); + await client.flush(); + + const [envelopeHeaders] = envelopes[0] ?? []; + expect(envelopeHeaders?.trace).toMatchObject({ trace_id: 'cccccccccccccccccccccccccccccccc' }); + }); + it('ignores an active span with an invalid span context', async () => { const client = setupClientWithOtlpIntegration(); getCurrentScope().setPropagationContext({ traceId: 'cccccccccccccccccccccccccccccccc', sampleRand: 0.5 }); From b139907add6f0e6aeeb9c0b18eec564e7bcc210d Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 7 Aug 2026 11:11:21 +0200 Subject: [PATCH 09/10] Derive the DSC from the span when a Sentry span is active The TwP placeholder path spread the scope DSC, which is now undefined while an external propagation context is active, so it collapsed to an empty object and emitted a `trace: {}` envelope header. A Sentry span means we are head of its trace, so fall through and derive the DSC from the span. --- .../src/tracing/dynamicSamplingContext.ts | 8 ++++-- .../tracing/dynamicSamplingContext.test.ts | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 97814254bbd7..a5781bbf6c0c 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -131,8 +131,12 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly { transaction: 'tx', }); }); + + it('derives the DSC from the span when an external propagation context is active', () => { + const options = getDefaultTestClientOptions({ tracesSampleRate: undefined, release: '1.0.1' }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // The scope yields no DSC while riding an external (e.g. OpenTelemetry) trace, but a Sentry span + // means we are head of its trace, so the DSC comes from the span rather than being left empty. + registerExternalPropagationContext(() => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb', + })); + + try { + const rootSpan = new SentryNonRecordingSpan({ traceId: 'cccccccccccccccccccccccccccccccc' }); + setCapturedScopesOnSpan(rootSpan, new Scope(), new Scope()); + + expect(getDynamicSamplingContextFromSpan(rootSpan)).toMatchObject({ + trace_id: 'cccccccccccccccccccccccccccccccc', + }); + } finally { + registerExternalPropagationContext(() => undefined); + } + }); }); describe('getDynamicSamplingContextFromClient', () => { From 7aa3021356e60bb05861ab90687bd3ea17aef3dc Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 7 Aug 2026 12:50:01 +0200 Subject: [PATCH 10/10] Reword the external propagation context DSC comment --- packages/core/src/tracing/dynamicSamplingContext.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index a5781bbf6c0c..f2a8159fbd57 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -68,9 +68,9 @@ export function getDynamicSamplingContextFromScope( scope: Scope, ): Partial | undefined { // While an external propagation context is active (e.g. the OTLP integration riding along on an - // OpenTelemetry span), the SDK is not the head of the trace and has no transaction semantics to - // describe it with, so there is no sampling context to send. The scope's own DSC would name a - // different trace than the one stamped on the event, so send none at all. Matches sentry-python. + // OpenTelemetry span), the SDK lacks most of the DSC information, like sampled, sample_rate, + // sample_rand and transaction. The scope's own DSC would also name a different trace than the one + // stamped on the event, so send none at all. Matches sentry-python. if (getExternalPropagationContext()) { return undefined; }