From 964704724ee6bc33f36934df8eceb9dc700f00ac Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 1 Sep 2026 16:22:59 +0200 Subject: [PATCH 1/5] test(e2e): Add bring-your-own-OpenTelemetry test app Co-Authored-By: Claude Opus 5 --- .../node-otel-sdk-node/package.json | 32 +++++++++++ .../node-otel-sdk-node/playwright.config.mjs | 34 ++++++++++++ .../node-otel-sdk-node/src/app.ts | 42 +++++++++++++++ .../node-otel-sdk-node/src/instrument.ts | 26 +++++++++ .../node-otel-sdk-node/start-event-proxy.mjs | 6 +++ .../node-otel-sdk-node/start-otel-proxy.mjs | 6 +++ .../node-otel-sdk-node/tests/errors.test.ts | 21 ++++++++ .../tests/opentelemetry.test.ts | 54 +++++++++++++++++++ .../tests/transactions.test.ts | 46 ++++++++++++++++ .../node-otel-sdk-node/tsconfig.json | 11 ++++ 10 files changed, 278 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json new file mode 100644 index 000000000000..9bc50dd938f6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json @@ -0,0 +1,32 @@ +{ + "name": "node-otel-sdk-node", + "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/instrumentation-http": "^0.220.0", + "@opentelemetry/sdk-node": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^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-otel-sdk-node/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/playwright.config.mjs new file mode 100644 index 000000000000..888e61cfb2dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/playwright.config.mjs @@ -0,0 +1,34 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm start`, + }, + { + webServer: [ + { + command: `node ./start-event-proxy.mjs`, + port: 3031, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: `node ./start-otel-proxy.mjs`, + port: 3032, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm start', + port: 3030, + stdout: 'pipe', + stderr: 'pipe', + env: { + PORT: 3030, + }, + }, + ], + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts new file mode 100644 index 000000000000..c5ed26f03606 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts @@ -0,0 +1,42 @@ +import './instrument'; + +// Other imports below +import { trace } from '@opentelemetry/api'; +import * as Sentry from '@sentry/node'; +import express from 'express'; +import * as http from 'http'; + +const app = express(); +const port = 3030; +const tracer = trace.getTracer('node-otel-sdk-node'); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-transaction', function (_req, res) { + Sentry.startSpan({ name: 'sentry-span' }, () => undefined); + tracer.startActiveSpan('otel-span', span => span.end()); + + res.send({ status: 'ok' }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.get('/test-outgoing', function (_req, res) { + http.get(`http://localhost:${port}/echo-headers`, response => { + let body = ''; + response.on('data', chunk => (body += chunk)); + response.on('end', () => res.type('json').send(body)); + }); +}); + +app.get('/echo-headers', function (req, res) { + res.send(req.headers); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts new file mode 100644 index 000000000000..c850577bf9f6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts @@ -0,0 +1,26 @@ +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import * as Sentry from '@sentry/node'; + +// The user owns OpenTelemetry here: their own SDK, their own instrumentation and their own +// exporter. Sentry runs alongside it and must neither register a competing tracer provider nor +// route its own spans through this pipeline. +const sdk = new NodeSDK({ + instrumentations: [new HttpInstrumentation()], + spanProcessors: [ + new BatchSpanProcessor(new OTLPTraceExporter({ url: 'http://localhost:3032/' }), { scheduledDelayMillis: 100 }), + ], +}); + +sdk.start(); + +Sentry.init({ + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs new file mode 100644 index 000000000000..b97bfc4664dd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-otel-sdk-node', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs new file mode 100644 index 000000000000..c24241310fbb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-otel-sdk-node-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts new file mode 100644 index 000000000000..f6ff65266c16 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('captures an exception and puts it on the Sentry trace', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-otel-sdk-node', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'This is an exception with id 456'; + }); + + const transactionEventPromise = waitForTransaction('node-otel-sdk-node', transactionEvent => { + return transactionEvent.transaction === 'GET /test-exception/:id'; + }); + + await fetch(`${baseURL}/test-exception/456`); + + const errorEvent = await errorEventPromise; + const transactionEvent = await transactionEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.transaction).toBe('GET /test-exception/:id'); + expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts new file mode 100644 index 000000000000..32b7d4349974 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; +import { waitForPlainRequest } from '@sentry-internal/test-utils'; + +interface OtlpSpan { + name: string; + traceId: string; +} + +/** + * The proxy hands back the payloads it received as newline separated JSON, so a single read can + * hold more than one OTLP export. + */ +function getExportedSpans(data: string): OtlpSpan[] { + return data + .split('\n') + .filter(Boolean) + .flatMap( + line => (JSON.parse(line) as { resourceSpans?: { scopeSpans?: { spans?: OtlpSpan[] }[] }[] }).resourceSpans ?? [], + ) + .flatMap(resourceSpan => resourceSpan.scopeSpans ?? []) + .flatMap(scopeSpan => scopeSpan.spans ?? []); +} + +test('exports the spans of the user OpenTelemetry setup to their own collector', async ({ baseURL }) => { + // The user's http span only ends once the response is out, so wait until both it and the span + // started inside the handler have been exported. + const otelExportPromise = waitForPlainRequest('node-otel-sdk-node-otel', data => { + const spans = getExportedSpans(data); + const otelSpan = spans.find(span => span.name === 'otel-span'); + return !!otelSpan && spans.some(span => span.name === 'GET' && span.traceId === otelSpan.traceId); + }); + + await fetch(`${baseURL}/test-transaction`); + + const exportedSpans = getExportedSpans(await otelExportPromise); + + const otelSpan = exportedSpans.find(span => span.name === 'otel-span'); + expect(otelSpan).toBeDefined(); + + // The user's own http instrumentation keeps working and stays on the same trace as their spans. + expect(exportedSpans).toContainEqual(expect.objectContaining({ name: 'GET', traceId: otelSpan?.traceId })); + + // Sentry does not feed its spans into the user's pipeline. + expect(exportedSpans.map(span => span.name)).not.toContain('sentry-span'); +}); + +test('propagates both the Sentry and the OpenTelemetry trace on outgoing requests', async ({ baseURL }) => { + const response = await fetch(`${baseURL}/test-outgoing`); + const headers = (await response.json()) as Record; + + expect(headers['sentry-trace']).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); + expect(headers['baggage']).toContain('sentry-trace_id='); + expect(headers['traceparent']).toMatch(/^00-[a-f0-9]{32}-[a-f0-9]{16}-\d{2}$/); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts new file mode 100644 index 000000000000..289d28d5f94d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends an express transaction from its own instrumentation', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-otel-sdk-node', transactionEvent => { + return transactionEvent.transaction === 'GET /test-transaction'; + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.http_server', + status: 'ok', + data: expect.objectContaining({ + 'http.route': '/test-transaction', + 'sentry.segment.name.source': 'route', + }), + }), + ); + + expect(transactionEvent.transaction_info).toEqual({ source: 'route' }); + + const spanDescriptions = (transactionEvent.spans || []).map(span => span.description); + + expect(spanDescriptions).toContain('sentry-span'); + // The user's OpenTelemetry spans belong to their pipeline, so they must not end up in Sentry. + expect(spanDescriptions).not.toContain('otel-span'); +}); + +test('parameterizes express routes', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-otel-sdk-node', transactionEvent => { + return transactionEvent.transaction === 'GET /test-param/:param'; + }); + + await fetch(`${baseURL}/test-param/123`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ 'http.route': '/test-param/:param' }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json new file mode 100644 index 000000000000..2887ec11a81d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} From 781c9ef872a167e06e74b89c181d27528d164e21 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 11:23:42 +0200 Subject: [PATCH 2/5] Test the OpenTelemetry-owns-tracing setup over ESM and align the migration guide --- MIGRATION.md | 27 +++-------- .../node-otel-sdk-node/package.json | 14 ++---- .../src/{app.ts => app.mjs} | 27 ++++++----- .../node-otel-sdk-node/src/instrument.mjs | 8 ++++ .../src/{instrument.ts => telemetry.mjs} | 12 ++--- .../node-otel-sdk-node/tests/errors.test.ts | 30 ++++++++---- .../tests/opentelemetry.test.ts | 45 ++++++++++++------ .../tests/transactions.test.ts | 46 ------------------- .../node-otel-sdk-node/tsconfig.json | 11 ----- 9 files changed, 91 insertions(+), 129 deletions(-) rename dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/{app.ts => app.mjs} (56%) create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.mjs rename dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/{instrument.ts => telemetry.mjs} (62%) delete mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json diff --git a/MIGRATION.md b/MIGRATION.md index 84e02afcde8a..5ccdb3b75b15 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -97,7 +97,7 @@ Sentry.init({ }); ``` -If a library you depend on emits its own OpenTelemetry spans and you want those in Sentry too, use setup 2. +Sentry owns spans end to end and there is no OpenTelemetry involved: spans created through `@opentelemetry/api` are ignored. If a library you depend on emits its own OpenTelemetry spans and you want those in Sentry too, use setup 2. ##### 2. OpenTelemetry-compatible mode, everything goes to Sentry @@ -119,7 +119,7 @@ Spans go to Sentry. This is not a general OpenTelemetry pipeline: there is no ex ##### 3. Your own OpenTelemetry, Sentry linked to it -Leave `enableOpenTelemetrySetup` unset or set it to `false`, turn Sentry tracing off, use your own OpenTelemetry setup, and add the Sentry `otlpIntegration()`: +Turn Sentry tracing off, run your own OpenTelemetry setup, and add the Sentry `otlpIntegration()`. Leave `enableOpenTelemetrySetup` unset or set it to `false`: ```js import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; @@ -142,30 +142,15 @@ Sentry.init({ `enableOpenTelemetrySetup` already defaults to `false` on most server SDKs, so there is nothing to set. On `@sentry/nextjs` and `@sentry/sveltekit` it defaults to `true`, so you have to set it to `false` explicitly. Otherwise Sentry registers its own tracer provider and you end up in setup 2 rather than this one. -OpenTelemetry owns spans end to end. Sentry captures errors and logs, and the Sentry `otlpIntegration()` attaches them to the active OpenTelemetry span so all your telemetry is connected in one trace. `getOtlpTracesEndpoint()` turns your DSN into the URL and auth headers for Sentry's OTLP endpoint, so you can point your own exporter at Sentry, at your own collector, or at both. +OpenTelemetry owns spans end to end and the two pipelines stay separate: Sentry sends no spans, and no Sentry span is exported to your OpenTelemetry pipeline. Sentry captures errors and logs, and the Sentry `otlpIntegration()` attaches them to the active OpenTelemetry span so all your telemetry is connected in one trace. `getOtlpTracesEndpoint()` turns your DSN into the URL and auth headers for Sentry's OTLP endpoint, so you can point your own exporter at Sentry, at your own collector, or at both. Sentry does not touch your pipeline: no exporter, no span processor, no tracer provider, and outgoing trace propagation is left to your propagator. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces) for the details, including what changed if you used the v10 integration. -##### Avoiding duplicate spans +##### Turning Sentry tracing off -Sentry instruments many of the same libraries OpenTelemetry does (Express, Postgres, Redis, Prisma, Kafka and so on), so enabling Sentry tracing on top of your own instrumentation gives you two spans for every operation. Leave `tracesSampleRate` in your `Sentry.init` unset to avoid duplicate spans. With tracing off, Sentry's instrumentation stays installed and keeps isolating requests, but emits no spans. +This setup only works with Sentry tracing off, so leave `tracesSampleRate` unset. Sentry instruments many of the same libraries OpenTelemetry does (Express, Postgres, Redis, Prisma, Kafka and so on), so leaving tracing on gives you two spans for every operation, in two pipelines that never join up. With tracing off, Sentry's instrumentation stays installed and keeps isolating requests, but emits no spans. -Note that this changed since v10, where setting `skipOpenTelemetrySetup: true` also turned Sentry's HTTP and fetch spans off by default. Sentry now emits those whenever tracing is enabled, regardless of `enableOpenTelemetrySetup`. - -If you do want Sentry spans alongside your own, keep `tracesSampleRate` set and drop the integrations that overlap. HTTP and fetch are the exception: turn off only their spans, because `httpIntegration` also provides request isolation, request data and session tracking: - -```js -Sentry.init({ - dsn: '__DSN__', - tracesSampleRate: 1.0, - integrations: integrations => [ - // your own OpenTelemetry instrumentation already covers these - ...integrations.filter(integration => integration.name !== 'Postgres'), - Sentry.httpIntegration({ spans: false }), - Sentry.nativeNodeFetchIntegration({ spans: false }), - ], -}); -``` +Note that this changed since v10, where setting `skipOpenTelemetrySetup: true` also turned Sentry's HTTP and fetch spans off by default. Sentry now emits those whenever tracing is enabled, regardless of `enableOpenTelemetrySetup`, so an app that relied on that has to unset `tracesSampleRate`. ##### Migrating custom OpenTelemetry setups diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json index 9bc50dd938f6..bb7ed3646030 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json @@ -3,24 +3,20 @@ "version": "1.0.0", "private": true, "scripts": { - "build": "tsc", - "start": "node dist/app.js", - "test": "playwright test", + "start": "node --import ./src/instrument.mjs src/app.mjs", "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:build": "pnpm install", + "test:assert": "playwright test" }, "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/instrumentation-http": "^0.220.0", "@opentelemetry/sdk-node": "^0.220.0", "@opentelemetry/sdk-trace-base": "^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" + "express": "^4.21.2" }, "devDependencies": { "@playwright/test": "~1.56.0", diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.mjs similarity index 56% rename from dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts rename to dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.mjs index c5ed26f03606..239912171ac8 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.mjs @@ -1,24 +1,22 @@ -import './instrument'; - -// Other imports below import { trace } from '@opentelemetry/api'; import * as Sentry from '@sentry/node'; import express from 'express'; -import * as http from 'http'; +import http from 'node:http'; const app = express(); const port = 3030; const tracer = trace.getTracer('node-otel-sdk-node'); -app.get('/test-param/:param', function (req, res) { - res.send({ paramWas: req.params.param }); -}); +app.get('/test-telemetry/:id', function (req, res) { + tracer.startActiveSpan('telemetry-handler', span => { + const { traceId, spanId } = span.spanContext(); + + Sentry.captureException(new Error(`This is an exception with id ${req.params.id}`)); -app.get('/test-transaction', function (_req, res) { - Sentry.startSpan({ name: 'sentry-span' }, () => undefined); - tracer.startActiveSpan('otel-span', span => span.end()); + span.end(); - res.send({ status: 'ok' }); + res.json({ traceId, spanId }); + }); }); app.get('/test-exception/:id', function (req, _res) { @@ -37,6 +35,13 @@ app.get('/echo-headers', function (req, res) { res.send(req.headers); }); +// Answers with the OpenTelemetry span the request ran under, so the test can check what the error +// captured by Sentry was linked to. +app.use(function onError(_err, _req, res, _next) { + const spanContext = trace.getActiveSpan()?.spanContext(); + res.status(500).json({ traceId: spanContext?.traceId, spanId: spanContext?.spanId }); +}); + app.listen(port, () => { console.log(`Example app listening on port ${port}`); }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.mjs new file mode 100644 index 000000000000..dd92b19da3f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.mjs @@ -0,0 +1,8 @@ +import { register } from 'node:module'; + +// The app runs as ESM, so the OpenTelemetry instrumentation needs import-in-the-middle to see the +// modules it patches. Registering the hook before anything else is imported also puts it next to +// Sentry's own orchestrion module hook, which the SDK installs from `Sentry.init()`. +register('@opentelemetry/instrumentation/hook.mjs', import.meta.url); + +await import('./telemetry.mjs'); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/telemetry.mjs similarity index 62% rename from dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts rename to dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/telemetry.mjs index c850577bf9f6..6a0ebe0a1563 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/telemetry.mjs @@ -4,9 +4,9 @@ import { NodeSDK } from '@opentelemetry/sdk-node'; import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; import * as Sentry from '@sentry/node'; -// The user owns OpenTelemetry here: their own SDK, their own instrumentation and their own -// exporter. Sentry runs alongside it and must neither register a competing tracer provider nor -// route its own spans through this pipeline. +// OpenTelemetry owns tracing here: the user's own SDK, their own instrumentation and their own +// exporter. In production the exporter would point at `Sentry.getOtlpTracesEndpoint(dsn)`; here it +// points at a local receiver so the test can assert what was exported. const sdk = new NodeSDK({ instrumentations: [new HttpInstrumentation()], spanProcessors: [ @@ -17,10 +17,10 @@ const sdk = new NodeSDK({ sdk.start(); Sentry.init({ - traceLifecycle: 'static', - environment: 'qa', // dynamic sampling bias to keep transactions + environment: 'qa', dsn: process.env.E2E_TEST_DSN, debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1, + // no tracesSampleRate: OpenTelemetry owns spans, Sentry owns errors and logs + integrations: [Sentry.otlpIntegration()], }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts index f6ff65266c16..0a9e4df2417d 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts @@ -1,21 +1,31 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError } from '@sentry-internal/test-utils'; -test('captures an exception and puts it on the Sentry trace', async ({ baseURL }) => { +test('links errors to the active OpenTelemetry span', async ({ baseURL }) => { const errorEventPromise = waitForError('node-otel-sdk-node', errorEvent => { - return errorEvent.exception?.values?.[0]?.value === 'This is an exception with id 456'; + return errorEvent.exception?.values?.[0]?.value === 'This is an exception with id 123'; }); - const transactionEventPromise = waitForTransaction('node-otel-sdk-node', transactionEvent => { - return transactionEvent.transaction === 'GET /test-exception/:id'; + const response = await fetch(`${baseURL}/test-telemetry/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('links errors from the Sentry instrumentation to the active OpenTelemetry span', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-otel-sdk-node', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'This is an exception with id 456'; }); - await fetch(`${baseURL}/test-exception/456`); + const response = await fetch(`${baseURL}/test-exception/456`); + const { traceId, spanId } = (await response.json()) as { traceId: string; spanId: string }; const errorEvent = await errorEventPromise; - const transactionEvent = await transactionEventPromise; - expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.transaction).toBe('GET /test-exception/:id'); - expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + // With tracing off, Sentry's channel instrumentation still runs and reports the errors express + // never handles. + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ type: 'auto.http.express', handled: false }); + expect(errorEvent.contexts?.trace).toEqual({ trace_id: traceId, span_id: spanId }); }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts index 32b7d4349974..af6c54e27f7e 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForPlainRequest } from '@sentry-internal/test-utils'; +import { waitForError, waitForPlainRequest, waitForTransaction } from '@sentry-internal/test-utils'; interface OtlpSpan { name: string; @@ -22,33 +22,48 @@ function getExportedSpans(data: string): OtlpSpan[] { } test('exports the spans of the user OpenTelemetry setup to their own collector', async ({ baseURL }) => { - // The user's http span only ends once the response is out, so wait until both it and the span - // started inside the handler have been exported. + // The http span only ends once the response is out, so wait until both it and the span started + // inside the handler have been exported. const otelExportPromise = waitForPlainRequest('node-otel-sdk-node-otel', data => { const spans = getExportedSpans(data); - const otelSpan = spans.find(span => span.name === 'otel-span'); - return !!otelSpan && spans.some(span => span.name === 'GET' && span.traceId === otelSpan.traceId); + return spans.some(span => span.name === 'telemetry-handler') && spans.some(span => span.name === 'GET'); }); - await fetch(`${baseURL}/test-transaction`); + const response = await fetch(`${baseURL}/test-telemetry/234`); + const { traceId } = (await response.json()) as { traceId: string }; const exportedSpans = getExportedSpans(await otelExportPromise); - const otelSpan = exportedSpans.find(span => span.name === 'otel-span'); - expect(otelSpan).toBeDefined(); - // The user's own http instrumentation keeps working and stays on the same trace as their spans. - expect(exportedSpans).toContainEqual(expect.objectContaining({ name: 'GET', traceId: otelSpan?.traceId })); + expect(exportedSpans).toContainEqual(expect.objectContaining({ name: 'telemetry-handler', traceId })); + expect(exportedSpans).toContainEqual(expect.objectContaining({ name: 'GET', traceId })); +}); + +test('sends no spans to Sentry', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('node-otel-sdk-node', () => true); + const errorPromise = waitForError('node-otel-sdk-node', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'This is an exception with id 345'; + }); + + await fetch(`${baseURL}/test-telemetry/345`); + // Proves the request's telemetry reached the proxy, so the absence check below is not vacuous. + await errorPromise; + + // Absence can only be time bounded. This guards against Sentry's own instrumentation emitting + // spans again, which would produce a transaction for every request, well inside this window. + const transaction = await Promise.race([ + transactionPromise, + new Promise(resolve => setTimeout(() => resolve(undefined), 3000)), + ]); - // Sentry does not feed its spans into the user's pipeline. - expect(exportedSpans.map(span => span.name)).not.toContain('sentry-span'); + expect(transaction).toBeUndefined(); }); -test('propagates both the Sentry and the OpenTelemetry trace on outgoing requests', async ({ baseURL }) => { +test('leaves outgoing trace propagation to the user propagator', async ({ baseURL }) => { const response = await fetch(`${baseURL}/test-outgoing`); const headers = (await response.json()) as Record; - expect(headers['sentry-trace']).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); - expect(headers['baggage']).toContain('sentry-trace_id='); expect(headers['traceparent']).toMatch(/^00-[a-f0-9]{32}-[a-f0-9]{16}-\d{2}$/); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['baggage']).toBeUndefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts deleted file mode 100644 index 289d28d5f94d..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends an express transaction from its own instrumentation', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('node-otel-sdk-node', transactionEvent => { - return transactionEvent.transaction === 'GET /test-transaction'; - }); - - await fetch(`${baseURL}/test-transaction`); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent.contexts?.trace).toEqual( - expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.http_server', - status: 'ok', - data: expect.objectContaining({ - 'http.route': '/test-transaction', - 'sentry.segment.name.source': 'route', - }), - }), - ); - - expect(transactionEvent.transaction_info).toEqual({ source: 'route' }); - - const spanDescriptions = (transactionEvent.spans || []).map(span => span.description); - - expect(spanDescriptions).toContain('sentry-span'); - // The user's OpenTelemetry spans belong to their pipeline, so they must not end up in Sentry. - expect(spanDescriptions).not.toContain('otel-span'); -}); - -test('parameterizes express routes', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('node-otel-sdk-node', transactionEvent => { - return transactionEvent.transaction === 'GET /test-param/:param'; - }); - - await fetch(`${baseURL}/test-param/123`); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent.contexts?.trace?.data).toEqual( - expect.objectContaining({ 'http.route': '/test-param/:param' }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json deleted file mode 100644 index 2887ec11a81d..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "types": ["node"], - "esModuleInterop": true, - "lib": ["es2018"], - "strict": true, - "outDir": "dist", - "skipLibCheck": true - }, - "include": ["src/**/*.ts"] -} From 7bbea4babcf8f5de0ae6e787f28aa296eb1996d7 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 12:08:22 +0200 Subject: [PATCH 3/5] Collapse the OpenTelemetry ESM setup into a single instrument file --- .../node-otel-sdk-node/src/instrument.mjs | 32 ++++++++++++++++--- .../node-otel-sdk-node/src/telemetry.mjs | 26 --------------- 2 files changed, 28 insertions(+), 30 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/telemetry.mjs diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.mjs index dd92b19da3f9..a37cba30293f 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.mjs @@ -1,8 +1,32 @@ +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import * as Sentry from '@sentry/node'; import { register } from 'node:module'; -// The app runs as ESM, so the OpenTelemetry instrumentation needs import-in-the-middle to see the -// modules it patches. Registering the hook before anything else is imported also puts it next to -// Sentry's own orchestrion module hook, which the SDK installs from `Sentry.init()`. +// What an ESM app needs for the OpenTelemetry instrumentation to see the modules it patches. It +// puts import-in-the-middle in the process next to the module hooks Sentry's channel injection +// registers from `Sentry.init()` below, which is the pairing this app exists to cover. register('@opentelemetry/instrumentation/hook.mjs', import.meta.url); -await import('./telemetry.mjs'); +// OpenTelemetry owns tracing here: the user's own SDK, their own instrumentation and their own +// exporter. In production the exporter would point at `Sentry.getOtlpTracesEndpoint(dsn)`; here it +// points at a local receiver so the test can assert what was exported. +const sdk = new NodeSDK({ + instrumentations: [new HttpInstrumentation()], + spanProcessors: [ + new BatchSpanProcessor(new OTLPTraceExporter({ url: 'http://localhost:3032/' }), { scheduledDelayMillis: 100 }), + ], +}); + +sdk.start(); + +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + // no tracesSampleRate: OpenTelemetry owns spans, Sentry owns errors and logs + integrations: [Sentry.otlpIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/telemetry.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/telemetry.mjs deleted file mode 100644 index 6a0ebe0a1563..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/telemetry.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; -import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import { NodeSDK } from '@opentelemetry/sdk-node'; -import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import * as Sentry from '@sentry/node'; - -// OpenTelemetry owns tracing here: the user's own SDK, their own instrumentation and their own -// exporter. In production the exporter would point at `Sentry.getOtlpTracesEndpoint(dsn)`; here it -// points at a local receiver so the test can assert what was exported. -const sdk = new NodeSDK({ - instrumentations: [new HttpInstrumentation()], - spanProcessors: [ - new BatchSpanProcessor(new OTLPTraceExporter({ url: 'http://localhost:3032/' }), { scheduledDelayMillis: 100 }), - ], -}); - -sdk.start(); - -Sentry.init({ - environment: 'qa', - dsn: process.env.E2E_TEST_DSN, - debug: !!process.env.DEBUG, - tunnel: `http://localhost:3031/`, // proxy server - // no tracesSampleRate: OpenTelemetry owns spans, Sentry owns errors and logs - integrations: [Sentry.otlpIntegration()], -}); From 35e97f1fab998afe149be6baad12f630de432957 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 13:37:03 +0200 Subject: [PATCH 4/5] Say spans are managed rather than owned in the OpenTelemetry setup guide --- MIGRATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 5ccdb3b75b15..8c031200621b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -97,7 +97,7 @@ Sentry.init({ }); ``` -Sentry owns spans end to end and there is no OpenTelemetry involved: spans created through `@opentelemetry/api` are ignored. If a library you depend on emits its own OpenTelemetry spans and you want those in Sentry too, use setup 2. +Spans are completely managed by the Sentry SDK and there is no OpenTelemetry involved: spans created through `@opentelemetry/api` are ignored. If a library you depend on emits its own OpenTelemetry spans and you want those in Sentry too, use setup 2. ##### 2. OpenTelemetry-compatible mode, everything goes to Sentry @@ -142,7 +142,7 @@ Sentry.init({ `enableOpenTelemetrySetup` already defaults to `false` on most server SDKs, so there is nothing to set. On `@sentry/nextjs` and `@sentry/sveltekit` it defaults to `true`, so you have to set it to `false` explicitly. Otherwise Sentry registers its own tracer provider and you end up in setup 2 rather than this one. -OpenTelemetry owns spans end to end and the two pipelines stay separate: Sentry sends no spans, and no Sentry span is exported to your OpenTelemetry pipeline. Sentry captures errors and logs, and the Sentry `otlpIntegration()` attaches them to the active OpenTelemetry span so all your telemetry is connected in one trace. `getOtlpTracesEndpoint()` turns your DSN into the URL and auth headers for Sentry's OTLP endpoint, so you can point your own exporter at Sentry, at your own collector, or at both. +Spans are completely managed by your OpenTelemetry setup and the two pipelines stay separate: Sentry sends no spans, and no Sentry span is exported to your OpenTelemetry pipeline. Sentry captures errors and logs, and the Sentry `otlpIntegration()` attaches them to the active OpenTelemetry span so all your telemetry is connected in one trace. `getOtlpTracesEndpoint()` turns your DSN into the URL and auth headers for Sentry's OTLP endpoint, so you can point your own exporter at Sentry, at your own collector, or at both. Sentry does not touch your pipeline: no exporter, no span processor, no tracer provider, and outgoing trace propagation is left to your propagator. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces) for the details, including what changed if you used the v10 integration. From c9bff8bcbb41a08dbeb285c41c2af845d21d7ac3 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 13:59:46 +0200 Subject: [PATCH 5/5] Wait for the request's own OpenTelemetry trace in the OTLP export assertion --- .../tests/opentelemetry.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts index af6c54e27f7e..fe0ebc9c2ae2 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/opentelemetry.test.ts @@ -22,21 +22,25 @@ function getExportedSpans(data: string): OtlpSpan[] { } test('exports the spans of the user OpenTelemetry setup to their own collector', async ({ baseURL }) => { - // The http span only ends once the response is out, so wait until both it and the span started - // inside the handler have been exported. + let traceId: string | undefined; + + // Every test in this app exports into the same proxy, and the http span only ends once the + // response is out, so wait for this request's own trace to be complete. Waiting on the span names + // alone matches a batch that another test filled, whose spans are on a different trace. const otelExportPromise = waitForPlainRequest('node-otel-sdk-node-otel', data => { - const spans = getExportedSpans(data); - return spans.some(span => span.name === 'telemetry-handler') && spans.some(span => span.name === 'GET'); + const names = getExportedSpans(data) + .filter(span => span.traceId === traceId) + .map(span => span.name); + return names.includes('telemetry-handler') && names.includes('GET'); }); const response = await fetch(`${baseURL}/test-telemetry/234`); - const { traceId } = (await response.json()) as { traceId: string }; + ({ traceId } = (await response.json()) as { traceId: string }); - const exportedSpans = getExportedSpans(await otelExportPromise); + const exportedSpans = getExportedSpans(await otelExportPromise).filter(span => span.traceId === traceId); // The user's own http instrumentation keeps working and stays on the same trace as their spans. - expect(exportedSpans).toContainEqual(expect.objectContaining({ name: 'telemetry-handler', traceId })); - expect(exportedSpans).toContainEqual(expect.objectContaining({ name: 'GET', traceId })); + expect(exportedSpans.map(span => span.name)).toEqual(expect.arrayContaining(['telemetry-handler', 'GET'])); }); test('sends no spans to Sentry', async ({ baseURL }) => {