diff --git a/dev-packages/e2e-tests/test-applications/node-otel/.gitignore b/dev-packages/e2e-tests/test-applications/node-otel/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-otel/.npmrc b/dev-packages/e2e-tests/test-applications/node-otel/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-otel/package.json b/dev-packages/e2e-tests/test-applications/node-otel/package.json new file mode 100644 index 000000000000..70d97d1fa502 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel/package.json @@ -0,0 +1,31 @@ +{ + "name": "node-otel", + "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/sdk-node": "0.52.1", + "@opentelemetry/exporter-trace-otlp-http": "0.52.1", + "@sentry/core": "latest || *", + "@sentry/node": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@types/express": "4.17.17", + "@types/node": "^18.19.1", + "express": "4.19.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-otel/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-otel/playwright.config.mjs new file mode 100644 index 000000000000..888e61cfb2dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel/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/src/app.ts b/dev-packages/e2e-tests/test-applications/node-otel/src/app.ts new file mode 100644 index 000000000000..26779990f6d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel/src/app.ts @@ -0,0 +1,53 @@ +import './instrument'; + +// Other imports below +import * as Sentry from '@sentry/node'; +import express from 'express'; + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-transaction', function (req, res) { + Sentry.withActiveSpan(null, async () => { + Sentry.startSpan({ name: 'test-transaction', op: 'e2e-test' }, () => { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + }); + + await Sentry.flush(); + + res.send({}); + }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel/src/instrument.ts new file mode 100644 index 000000000000..bbc5ddf9c30f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel/src/instrument.ts @@ -0,0 +1,22 @@ +const opentelemetry = require('@opentelemetry/sdk-node'); +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); +const Sentry = require('@sentry/node'); + +const sentryClient = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: + process.env.E2E_TEST_DSN || + 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + + // Additional OTEL options + openTelemetrySpanProcessors: [ + new opentelemetry.node.BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel/start-event-proxy.mjs new file mode 100644 index 000000000000..e82b876a4979 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel/start-otel-proxy.mjs new file mode 100644 index 000000000000..df546bf5ff77 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-otel-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-otel/tests/errors.test.ts new file mode 100644 index 000000000000..e5b2d5ff6836 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel/tests/errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-otel', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts new file mode 100644 index 000000000000..de68adf681b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts @@ -0,0 +1,207 @@ +import { expect, test } from '@playwright/test'; +import { waitForPlainRequest, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-otel', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + // Ensure we also send data to the OTLP endpoint + const otelPromise = waitForPlainRequest('node-otel-otel', data => { + const json = JSON.parse(data) as any; + + return json.resourceSpans.length > 0; + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + const otelData = await otelPromise; + + // For now we do not test the actual shape of this, but only existence + expect(otelData).toBeDefined(); + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-transaction', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.http.otel.http', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'query', + 'express.type': 'middleware', + }, + description: 'query', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'expressInit', + 'express.type': 'middleware', + }, + description: 'expressInit', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-transaction', + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + }, + description: '/test-transaction', + op: 'request_handler.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-otel', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/:id' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'query', + 'express.type': 'middleware', + }, + description: 'query', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'expressInit', + 'express.type': 'middleware', + }, + description: 'expressInit', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-exception/:id', + 'express.name': '/test-exception/:id', + 'express.type': 'request_handler', + }, + description: '/test-exception/:id', + op: 'request_handler.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-otel/tsconfig.json new file mode 100644 index 000000000000..8cb64e989ed9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 2ce75908168d..f0e2739f2629 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -169,7 +169,9 @@ function _init( // If users opt-out of this, they _have_ to set up OpenTelemetry themselves // There is no way to use this SDK without OpenTelemetry! if (!options.skipOpenTelemetrySetup) { - initOpenTelemetry(client); + initOpenTelemetry(client, { + spanProcessors: options.openTelemetrySpanProcessors, + }); validateOpenTelemetrySetup(); } diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 4f0bb444d83d..b268314485a6 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -1,6 +1,7 @@ import moduleModule from 'module'; import { DiagLogLevel, diag } from '@opentelemetry/api'; import { Resource } from '@opentelemetry/resources'; +import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { ATTR_SERVICE_NAME, @@ -22,15 +23,20 @@ declare const __IMPORT_META_URL_REPLACEMENT__: string; // About 277h - this must fit into new Array(len)! const MAX_MAX_SPAN_WAIT_DURATION = 1_000_000; +interface AdditionalOpenTelemetryOptions { + /** Additional SpanProcessor instances that should be used. */ + spanProcessors?: SpanProcessor[]; +} + /** * Initialize OpenTelemetry for Node. */ -export function initOpenTelemetry(client: NodeClient): void { +export function initOpenTelemetry(client: NodeClient, options: AdditionalOpenTelemetryOptions = {}): void { if (client.getOptions().debug) { setupOpenTelemetryLogger(); } - const provider = setupOtel(client); + const provider = setupOtel(client, options); client.traceProvider = provider; } @@ -129,7 +135,7 @@ function getPreloadMethods(integrationNames?: string[]): ((() => void) & { id: s } /** Just exported for tests. */ -export function setupOtel(client: NodeClient): BasicTracerProvider { +export function setupOtel(client: NodeClient, options: AdditionalOpenTelemetryOptions = {}): BasicTracerProvider { // Create and configure NodeTracerProvider const provider = new BasicTracerProvider({ sampler: new SentrySampler(client), @@ -144,6 +150,7 @@ export function setupOtel(client: NodeClient): BasicTracerProvider { new SentrySpanProcessor({ timeout: _clampSpanProcessorTimeout(client.getOptions().maxSpanWaitDuration), }), + ...(options.spanProcessors || []), ], }); diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index ebcdee869523..c7f166ed9b4d 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -1,6 +1,6 @@ import type { Span as WriteableSpan } from '@opentelemetry/api'; import type { Instrumentation } from '@opentelemetry/instrumentation'; -import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import type { ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'; import type { ClientOptions, Options, SamplingContext, Scope, Span, TracePropagationTargets } from '@sentry/core'; import type { NodeTransportOptions } from './transports'; @@ -121,6 +121,11 @@ export interface BaseNodeOptions { */ openTelemetryInstrumentations?: Instrumentation[]; + /** + * Provide an array of additional OpenTelemetry SpanProcessors that should be registered. + */ + openTelemetrySpanProcessors?: SpanProcessor[]; + /** * The max. duration in seconds that the SDK will wait for parent spans to be finished before discarding a span. * The SDK will automatically clean up spans that have no finished parent after this duration.