From f31a172b72d81bcff0fb2dc7bef951ac7f7b9a2e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 21 Oct 2026 17:53:49 +0200 Subject: [PATCH] test(e2e): Add node-otel-sdk-node-v3 e2e app for OpenTelemetry JS v3 Co-Authored-By: Claude Opus 5.5 --- .../node-otel-sdk-node-v3/package.json | 28 +++++++ .../playwright.config.mjs | 34 +++++++++ .../node-otel-sdk-node-v3/pnpm-workspace.yaml | 3 + .../node-otel-sdk-node-v3/src/app.mjs | 47 ++++++++++++ .../node-otel-sdk-node-v3/src/instrument.mjs | 35 +++++++++ .../start-event-proxy.mjs | 6 ++ .../start-otel-proxy.mjs | 6 ++ .../tests/errors.test.ts | 31 ++++++++ .../tests/opentelemetry.test.ts | 73 +++++++++++++++++++ 9 files changed, 263 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/pnpm-workspace.yaml create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/src/app.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/src/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/start-otel-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/tests/opentelemetry.test.ts diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/package.json new file mode 100644 index 000000000000..264efe90d301 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/package.json @@ -0,0 +1,28 @@ +{ + "name": "node-otel-sdk-node-v3", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node --import ./src/instrument.mjs src/app.mjs", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "playwright test" + }, + "dependencies": { + "@opentelemetry/api": "^1.10.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.300.0", + "@opentelemetry/instrumentation": "^0.300.0", + "@opentelemetry/instrumentation-http": "^0.300.0", + "@opentelemetry/sdk-node": "^0.300.0", + "@opentelemetry/sdk-trace": "^3.0.0", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "express": "^4.21.2" + }, + "devDependencies": { + "@playwright/test": "1.64.1", + "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/playwright.config.mjs new file mode 100644 index 000000000000..888e61cfb2dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/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-v3/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/pnpm-workspace.yaml new file mode 100644 index 000000000000..428f42f5ee7b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + protobufjs: false +minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/src/app.mjs new file mode 100644 index 000000000000..ee441975d47c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/src/app.mjs @@ -0,0 +1,47 @@ +import { trace } from '@opentelemetry/api'; +import * as Sentry from '@sentry/node'; +import express from 'express'; +import http from 'node:http'; + +const app = express(); +const port = 3030; +const tracer = trace.getTracer('node-otel-sdk-node-v3'); + +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}`)); + + span.end(); + + res.json({ traceId, spanId }); + }); +}); + +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); +}); + +// 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-v3/src/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/src/instrument.mjs new file mode 100644 index 000000000000..c9b910fd3122 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/src/instrument.mjs @@ -0,0 +1,35 @@ +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'; +import * as Sentry from '@sentry/node'; +import { register } from 'node:module'; + +// 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); + +// 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({ + exporter: 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.openTelemetryIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/start-event-proxy.mjs new file mode 100644 index 000000000000..52bbf5464e17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-otel-sdk-node-v3', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/start-otel-proxy.mjs new file mode 100644 index 000000000000..0a3a31f582b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-otel-sdk-node-v3-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/tests/errors.test.ts new file mode 100644 index 000000000000..603e9c053aaa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/tests/errors.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('links errors to the active OpenTelemetry span', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-otel-sdk-node-v3', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + 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-v3', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'This is an exception with id 456'; + }); + + const response = await fetch(`${baseURL}/test-exception/456`); + const { traceId, spanId } = (await response.json()) as { traceId: string; spanId: string }; + + const errorEvent = await errorEventPromise; + + // 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-v3/tests/opentelemetry.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/tests/opentelemetry.test.ts new file mode 100644 index 000000000000..cbeffac8b286 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node-v3/tests/opentelemetry.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForPlainRequest, waitForTransaction } 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 }) => { + 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-v3-otel', data => { + 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`); + ({ traceId } = (await response.json()) as { traceId: string }); + + 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.map(span => span.name)).toEqual(expect.arrayContaining(['telemetry-handler', 'GET'])); +}); + +test('sends no spans to Sentry', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('node-otel-sdk-node-v3', () => true); + const errorPromise = waitForError('node-otel-sdk-node-v3', 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)), + ]); + + expect(transaction).toBeUndefined(); +}); + +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['traceparent']).toMatch(/^00-[a-f0-9]{32}-[a-f0-9]{16}-\d{2}$/); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['baggage']).toBeUndefined(); +});