diff --git a/MIGRATION.md b/MIGRATION.md index cfaa4fa824c0..e05f61044502 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -76,7 +76,7 @@ Affected SDKs: Server-side SDKs (`@sentry/node` and all dependents). By default, v11 no longer sets up an OpenTelemetry tracer provider for **most** SDKs. SDKs now own the full span lifecycle, producing native Sentry spans. -A new optional OpenTelemetry integration lets you connect Sentry events such as Errors, Logs, Crons and Metrics to your OpenTelemetry traces, if you need to. +A new optional OpenTelemetry integration lets you connect Sentry events such as Errors, Logs, Crons and Metrics to your OpenTelemetry traces, if you need to. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces). Only `@sentry/nextjs` and `@sentry/sveltekit` still set up an OpenTelemetry compatible light tracer provider to capture spans the underlying frameworks emit. @@ -111,8 +111,47 @@ With this, we also heavily reduced our OpenTelemetry dependencies, with `@opente For most users, day-to-day tracing is **unchanged**. -> **TODO(v11):** Document the new optional OpenTelemetry integration once its final name and signature -> are locked in — add the `Sentry.init` example. +#### Connecting Sentry to your OpenTelemetry traces + +`Sentry.otlpIntegration()` attaches everything Sentry sends that carries trace information (errors, logs, metrics and crons) to the OpenTelemetry span that is active when it happens. It takes no options, and is available from every server-side SDK, so there is nothing extra to install or import. + +It does not set up a span exporter, span processor, or tracer provider. You keep full ownership of your OpenTelemetry pipeline, and outgoing request propagation is left to your OpenTelemetry propagator. To send your spans to Sentry, point your own exporter at the URL and auth headers that `Sentry.getOtlpTracesEndpoint()` derives from your DSN: + +```js +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import * as Sentry from '@sentry/node'; + +const provider = new NodeTracerProvider({ + spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter(Sentry.getOtlpTracesEndpoint('__DSN__')))], +}); + +provider.register(); + +Sentry.init({ + dsn: '__DSN__', + integrations: [Sentry.otlpIntegration()], +}); +``` + +An active Sentry span still takes precedence, so this only changes what happens when Sentry has no span of its own, which is the usual setup when OpenTelemetry owns tracing. + +If you used the v10 integration from `@sentry/node-core/light/otlp`, three things changed: it moved to the main export of every server SDK, it [no longer sets up an exporter for you and lost its options](#3-removed-apis), and it [reports itself as `Otlp` rather than `OtlpIntegration`](#otlpintegration-integration-renamed-to-otlp). Configure your own exporter as shown above, pointing it at your collector's URL if you route through one. + +```js +// before +import * as Sentry from '@sentry/node-core/light'; +import { otlpIntegration } from '@sentry/node-core/light/otlp'; + +Sentry.init({ dsn: '__DSN__', integrations: [otlpIntegration()] }); + +// after +import * as Sentry from '@sentry/node'; + +// set up your own tracer provider and exporter, then: +Sentry.init({ dsn: '__DSN__', integrations: [Sentry.otlpIntegration()] }); +``` > **TODO(v11):** Link to the upcoming guide covering common use cases with the new OpenTelemetry setup > (running your own OpenTelemetry setup alongside Sentry, connecting Sentry events to OTel traces, etc.). @@ -625,6 +664,8 @@ Sentry.init({ - (AWS Lambda) The deprecated `startTrace` option was removed. It no longer had any effect; to disable tracing, set `tracesSampleRate` to `0`. - (AWS Lambda) The deprecated `tryPatchHandler` function was removed. It was no longer used. - (Express) The deprecated `patchExpressModule(options)` signature was removed. Use `patchExpressModule(moduleExports, getOptions)` instead. +- The `@sentry/node-core/light/otlp` entry point was removed, along with its optional `@opentelemetry/exporter-trace-otlp-http` peer dependency. `otlpIntegration` is now exported directly from every server-side SDK, so `Sentry.otlpIntegration()` needs no extra import or install. +- The `otlpIntegration` options `setupOtlpTracesExporter` and `collectorUrl` were removed, and the integration no longer sets up a span exporter, span processor, or tracer provider. Configure your own exporter and point it at `Sentry.getOtlpTracesEndpoint(dsn)`, or at your collector's URL if you route through one. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces). ### `@sentry/cloudflare` @@ -907,6 +948,26 @@ Several default integrations were renamed to match the names used by the other S - `DenoMysql` => `Mysql` - `DenoPostgres` => `Postgres` +### `OtlpIntegration` integration renamed to `Otlp` + +Affected SDKs: Server-side SDKs (`@sentry/node` and all dependents). + +The OTLP integration reports itself as `Otlp` rather than `OtlpIntegration`, matching every other integration in the SDKs, none of which carry an `Integration` suffix in their name. The `otlpIntegration()` export itself is unchanged. This only matters if you reference the integration by name: + +```js +// before +Sentry.init({ + integrations: integrations => integrations.filter(integration => integration.name !== 'OtlpIntegration'), +}); + +// after +Sentry.init({ + integrations: integrations => integrations.filter(integration => integration.name !== 'Otlp'), +}); +``` + +The same applies when looking the integration up by name, e.g. via `client.getIntegrationByName('OtlpIntegration')`. + ## 6. Type Changes - Several public types that used `any` now use `unknown` — including `StackFrame`, `SamplingContext`, 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..f4e0fc622407 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/src/app.ts @@ -0,0 +1,95 @@ +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-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}`)); + + 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..fdcf53d79e9f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-otlp/tests/otlp.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem, waitForError, waitForMetric, waitForRequest } from '@sentry-internal/test-utils'; +import type { SerializedLogContainer } from '@sentry/core'; + +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`); +} + +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 { traceId, spanId } = await triggerTelemetry(baseURL as string, '123'); + const errorEvent = await errorEventPromise; + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: traceId, + span_id: spanId, + }); +}); + +test('sends no envelope trace header while riding along on an OpenTelemetry span', async ({ baseURL }) => { + const envelopePromise = waitForRequest('node-express-otlp', ({ envelope }) => { + const [, items] = envelope; + return items.some( + item => + (item[1] as { exception?: { values?: { value?: string }[] } })?.exception?.values?.[0]?.value === + 'This is an exception with id 567', + ); + }); + + await triggerTelemetry(baseURL as string, '567'); + const { envelope } = await envelopePromise; + const [envelopeHeaders] = envelope; + + // The Sentry scope's sampling context describes a different trace than the OpenTelemetry one the + // event is stamped with, so no `trace` header is sent rather than one naming the wrong trace. + expect((envelopeHeaders as { trace?: unknown }).trace).toBeUndefined(); +}); + +test('attaches the active OpenTelemetry trace to logs', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('node-express-otlp', envelope => { + return ( + 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 { traceId, spanId } = await triggerTelemetry(baseURL as string, '456'); + + 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/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, 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/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 8d428ddaa1d4..f2a8159fbd57 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -1,6 +1,6 @@ import type { Client } from '../client'; import { DEFAULT_ENVIRONMENT } from '../constants'; -import { getClient } from '../currentScopes'; +import { getClient, getExternalPropagationContext } from '../currentScopes'; import type { Scope } from '../scope'; import { SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE, @@ -63,7 +63,18 @@ export function getDynamicSamplingContextFromClient(trace_id: string, client: Cl /** * Get the dynamic sampling context for the currently active scopes. */ -export function getDynamicSamplingContextFromScope(client: Client, scope: Scope): Partial { +export function getDynamicSamplingContextFromScope( + client: Client, + scope: Scope, +): Partial | undefined { + // While an external propagation context is active (e.g. the OTLP integration riding along on an + // OpenTelemetry span), the SDK lacks most of the DSC information, like sampled, sample_rate, + // sample_rand and transaction. The scope's own DSC would also name a different trace than the one + // stamped on the event, so send none at all. Matches sentry-python. + if (getExternalPropagationContext()) { + return undefined; + } + const propagationContext = scope.getPropagationContext(); return propagationContext.dsc || getDynamicSamplingContextFromClient(propagationContext.traceId, client); } @@ -120,8 +131,12 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly { transaction: 'tx', }); }); + + it('derives the DSC from the span when an external propagation context is active', () => { + const options = getDefaultTestClientOptions({ tracesSampleRate: undefined, release: '1.0.1' }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // The scope yields no DSC while riding an external (e.g. OpenTelemetry) trace, but a Sentry span + // means we are head of its trace, so the DSC comes from the span rather than being left empty. + registerExternalPropagationContext(() => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb', + })); + + try { + const rootSpan = new SentryNonRecordingSpan({ traceId: 'cccccccccccccccccccccccccccccccc' }); + setCapturedScopesOnSpan(rootSpan, new Scope(), new Scope()); + + expect(getDynamicSamplingContextFromSpan(rootSpan)).toMatchObject({ + trace_id: 'cccccccccccccccccccccccccccccccc', + }); + } finally { + registerExternalPropagationContext(() => undefined); + } + }); }); describe('getDynamicSamplingContextFromClient', () => { 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..e0d925788dd7 --- /dev/null +++ b/packages/server-utils/src/otlp.ts @@ -0,0 +1,92 @@ +import { isSpanContextValid, 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; + } + + // 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 }; + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Connects Sentry to an existing OpenTelemetry setup. + * + * 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}. + */ +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. + * + * @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); + 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..616919363d59 --- /dev/null +++ b/packages/server-utils/test/otlp.test.ts @@ -0,0 +1,192 @@ +import type { Context, ContextManager } from '@opentelemetry/api'; +import { context, INVALID_SPAN_CONTEXT, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Envelope } from '@sentry/core'; +import { + getCurrentScope, + getGlobalScope, + 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; +} + +/** Captures the envelopes the client actually sends, so their headers can be asserted on. */ +function setupClientCapturingEnvelopes(): { client: TestClient; envelopes: Envelope[] } { + const envelopes: Envelope[] = []; + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: DSN, + integrations: [otlpIntegration()], + stackParser: () => [], + enableSend: true, + }), + ); + client.on('beforeEnvelope', envelope => envelopes.push(envelope)); + setCurrentClient(client); + client.init(); + return { client, envelopes }; +} + +describe('otlpIntegration', () => { + beforeEach(() => { + getCurrentScope().clear(); + 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('sends no envelope trace header while riding along on an OpenTelemetry span', async () => { + const { client, envelopes } = setupClientCapturingEnvelopes(); + getCurrentScope().setPropagationContext({ traceId: 'cccccccccccccccccccccccccccccccc', sampleRand: 0.5 }); + + withActiveOtelSpan(() => { + client.captureException(new Error('boom')); + }); + await client.flush(); + + // The scope's DSC would name a different trace than the event, and we have no transaction + // semantics to describe the OpenTelemetry one with, so no sampling context is sent at all. + const [envelopeHeaders] = envelopes[0] ?? []; + expect(envelopeHeaders).toBeDefined(); + expect(envelopeHeaders?.trace).toBeUndefined(); + }); + + it('still sends an envelope trace header when no OpenTelemetry span is active', async () => { + const { client, envelopes } = setupClientCapturingEnvelopes(); + getCurrentScope().setPropagationContext({ traceId: 'cccccccccccccccccccccccccccccccc', sampleRand: 0.5 }); + + client.captureException(new Error('boom')); + await client.flush(); + + const [envelopeHeaders] = envelopes[0] ?? []; + expect(envelopeHeaders?.trace).toMatchObject({ trace_id: 'cccccccccccccccccccccccccccccccc' }); + }); + + it('ignores an active span with an invalid span context', async () => { + const client = setupClientWithOtlpIntegration(); + getCurrentScope().setPropagationContext({ traceId: 'cccccccccccccccccccccccccccccccc', sampleRand: 0.5 }); + + // 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 }); + + 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,