From d4fb98ae97974f342164206fbade54ffc19d43b6 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 6 Aug 2026 15:16:43 +0200 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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);