From afc1765dc0c9cb507aa29effc0424027b87ef82e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 10 Mar 2026 15:30:53 +0100 Subject: [PATCH 1/5] feat(node-core): Add OTLP integration for light SDK Added `otlpIntegration` at `@sentry/node-core/light/otlp` for users who manage their own OpenTelemetry setup and want to send trace data to Sentry without adopting the full `@sentry/node` SDK. ```js import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; import * as Sentry from '@sentry/node-core/light'; import { otlpIntegration } from '@sentry/node-core/light/otlp'; const provider = new NodeTracerProvider(); provider.register(); Sentry.init({ dsn: '__DSN__', integrations: [ otlpIntegration({ setupOtlpTracesExporter: true, // Export OTel spans to Sentry via OTLP (default: true) setupPropagator: true, // Propagate sentry-trace/baggage headers (default: true) captureExceptions: false, // Capture span.recordException() as Sentry errors (default: false) }), ], }); ``` The integration links Sentry errors to OTel traces, exports spans to Sentry via OTLP, and propagates `sentry-trace`/`baggage` headers for distributed tracing. Co-Authored-By: Claude claude-opus-4-6 From 27431755a10d50365d6e33e02e39ae351a9e76ce Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 10 Mar 2026 15:31:51 +0100 Subject: [PATCH 2/5] Add external propagation context support and export SENTRY_API_VERSION Co-Authored-By: Claude claude-opus-4-6 Agent transcript: https://claudescope.sentry.dev/share/8DFqqLeunVtEbRWN6SnCfcJ8kjt__yomPfY01iQxSMU --- packages/core/src/api.ts | 2 +- packages/core/src/currentScopes.ts | 30 +++++++ packages/core/src/index.ts | 5 +- packages/core/src/utils/traceData.ts | 13 ++- packages/core/test/lib/currentScopes.test.ts | 86 +++++++++++++++++++ .../core/test/lib/utils/traceData.test.ts | 44 ++++++++++ 6 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 packages/core/test/lib/currentScopes.test.ts diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts index 924c6a8e28ad..2aea96fd825b 100644 --- a/packages/core/src/api.ts +++ b/packages/core/src/api.ts @@ -3,7 +3,7 @@ import type { DsnComponents, DsnLike } from './types-hoist/dsn'; import type { SdkInfo } from './types-hoist/sdkinfo'; import { dsnToString, makeDsn } from './utils/dsn'; -const SENTRY_API_VERSION = '7'; +export const SENTRY_API_VERSION = '7'; /** Returns the prefix to construct Sentry ingestion API endpoints. */ function getBaseApiEndpoint(dsn: DsnComponents): string { diff --git a/packages/core/src/currentScopes.ts b/packages/core/src/currentScopes.ts index fc40051e56d8..a88aed55c971 100644 --- a/packages/core/src/currentScopes.ts +++ b/packages/core/src/currentScopes.ts @@ -5,6 +5,31 @@ import { Scope } from './scope'; import type { TraceContext } from './types-hoist/context'; import { generateSpanId } from './utils/propagationContext'; +let _externalPropagationContextProvider: (() => { traceId: string; spanId: string } | undefined) | undefined; + +/** + * Register an external propagation context provider function. + * When registered, trace context will be read from the external source (e.g. OpenTelemetry) + * instead of from the Sentry scope's propagation context. + */ +export function registerExternalPropagationContext(fn: () => { traceId: string; spanId: string } | undefined): void { + _externalPropagationContextProvider = fn; +} + +/** + * Get the external propagation context, if a provider has been registered. + */ +export function getExternalPropagationContext(): { traceId: string; spanId: string } | undefined { + return _externalPropagationContextProvider?.(); +} + +/** + * Check if an external propagation context provider has been registered. + */ +export function hasExternalPropagationContext(): boolean { + return _externalPropagationContextProvider !== undefined; +} + /** * Get the currently active scope. */ @@ -125,6 +150,11 @@ export function getClient(): C | undefined { * Get a trace context for the given scope. */ export function getTraceContextFromScope(scope: Scope): TraceContext { + const externalContext = getExternalPropagationContext(); + if (externalContext) { + return { trace_id: externalContext.traceId, span_id: externalContext.spanId }; + } + const propagationContext = scope.getPropagationContext(); const { traceId, parentSpanId, propagationSpanId } = propagationContext; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 61865ea7ba3c..b776a30dcb6a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,6 +41,9 @@ export { withIsolationScope, getClient, getTraceContextFromScope, + registerExternalPropagationContext, + getExternalPropagationContext, + hasExternalPropagationContext, } from './currentScopes'; export { getDefaultCurrentScope, getDefaultIsolationScope } from './defaultScopes'; export { setAsyncContextStrategy } from './asyncContext'; @@ -49,7 +52,7 @@ export { makeSession, closeSession, updateSession } from './session'; export { Scope } from './scope'; export type { CaptureContext, ScopeContext, ScopeData } from './scope'; export { notifyEventProcessors } from './eventProcessors'; -export { getEnvelopeEndpointWithUrlEncodedAuth, getReportDialogEndpoint } from './api'; +export { getEnvelopeEndpointWithUrlEncodedAuth, getReportDialogEndpoint, SENTRY_API_VERSION } from './api'; export { Client } from './client'; export { ServerRuntimeClient } from './server-runtime-client'; export { initAndBind, setCurrentClient } from './sdk'; diff --git a/packages/core/src/utils/traceData.ts b/packages/core/src/utils/traceData.ts index 9958e2761960..c19b2560b605 100644 --- a/packages/core/src/utils/traceData.ts +++ b/packages/core/src/utils/traceData.ts @@ -1,7 +1,7 @@ import { getAsyncContextStrategy } from '../asyncContext'; import { getMainCarrier } from '../carrier'; import type { Client } from '../client'; -import { getClient, getCurrentScope } from '../currentScopes'; +import { getClient, getCurrentScope, hasExternalPropagationContext } from '../currentScopes'; import { isEnabled } from '../exports'; import type { Scope } from '../scope'; import { getDynamicSamplingContextFromScope, getDynamicSamplingContextFromSpan } from '../tracing'; @@ -20,6 +20,10 @@ import { generateSentryTraceHeader, generateTraceparentHeader, TRACEPARENT_REGEX * This function also applies some validation to the generated sentry-trace and baggage values to ensure that * only valid strings are returned. * + * When an external propagation context is registered (e.g. via the OTLP integration) and there is no active + * Sentry span, this function returns an empty object to defer outgoing request propagation to the external + * propagator (e.g. an OpenTelemetry propagator). + * * If (@param options.propagateTraceparent) is `true`, the function will also generate a `traceparent` value, * following the W3C traceparent header format. * @@ -42,6 +46,13 @@ export function getTraceData( const scope = options.scope || getCurrentScope(); const span = options.span || getActiveSpan(); + + // When no active span and external propagation context is registered (e.g. OTLP integration), + // return empty to let the OTel propagator handle outgoing request propagation. + if (!span && hasExternalPropagationContext()) { + return {}; + } + const sentryTrace = span ? spanToTraceHeader(span) : scopeToTraceHeader(scope); const dsc = span ? getDynamicSamplingContextFromSpan(span) : getDynamicSamplingContextFromScope(client, scope); const baggage = dynamicSamplingContextToSentryBaggageHeader(dsc); diff --git a/packages/core/test/lib/currentScopes.test.ts b/packages/core/test/lib/currentScopes.test.ts new file mode 100644 index 000000000000..2320235ac4b0 --- /dev/null +++ b/packages/core/test/lib/currentScopes.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + getExternalPropagationContext, + getTraceContextFromScope, + hasExternalPropagationContext, + registerExternalPropagationContext, +} from '../../src/currentScopes'; +import { Scope } from '../../src/scope'; + +describe('External Propagation Context', () => { + afterEach(() => { + // Reset by registering a provider that returns undefined + registerExternalPropagationContext(() => undefined); + }); + + describe('registerExternalPropagationContext', () => { + it('registers a provider function', () => { + registerExternalPropagationContext(() => ({ + traceId: 'abc123', + spanId: 'def456', + })); + + expect(hasExternalPropagationContext()).toBe(true); + }); + }); + + describe('getExternalPropagationContext', () => { + it('returns undefined when provider returns undefined', () => { + registerExternalPropagationContext(() => undefined); + expect(getExternalPropagationContext()).toBeUndefined(); + }); + + it('returns trace context from provider', () => { + registerExternalPropagationContext(() => ({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + })); + + const result = getExternalPropagationContext(); + expect(result).toEqual({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + }); + }); + }); + + describe('hasExternalPropagationContext', () => { + it('returns true after registration', () => { + registerExternalPropagationContext(() => undefined); + expect(hasExternalPropagationContext()).toBe(true); + }); + }); + + describe('getTraceContextFromScope with external propagation context', () => { + it('uses external propagation context when available', () => { + registerExternalPropagationContext(() => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + spanId: 'bbbbbbbbbbbbbb01', + })); + + const scope = new Scope(); + scope.setPropagationContext({ + traceId: 'cccccccccccccccccccccccccccccc01', + sampleRand: 0.5, + }); + + const traceContext = getTraceContextFromScope(scope); + expect(traceContext.trace_id).toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'); + expect(traceContext.span_id).toBe('bbbbbbbbbbbbbb01'); + expect(traceContext.parent_span_id).toBeUndefined(); + }); + + it('falls back to scope propagation context when provider returns undefined', () => { + registerExternalPropagationContext(() => undefined); + + const scope = new Scope(); + scope.setPropagationContext({ + traceId: 'cccccccccccccccccccccccccccccc01', + sampleRand: 0.5, + }); + + const traceContext = getTraceContextFromScope(scope); + expect(traceContext.trace_id).toBe('cccccccccccccccccccccccccccccc01'); + }); + }); +}); diff --git a/packages/core/test/lib/utils/traceData.test.ts b/packages/core/test/lib/utils/traceData.test.ts index 379103a8a48c..6baf7a9d7a40 100644 --- a/packages/core/test/lib/utils/traceData.test.ts +++ b/packages/core/test/lib/utils/traceData.test.ts @@ -6,6 +6,7 @@ import { getIsolationScope, getMainCarrier, getTraceData, + registerExternalPropagationContext, Scope, SentrySpan, setAsyncContextStrategy, @@ -347,4 +348,47 @@ describe('getTraceData', () => { expect(traceData.traceparent).toBeDefined(); expect(traceData.traceparent).toMatch(/00-12345678901234567890123456789099-[0-9a-f]{16}-00/); }); + + it('returns empty object when no span and external propagation context is registered', () => { + setupClient(); + + registerExternalPropagationContext(() => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + spanId: 'bbbbbbbbbbbbbb01', + })); + + const traceData = getTraceData(); + expect(traceData).toEqual({}); + + // Clean up + registerExternalPropagationContext(() => undefined); + }); + + it('still returns trace data from span even when external propagation context is registered', () => { + setupClient(); + + registerExternalPropagationContext(() => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + spanId: 'bbbbbbbbbbbbbb01', + })); + + const span = new SentrySpan({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + sampled: true, + }); + + withActiveSpan(span, () => { + const data = getTraceData(); + + expect(data).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-environment=production,sentry-public_key=123,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', + }); + }); + + // Clean up + registerExternalPropagationContext(() => undefined); + }); }); From c50ae17d7bae025bf6bc3c46fdeec8f14db1487a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 10 Mar 2026 15:32:01 +0100 Subject: [PATCH 3/5] Add otlpIntegration for @sentry/node-core/light/otlp Co-Authored-By: Claude claude-opus-4-6 Agent transcript: https://claudescope.sentry.dev/share/hfhxEBlY6K2-8KKuZFKgEQ9b1NahysDmjm3gxHBEbl4 --- packages/node-core/package.json | 17 +- packages/node-core/rollup.npm.config.mjs | 2 +- .../node-core/src/light/integrations/otlp.ts | 284 ++++++++++++++++++ .../test/light/integrations/otlp.test.ts | 248 +++++++++++++++ yarn.lock | 110 ++++++- 5 files changed, 646 insertions(+), 15 deletions(-) create mode 100644 packages/node-core/src/light/integrations/otlp.ts create mode 100644 packages/node-core/test/light/integrations/otlp.test.ts diff --git a/packages/node-core/package.json b/packages/node-core/package.json index d68b587a8993..1db3722ef933 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -54,6 +54,16 @@ "require": { "default": "./build/cjs/init.js" } + }, + "./light/otlp": { + "import": { + "types": "./build/types/light/integrations/otlp.d.ts", + "default": "./build/esm/light/integrations/otlp.js" + }, + "require": { + "types": "./build/types/light/integrations/otlp.d.ts", + "default": "./build/cjs/light/integrations/otlp.js" + } } }, "typesVersions": { @@ -73,7 +83,8 @@ "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" + "@opentelemetry/semantic-conventions": "^1.39.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -96,6 +107,9 @@ }, "@opentelemetry/semantic-conventions": { "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true } }, "dependencies": { @@ -111,6 +125,7 @@ "@opentelemetry/resources": "^2.5.1", "@opentelemetry/sdk-trace-base": "^2.5.1", "@opentelemetry/semantic-conventions": "^1.39.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@types/node": "^18.19.1" }, "scripts": { diff --git a/packages/node-core/rollup.npm.config.mjs b/packages/node-core/rollup.npm.config.mjs index 9bae67fd2dd8..d53249839023 100644 --- a/packages/node-core/rollup.npm.config.mjs +++ b/packages/node-core/rollup.npm.config.mjs @@ -19,7 +19,7 @@ export default [ localVariablesWorkerConfig, ...makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/init.ts', 'src/light/index.ts'], + entrypoints: ['src/index.ts', 'src/init.ts', 'src/light/index.ts', 'src/light/integrations/otlp.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/packages/node-core/src/light/integrations/otlp.ts b/packages/node-core/src/light/integrations/otlp.ts new file mode 100644 index 000000000000..0139f9334de3 --- /dev/null +++ b/packages/node-core/src/light/integrations/otlp.ts @@ -0,0 +1,284 @@ +import { + type Context, + createContextKey, + propagation, + type TextMapGetter, + type TextMapSetter, + trace, + TraceFlags, +} from '@opentelemetry/api'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import type { SpanExporter } from '@opentelemetry/sdk-trace-base'; +import { BasicTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import type { Client, IntegrationFn } from '@sentry/core'; +import { + captureException, + debug, + defineIntegration, + dynamicSamplingContextToSentryBaggageHeader, + generateSentryTraceHeader, + propagationContextFromHeaders, + registerExternalPropagationContext, + SENTRY_API_VERSION, +} from '@sentry/core'; + +interface OtlpIntegrationOptions { + /** + * Whether to set up the OTLP traces exporter that sends spans to Sentry. + * Default: true + */ + setupOtlpTracesExporter?: boolean; + + /** + * Whether to set up the Sentry propagator that injects/extracts sentry-trace and baggage headers. + * Default: true + */ + setupPropagator?: boolean; + + /** + * Whether to capture exceptions recorded on OTel spans as Sentry errors. + * Default: false + */ + captureExceptions?: boolean; +} + +const INTEGRATION_NAME = 'OtlpIntegration'; + +const _otlpIntegration = ((userOptions: OtlpIntegrationOptions = {}) => { + const options: Required = { + setupOtlpTracesExporter: userOptions.setupOtlpTracesExporter ?? true, + setupPropagator: userOptions.setupPropagator ?? true, + captureExceptions: userOptions.captureExceptions ?? false, + }; + + let _spanProcessor: BatchSpanProcessor | undefined; + let _tracerProvider: BasicTracerProvider | undefined; + + return { + name: INTEGRATION_NAME, + + setup(_client: Client): void { + // Always register external propagation context so that Sentry error/log events + // are linked to the active OTel trace context. + registerExternalPropagationContext(() => { + const activeSpan = trace.getActiveSpan(); + if (!activeSpan) { + return undefined; + } + const spanContext = activeSpan.spanContext(); + return { traceId: spanContext.traceId, spanId: spanContext.spanId }; + }); + + debug.log(`[${INTEGRATION_NAME}] External propagation context registered.`); + }, + + afterAllSetup(client: Client): void { + if (options.setupOtlpTracesExporter) { + setupTracesExporter(client); + } + + if (options.setupPropagator) { + setupPropagator(); + } + + if (options.captureExceptions) { + setupExceptionInterceptor(); + } + }, + }; + + function setupTracesExporter(client: Client): void { + const dsn = client.getDsn(); + if (!dsn) { + debug.warn(`[${INTEGRATION_NAME}] No DSN found. OTLP exporter not set up.`); + return; + } + + const { protocol, host, port, path, projectId, publicKey } = dsn; + + const basePath = path ? `/${path}` : ''; + const portStr = port ? `:${port}` : ''; + const endpoint = `${protocol}://${host}${portStr}${basePath}/api/${projectId}/integration/otlp/v1/traces/`; + + const sdkInfo = client.getSdkMetadata()?.sdk; + const sentryClient = sdkInfo ? `, sentry_client=${sdkInfo.name}/${sdkInfo.version}` : ''; + const authHeader = `Sentry sentry_version=${SENTRY_API_VERSION}, sentry_key=${publicKey}${sentryClient}`; + + let exporter: SpanExporter; + try { + exporter = new OTLPTraceExporter({ + url: endpoint, + headers: { + 'X-Sentry-Auth': authHeader, + }, + }); + } catch (e) { + debug.warn(`[${INTEGRATION_NAME}] Failed to create OTLPTraceExporter:`, e); + return; + } + + _spanProcessor = new BatchSpanProcessor(exporter); + + // Add span processor to existing global tracer provider. + // trace.getTracerProvider() returns a ProxyTracerProvider; unwrap it to get the real provider. + const globalProvider = trace.getTracerProvider(); + const delegate = + 'getDelegate' in globalProvider + ? (globalProvider as unknown as { getDelegate(): unknown }).getDelegate() + : globalProvider; + + // In OTel v2, addSpanProcessor was removed. We push into the internal _spanProcessors + // array on the MultiSpanProcessor, which is how OTel's own forceFlush() accesses it. + const activeProcessor = (delegate as Record)?._activeSpanProcessor as + | { _spanProcessors?: unknown[] } + | undefined; + if (activeProcessor?._spanProcessors) { + activeProcessor._spanProcessors.push(_spanProcessor); + debug.log(`[${INTEGRATION_NAME}] Added span processor to existing TracerProvider.`); + } else { + // No user-configured provider; create a minimal one and set it as global + _tracerProvider = new BasicTracerProvider({ + spanProcessors: [_spanProcessor], + }); + trace.setGlobalTracerProvider(_tracerProvider); + debug.log(`[${INTEGRATION_NAME}] Created new TracerProvider with OTLP span processor.`); + } + + client.on('flush', () => { + void _spanProcessor?.forceFlush(); + }); + + client.on('close', () => { + void _spanProcessor?.shutdown(); + void _tracerProvider?.shutdown(); + }); + } + + function setupPropagator(): void { + propagation.setGlobalPropagator(new SentryOTLPPropagator()); + debug.log(`[${INTEGRATION_NAME}] Sentry OTLP propagator installed.`); + } + + function setupExceptionInterceptor(): void { + // Get the Span implementation class by creating a temporary span from a temporary provider. + // SpanImpl is not exported from @opentelemetry/sdk-trace-base's public API, + // so we discover it at runtime to patch recordException. + const tempProvider = new BasicTracerProvider(); + const tempTracer = tempProvider.getTracer('sentry-otlp-setup'); + const tempSpan = tempTracer.startSpan('sentry-otlp-setup'); + + const SpanClass = (Object.getPrototypeOf(tempSpan) as { constructor: { prototype: Record } }) + .constructor; + tempSpan.end(); + tempProvider.shutdown().catch(() => undefined); + + if (typeof SpanClass?.prototype?.recordException !== 'function') { + debug.warn(`[${INTEGRATION_NAME}] Could not find Span.recordException to patch.`); + return; + } + + // Check if already patched (flag stored on the prototype to survive multiple integration instances) + if (SpanClass.prototype.__sentry_patched_exception__) { + return; + } + SpanClass.prototype.__sentry_patched_exception__ = true; + + const originalRecordException = SpanClass.prototype.recordException as ( + exception: unknown, + ...rest: unknown[] + ) => void; + SpanClass.prototype.recordException = new Proxy(originalRecordException, { + apply(target, thisArg, argArray) { + // Use the span's own context, not the currently active span, so the error + // is linked to the correct trace even when called on a non-active span. + const spanContext = (thisArg as { spanContext?: () => { traceId: string; spanId: string } }).spanContext?.(); + captureException(argArray[0], { + mechanism: { type: 'auto.otlp.record_exception', handled: false }, + captureContext: spanContext + ? { + contexts: { + trace: { + trace_id: spanContext.traceId, + span_id: spanContext.spanId, + }, + }, + } + : undefined, + }); + return Reflect.apply(target, thisArg, argArray); + }, + }); + } +}) satisfies IntegrationFn; + +/** + * OTLP integration for the Sentry light SDK. + * + * Bridges an existing OpenTelemetry setup with Sentry by: + * 1. Linking Sentry error/log events to the active OTel trace context + * 2. Exporting OTel spans to Sentry via OTLP + * 3. Propagating sentry-trace/baggage headers via a custom OTel propagator + * 4. Optionally capturing exceptions recorded on OTel spans + */ +export const otlpIntegration = defineIntegration(_otlpIntegration); + +const SENTRY_BAGGAGE_KEY = createContextKey('sentry-baggage'); + +class SentryOTLPPropagator { + public inject(ctx: Context, carrier: unknown, setter: TextMapSetter): void { + const span = trace.getSpan(ctx); + if (!span) { + return; + } + + const spanContext = span.spanContext(); + const sampled = spanContext.traceFlags === TraceFlags.SAMPLED; + const sentryTrace = generateSentryTraceHeader(spanContext.traceId, spanContext.spanId, sampled); + setter.set(carrier, 'sentry-trace', sentryTrace); + + // Pass through Sentry baggage (DSC) that was stored on the context during extract. + // We don't generate new baggage as head SDK since we have no transaction semantic in OTLP mode. + const dsc = ctx.getValue(SENTRY_BAGGAGE_KEY) as Record | undefined; + const baggageStr = dynamicSamplingContextToSentryBaggageHeader(dsc); + if (baggageStr) { + setter.set(carrier, 'baggage', baggageStr); + } + } + + public extract(ctx: Context, carrier: unknown, getter: TextMapGetter): Context { + const sentryTraceValue = getter.get(carrier, 'sentry-trace'); + const baggageValue = getter.get(carrier, 'baggage'); + + const sentryTrace = Array.isArray(sentryTraceValue) ? sentryTraceValue[0] : sentryTraceValue; + const baggage = Array.isArray(baggageValue) ? baggageValue[0] : baggageValue; + + if (!sentryTrace) { + return ctx; + } + + const propagationContext = propagationContextFromHeaders(sentryTrace, baggage); + + // Store the DSC on the OTel context so inject() can pass it through + let updatedCtx = propagationContext.dsc ? ctx.setValue(SENTRY_BAGGAGE_KEY, propagationContext.dsc) : ctx; + + // Create a remote span context from the extracted data so OTel links to the right trace + const { traceId, parentSpanId } = propagationContext; + if (traceId && parentSpanId) { + // Always set SAMPLED so OTel records the span — leave sampling decisions to Sentry + const remoteSpanContext = { + traceId, + spanId: parentSpanId, + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + }; + + updatedCtx = trace.setSpanContext(updatedCtx, remoteSpanContext); + } + + return updatedCtx; + } + + public fields(): string[] { + return ['sentry-trace', 'baggage']; + } +} diff --git a/packages/node-core/test/light/integrations/otlp.test.ts b/packages/node-core/test/light/integrations/otlp.test.ts new file mode 100644 index 000000000000..485c0ed8121f --- /dev/null +++ b/packages/node-core/test/light/integrations/otlp.test.ts @@ -0,0 +1,248 @@ +import { defaultTextMapGetter, defaultTextMapSetter, propagation, ROOT_CONTEXT, trace } from '@opentelemetry/api'; +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { otlpIntegration } from '../../../src/light/integrations/otlp'; +import { cleanupLightSdk, mockLightSdkInit } from '../../helpers/mockLightSdkInit'; + +describe('Light Mode | otlpIntegration', () => { + afterEach(() => { + cleanupLightSdk(); + }); + + it('has correct integration name', () => { + const integration = otlpIntegration(); + expect(integration.name).toBe('OtlpIntegration'); + }); + + it('accepts empty options', () => { + const integration = otlpIntegration(); + expect(integration.name).toBe('OtlpIntegration'); + }); + + it('accepts all options', () => { + const integration = otlpIntegration({ + setupOtlpTracesExporter: false, + setupPropagator: false, + captureExceptions: true, + }); + expect(integration.name).toBe('OtlpIntegration'); + }); + + // Exception interceptor tests must run before other tests that call mockLightSdkInit, + // because the recordException patch is guarded by a prototype flag and can only be applied once. + describe('exception interceptor', () => { + let provider: BasicTracerProvider; + + beforeEach(() => { + provider = new BasicTracerProvider(); + trace.setGlobalTracerProvider(provider); + }); + + afterEach(() => { + provider.shutdown(); + trace.disable(); + }); + + it('captures exceptions from span.recordException when captureExceptions is true', () => { + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + + mockLightSdkInit({ + integrations: [ + otlpIntegration({ setupOtlpTracesExporter: false, setupPropagator: false, captureExceptions: true }), + ], + }); + + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test-span'); + const error = new Error('test exception'); + span.recordException(error); + span.end(); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.otlp.record_exception', handled: false }, + captureContext: { + contexts: { + trace: { + trace_id: span.spanContext().traceId, + span_id: span.spanContext().spanId, + }, + }, + }, + }); + + captureExceptionSpy.mockRestore(); + }); + + it('still calls original recordException', () => { + mockLightSdkInit({ + integrations: [ + otlpIntegration({ setupOtlpTracesExporter: false, setupPropagator: false, captureExceptions: true }), + ], + }); + + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test-span'); + span.recordException(new Error('test exception')); + + // The span should have the exception event recorded by the original implementation + const events = (span as unknown as { events: Array<{ name: string }> }).events; + expect(events.some(e => e.name === 'exception')).toBe(true); + + span.end(); + }); + }); + + describe('endpoint construction', () => { + it('constructs correct endpoint from DSN', () => { + const client = mockLightSdkInit({ + integrations: [otlpIntegration({ setupPropagator: false })], + }); + + const dsn = client?.getDsn(); + expect(dsn).toBeDefined(); + expect(dsn?.host).toBe('domain'); + expect(dsn?.projectId).toBe('123'); + }); + + it('handles DSN with port and path', () => { + const client = mockLightSdkInit({ + dsn: 'https://key@sentry.example.com:9000/mypath/456', + integrations: [otlpIntegration({ setupPropagator: false })], + }); + + const dsn = client?.getDsn(); + expect(dsn?.host).toBe('sentry.example.com'); + expect(dsn?.port).toBe('9000'); + expect(dsn?.path).toBe('mypath'); + expect(dsn?.projectId).toBe('456'); + }); + }); + + describe('auth header', () => { + it('constructs correct X-Sentry-Auth header format with sentry_client', () => { + const client = mockLightSdkInit({ + integrations: [otlpIntegration({ setupPropagator: false })], + }); + + const dsn = client?.getDsn(); + expect(dsn?.publicKey).toBe('username'); + + const sdkInfo = client?.getSdkMetadata()?.sdk; + expect(sdkInfo?.name).toBe('sentry.javascript.node-light'); + expect(sdkInfo?.version).toBeDefined(); + + const expectedAuth = `Sentry sentry_version=7, sentry_key=${dsn?.publicKey}, sentry_client=${sdkInfo?.name}/${sdkInfo?.version}`; + expect(expectedAuth).toMatch( + /^Sentry sentry_version=7, sentry_key=username, sentry_client=sentry\.javascript\.node-light\/.+$/, + ); + }); + }); + + describe('propagator', () => { + let provider: BasicTracerProvider; + + beforeEach(() => { + provider = new BasicTracerProvider(); + trace.setGlobalTracerProvider(provider); + }); + + afterEach(() => { + provider.shutdown(); + propagation.disable(); + trace.disable(); + }); + + it('injects sentry-trace header with sampled flag', () => { + mockLightSdkInit({ + integrations: [otlpIntegration({ setupOtlpTracesExporter: false })], + }); + + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test-span'); + const ctx = trace.setSpan(ROOT_CONTEXT, span); + + const carrier: Record = {}; + propagation.inject(ctx, carrier, defaultTextMapSetter); + + expect(carrier['sentry-trace']).toBeDefined(); + expect(carrier['sentry-trace']).toMatch(/^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/); + + span.end(); + }); + + it('extracts sentry-trace header into remote span context', () => { + mockLightSdkInit({ + integrations: [otlpIntegration({ setupOtlpTracesExporter: false })], + }); + + const carrier = { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + }; + + const extractedCtx = propagation.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + const spanContext = trace.getSpanContext(extractedCtx); + + expect(spanContext).toBeDefined(); + expect(spanContext?.traceId).toBe('12345678901234567890123456789012'); + expect(spanContext?.spanId).toBe('1234567890123456'); + expect(spanContext?.isRemote).toBe(true); + }); + + it('propagates sentry baggage through extract and inject', () => { + mockLightSdkInit({ + integrations: [otlpIntegration({ setupOtlpTracesExporter: false })], + }); + + const incomingCarrier = { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production,sentry-release=1.0.0', + }; + + // Extract incoming headers into OTel context + const extractedCtx = propagation.extract(ROOT_CONTEXT, incomingCarrier, defaultTextMapGetter); + + // Start a child span in the extracted context + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('child-span', {}, extractedCtx); + const spanCtx = trace.setSpan(extractedCtx, span); + + // Inject into outgoing carrier + const outgoingCarrier: Record = {}; + propagation.inject(spanCtx, outgoingCarrier, defaultTextMapSetter); + + expect(outgoingCarrier['baggage']).toBeDefined(); + expect(outgoingCarrier['baggage']).toContain('sentry-environment=production'); + expect(outgoingCarrier['baggage']).toContain('sentry-release=1.0.0'); + + span.end(); + }); + + it('does not inject baggage when no incoming sentry baggage', () => { + mockLightSdkInit({ + integrations: [otlpIntegration({ setupOtlpTracesExporter: false })], + }); + + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test-span'); + const ctx = trace.setSpan(ROOT_CONTEXT, span); + + const carrier: Record = {}; + propagation.inject(ctx, carrier, defaultTextMapSetter); + + expect(carrier['baggage']).toBeUndefined(); + + span.end(); + }); + + it('returns fields', () => { + mockLightSdkInit({ + integrations: [otlpIntegration({ setupOtlpTracesExporter: false })], + }); + + const fields = propagation.fields(); + expect(fields).toContain('sentry-trace'); + expect(fields).toContain('baggage'); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 21c9563e723e..1891f38694a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6225,6 +6225,17 @@ dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" +"@opentelemetry/exporter-trace-otlp-http@^0.211.0": + version "0.211.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.211.0.tgz#efe8a4ddcbc92aa011c5e416b1542bb7217f09cc" + integrity sha512-F1Rv3JeMkgS//xdVjbQMrI3+26e5SXC7vXA6trx8SWEA0OUhw4JHB+qeHtH0fJn46eFItrYbL5m8j4qi9Sfaxw== + dependencies: + "@opentelemetry/core" "2.5.0" + "@opentelemetry/otlp-exporter-base" "0.211.0" + "@opentelemetry/otlp-transformer" "0.211.0" + "@opentelemetry/resources" "2.5.0" + "@opentelemetry/sdk-trace-base" "2.5.0" + "@opentelemetry/instrumentation-amqplib@0.58.0": version "0.58.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz#e3dc86ebfa7d72fe861a63b1c24a062faeb64a8c" @@ -6460,11 +6471,40 @@ import-in-the-middle "^2.0.0" require-in-the-middle "^8.0.0" +"@opentelemetry/otlp-exporter-base@0.211.0": + version "0.211.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.211.0.tgz#4badad23c5880b53456fa42ecfa9711893d6c36c" + integrity sha512-bp1+63V8WPV+bRI9EQG6E9YID1LIHYSZVbp7f+44g9tRzCq+rtw/o4fpL5PC31adcUsFiz/oN0MdLISSrZDdrg== + dependencies: + "@opentelemetry/core" "2.5.0" + "@opentelemetry/otlp-transformer" "0.211.0" + +"@opentelemetry/otlp-transformer@0.211.0": + version "0.211.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.211.0.tgz#d01b5474719e3e9ffc4d01e372c28f7f9442fac5" + integrity sha512-julhCJ9dXwkOg9svuuYqqjXLhVaUgyUvO2hWbTxwjvLXX2rG3VtAaB0SzxMnGTuoCZizBT7Xqqm2V7+ggrfCXA== + dependencies: + "@opentelemetry/api-logs" "0.211.0" + "@opentelemetry/core" "2.5.0" + "@opentelemetry/resources" "2.5.0" + "@opentelemetry/sdk-logs" "0.211.0" + "@opentelemetry/sdk-metrics" "2.5.0" + "@opentelemetry/sdk-trace-base" "2.5.0" + protobufjs "8.0.0" + "@opentelemetry/redis-common@^0.38.2": version "0.38.2" resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz#cefa4f3e79db1cd54f19e233b7dfb56621143955" integrity sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA== +"@opentelemetry/resources@2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.5.0.tgz#e7a575b2c534961a9db5153f9498931c786a607a" + integrity sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g== + dependencies: + "@opentelemetry/core" "2.5.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/resources@2.5.1", "@opentelemetry/resources@^2.5.1": version "2.5.1" resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.5.1.tgz#90ccc27cea02b543f20a7db9834852ec11784c1a" @@ -6473,6 +6513,32 @@ "@opentelemetry/core" "2.5.1" "@opentelemetry/semantic-conventions" "^1.29.0" +"@opentelemetry/sdk-logs@0.211.0": + version "0.211.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-logs/-/sdk-logs-0.211.0.tgz#a307e3d4e419c5e2660f68f7f56f6044e073633d" + integrity sha512-O5nPwzgg2JHzo59kpQTPUOTzFi0Nv5LxryG27QoXBciX3zWM3z83g+SNOHhiQVYRWFSxoWn1JM2TGD5iNjOwdA== + dependencies: + "@opentelemetry/api-logs" "0.211.0" + "@opentelemetry/core" "2.5.0" + "@opentelemetry/resources" "2.5.0" + +"@opentelemetry/sdk-metrics@2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz#2b7785f3a8b2efbd5a6bf1342ed159656dc2af4a" + integrity sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA== + dependencies: + "@opentelemetry/core" "2.5.0" + "@opentelemetry/resources" "2.5.0" + +"@opentelemetry/sdk-trace-base@2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz#4b96ae2494a4de5e3bfb36ef7459b30a1ce3332a" + integrity sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ== + dependencies: + "@opentelemetry/core" "2.5.0" + "@opentelemetry/resources" "2.5.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/sdk-trace-base@^2.5.1": version "2.5.1" resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz#4f55f37e18ac3f971936d4717b6bfd43cfd72d61" @@ -9669,12 +9735,12 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=18": - version "22.10.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" - integrity sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ== +"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=13.7.0", "@types/node@>=18": + version "25.3.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.3.3.tgz#605862544ee7ffd7a936bcbf0135a14012f1e549" + integrity sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ== dependencies: - undici-types "~6.20.0" + undici-types "~7.18.0" "@types/node@^14.8.0": version "14.18.63" @@ -21036,10 +21102,10 @@ long@^4.0.0: resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== -long@^5.2.1: - version "5.2.3" - resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" - integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== +long@^5.0.0, long@^5.2.1: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== longest-streak@^3.0.0: version "3.1.0" @@ -25408,6 +25474,24 @@ property-information@^7.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== +protobufjs@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-8.0.0.tgz#d884102c1fe8d0b1e2493789ad37bc7ea47c0893" + integrity sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + proxy-addr@^2.0.7, proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -29403,10 +29487,10 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -undici-types@~6.20.0: - version "6.20.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" - integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== undici@7.18.2: version "7.18.2" From a771912865c7a47e2fe4c060f31940d66c16847b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 10 Mar 2026 15:32:11 +0100 Subject: [PATCH 4/5] Add E2E test app for node-core-light-otlp Co-Authored-By: Claude claude-opus-4-6 Agent transcript: https://claudescope.sentry.dev/share/e2REiiIp0G0cM0nRx0mrVwfUpEASPpwgY10OqDoN7vI --- .../node-core-light-otlp/.gitignore | 4 + .../node-core-light-otlp/.npmrc | 2 + .../node-core-light-otlp/package.json | 40 ++++++++ .../node-core-light-otlp/playwright.config.ts | 34 +++++++ .../node-core-light-otlp/src/app.ts | 99 +++++++++++++++++++ .../start-event-proxy.mjs | 6 ++ .../node-core-light-otlp/start-otel-proxy.mjs | 6 ++ .../node-core-light-otlp/tests/errors.test.ts | 50 ++++++++++ .../tests/otel-spans.test.ts | 16 +++ .../tests/request-isolation.test.ts | 60 +++++++++++ .../node-core-light-otlp/tsconfig.json | 18 ++++ 11 files changed, 335 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-otel-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/otel-spans.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/request-isolation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-otlp/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.gitignore new file mode 100644 index 000000000000..f5bd8548c7aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.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-core-light-otlp/package.json b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/package.json new file mode 100644 index 000000000000..fcf388cfaa89 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/package.json @@ -0,0 +1,40 @@ +{ + "name": "node-core-light-otlp-app", + "version": "1.0.0", + "private": true, + "type": "module", + "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.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", + "@opentelemetry/sdk-trace-base": "^2.5.1", + "@opentelemetry/sdk-trace-node": "^2.5.1", + "@sentry/node-core": "latest || *", + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "express": "^4.21.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "latest || *" + }, + "volta": { + "node": "22.18.0" + }, + "sentryTest": { + "variants": [ + { + "label": "node 22 (light mode + OTLP integration)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/playwright.config.ts new file mode 100644 index 000000000000..604e6d9e6861 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/playwright.config.ts @@ -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-core-light-otlp/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/src/app.ts new file mode 100644 index 000000000000..75c1b9934d8f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/src/app.ts @@ -0,0 +1,99 @@ +import { trace } from '@opentelemetry/api'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import * as Sentry from '@sentry/node-core/light'; +import { otlpIntegration } from '@sentry/node-core/light/otlp'; +import express from 'express'; + +const provider = new NodeTracerProvider({ + spanProcessors: [ + // The user's own exporter (sends to test proxy for verification) + new BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), + ], +}); + +provider.register(); + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + debug: true, + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', // Use event proxy for testing + integrations: [otlpIntegration({ captureExceptions: true })], +}); + +const app = express(); +const port = 3030; +const tracer = trace.getTracer('test-app'); + +app.get('/test-error', (_req, res) => { + Sentry.setTag('test', 'error'); + Sentry.captureException(new Error('Test error from light+otel')); + res.status(500).json({ error: 'Error captured' }); +}); + +app.get('/test-otel-span', (_req, res) => { + tracer.startActiveSpan('test-span', span => { + Sentry.captureException(new Error('Error inside OTel span')); + span.end(); + }); + + res.json({ ok: true }); +}); + +app.get('/test-isolation/:userId', async (req, res) => { + const userId = req.params.userId; + + // The light httpIntegration provides request isolation via diagnostics_channel. + // This should still work alongside the OTLP integration. + Sentry.setUser({ id: userId }); + Sentry.setTag('user_id', userId); + + // Simulate async work + await new Promise(resolve => setTimeout(resolve, Math.random() * 200 + 50)); + + const isolationScope = Sentry.getIsolationScope(); + const scopeData = isolationScope.getScopeData(); + + const isIsolated = scopeData.user?.id === userId && scopeData.tags?.user_id === userId; + + res.json({ + userId, + isIsolated, + scope: { + userId: scopeData.user?.id, + userIdTag: scopeData.tags?.user_id, + }, + }); +}); + +app.get('/test-isolation-error/:userId', (req, res) => { + const userId = req.params.userId; + Sentry.setTag('user_id', userId); + Sentry.setUser({ id: userId }); + + Sentry.captureException(new Error(`Error for user ${userId}`)); + res.json({ userId, captured: true }); +}); + +app.get('/test-record-exception', (_req, res) => { + tracer.startActiveSpan('span-with-exception', span => { + span.recordException(new Error('Recorded exception on span')); + span.end(); + }); + + res.json({ ok: true }); +}); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok' }); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-event-proxy.mjs new file mode 100644 index 000000000000..3e170b6311bd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-light-otlp', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-otel-proxy.mjs new file mode 100644 index 000000000000..d3f1d89b1149 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-core-light-otlp-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts new file mode 100644 index 000000000000..93368b4cc77c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('should capture errors with correct tags', async ({ request }) => { + const errorEventPromise = waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Test error from light+otel'; + }); + + const response = await request.get('/test-error'); + expect(response.status()).toBe(500); + + const errorEvent = await errorEventPromise; + expect(errorEvent).toBeDefined(); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from light+otel'); + expect(errorEvent.tags?.test).toBe('error'); +}); + +test('should link error events to the active OTel trace context', async ({ request }) => { + const errorEventPromise = waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error inside OTel span'; + }); + + await request.get('/test-otel-span'); + + const errorEvent = await errorEventPromise; + expect(errorEvent).toBeDefined(); + + // The error event should have trace context from the OTel span + expect(errorEvent.contexts?.trace).toBeDefined(); + expect(errorEvent.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/); + expect(errorEvent.contexts?.trace?.span_id).toMatch(/[a-f0-9]{16}/); +}); + +test('should capture exceptions from span.recordException()', async ({ request }) => { + const errorEventPromise = waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Recorded exception on span'; + }); + + await request.get('/test-record-exception'); + + const errorEvent = await errorEventPromise; + expect(errorEvent).toBeDefined(); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Recorded exception on span'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.otlp.record_exception'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false); + + // Should be linked to the OTel span's trace context + expect(errorEvent.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/); + expect(errorEvent.contexts?.trace?.span_id).toMatch(/[a-f0-9]{16}/); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/otel-spans.test.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/otel-spans.test.ts new file mode 100644 index 000000000000..b45c09e00b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/otel-spans.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForPlainRequest } from '@sentry-internal/test-utils'; + +test('User OTel exporter still receives spans', async ({ request }) => { + // The user's own OTel exporter sends spans to port 3032 (our test proxy). + // Verify that OTel span export still works alongside the Sentry OTLP integration. + const otelPromise = waitForPlainRequest('node-core-light-otlp-otel', data => { + const json = JSON.parse(data) as { resourceSpans: unknown[] }; + return json.resourceSpans.length > 0; + }); + + await request.get('/test-otel-span'); + + const otelData = await otelPromise; + expect(otelData).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/request-isolation.test.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/request-isolation.test.ts new file mode 100644 index 000000000000..3510e9f349bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/request-isolation.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('should isolate scope data across concurrent requests', async ({ request }) => { + const [response1, response2, response3] = await Promise.all([ + request.get('/test-isolation/user-1'), + request.get('/test-isolation/user-2'), + request.get('/test-isolation/user-3'), + ]); + + const data1 = await response1.json(); + const data2 = await response2.json(); + const data3 = await response3.json(); + + expect(data1.isIsolated).toBe(true); + expect(data1.userId).toBe('user-1'); + expect(data1.scope.userId).toBe('user-1'); + expect(data1.scope.userIdTag).toBe('user-1'); + + expect(data2.isIsolated).toBe(true); + expect(data2.userId).toBe('user-2'); + expect(data2.scope.userId).toBe('user-2'); + expect(data2.scope.userIdTag).toBe('user-2'); + + expect(data3.isIsolated).toBe(true); + expect(data3.userId).toBe('user-3'); + expect(data3.scope.userId).toBe('user-3'); + expect(data3.scope.userIdTag).toBe('user-3'); +}); + +test('should isolate errors across concurrent requests', async ({ request }) => { + const errorPromises = [ + waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error for user user-1'; + }), + waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error for user user-2'; + }), + waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error for user user-3'; + }), + ]; + + await Promise.all([ + request.get('/test-isolation-error/user-1'), + request.get('/test-isolation-error/user-2'), + request.get('/test-isolation-error/user-3'), + ]); + + const [error1, error2, error3] = await Promise.all(errorPromises); + + expect(error1?.user?.id).toBe('user-1'); + expect(error1?.tags?.user_id).toBe('user-1'); + + expect(error2?.user?.id).toBe('user-2'); + expect(error2?.tags?.user_id).toBe('user-2'); + + expect(error3?.user?.id).toBe('user-3'); + expect(error3?.tags?.user_id).toBe('user-3'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tsconfig.json new file mode 100644 index 000000000000..a2a82225afca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 2b22df30d01343612c04659168d5bf4d403219ab Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 10 Mar 2026 15:32:19 +0100 Subject: [PATCH 5/5] Add changelog entry for OTLP integration --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfe21fe38ba6..8223d1ccc695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + - **feat(nestjs): Instrument `@nestjs/schedule` decorators ([#19735](https://github.com/getsentry/sentry-javascript/pull/19735))** Automatically capture exceptions thrown in `@Cron`, `@Interval`, and `@Timeout` decorated methods. @@ -13,6 +15,40 @@ `auto.function.nestjs.cron`. If you have Sentry queries or alerts that filter on the old mechanism type, update them accordingly. +- **feat(node-core): Add OTLP integration for node-core/light ([#19729](https://github.com/getsentry/sentry-javascript/pull/19729))** + + Added `otlpIntegration` at `@sentry/node-core/light/otlp` for users who manage + their own OpenTelemetry setup and want to send trace data to Sentry without + adopting the full `@sentry/node` SDK. + + ```js + import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; + import * as Sentry from '@sentry/node-core/light'; + import { otlpIntegration } from '@sentry/node-core/light/otlp'; + + const provider = new NodeTracerProvider(); + provider.register(); + + Sentry.init({ + dsn: '__DSN__', + integrations: [ + otlpIntegration({ + // Export OTel spans to Sentry via OTLP (default: true) + setupOtlpTracesExporter: true, + + // Propagate sentry-trace/baggage headers (default: true) + setupPropagator: true, + + // Capture span.recordException() as Sentry errors (default: false) + captureExceptions: false, + }), + ], + }); + ``` + + The integration links Sentry errors to OTel traces, exports spans to Sentry via OTLP, and propagates + sentry-trace/baggage headers for distributed tracing. + ## 10.43.0 ### Important Changes