From 1c9780e93575ec2676d0533e7fe4ab86c0de8024 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 13 Mar 2026 16:48:07 +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({ // Export OTel spans to Sentry via OTLP (default: true) setupOtlpTracesExporter: true, // Send traces to a custom collector instead of the DSN-derived endpoint (default: undefined) collectorUrl: 'https://my-collector.example.com/v1/traces', }), ], }); ``` The integration links Sentry errors to OTel traces and exports spans to Sentry via OTLP. Co-Authored-By: Claude claude-opus-4-6 From 64cf942c5c8819d24b71900a170d75cc32698ba2 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 13 Mar 2026 16:48:29 +0100 Subject: [PATCH 2/5] Add external propagation context support and export SENTRY_API_VERSION --- 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 4431f598c42a579d4a817cd44610fc2819d6d92e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 13 Mar 2026 16:50:20 +0100 Subject: [PATCH 3/5] Add otlpIntegration for @sentry/node-core/light/otlp --- packages/node-core/package.json | 17 ++- packages/node-core/rollup.npm.config.mjs | 2 +- .../src/light/integrations/otlpIntegration.ts | 142 ++++++++++++++++++ .../integrations/otlpIntegration.test.ts | 73 +++++++++ yarn.lock | 91 +++++++++-- 5 files changed, 311 insertions(+), 14 deletions(-) create mode 100644 packages/node-core/src/light/integrations/otlpIntegration.ts create mode 100644 packages/node-core/test/light/integrations/otlpIntegration.test.ts diff --git a/packages/node-core/package.json b/packages/node-core/package.json index f49f700535e8..203fce415f71 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/otlpIntegration.d.ts", + "default": "./build/esm/light/integrations/otlpIntegration.js" + }, + "require": { + "types": "./build/types/light/integrations/otlpIntegration.d.ts", + "default": "./build/cjs/light/integrations/otlpIntegration.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": { @@ -107,6 +121,7 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", "@opentelemetry/instrumentation": "^0.213.0", "@opentelemetry/resources": "^2.6.0", "@opentelemetry/sdk-trace-base": "^2.6.0", diff --git a/packages/node-core/rollup.npm.config.mjs b/packages/node-core/rollup.npm.config.mjs index 9bae67fd2dd8..9fa0a1fb19b9 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/otlpIntegration.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/packages/node-core/src/light/integrations/otlpIntegration.ts b/packages/node-core/src/light/integrations/otlpIntegration.ts new file mode 100644 index 000000000000..3f4507813525 --- /dev/null +++ b/packages/node-core/src/light/integrations/otlpIntegration.ts @@ -0,0 +1,142 @@ +import { trace } 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 { debug, defineIntegration, 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; + + /** + * URL of your own OpenTelemetry collector. + * When set, the exporter will send traces to this URL instead of the Sentry OTLP endpoint derived from the DSN. + * Default: undefined (uses DSN-derived endpoint) + */ + collectorUrl?: string; +} + +const INTEGRATION_NAME = 'OtlpIntegration'; + +const _otlpIntegration = ((userOptions: OtlpIntegrationOptions = {}) => { + const options = { + setupOtlpTracesExporter: userOptions.setupOtlpTracesExporter ?? true, + collectorUrl: userOptions.collectorUrl, + }; + + 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); + } + }, + }; + + function setupTracesExporter(client: Client): void { + let endpoint: string; + let headers: Record | undefined; + + if (options.collectorUrl) { + endpoint = options.collectorUrl; + debug.log(`[${INTEGRATION_NAME}] Sending traces to collector at ${endpoint}`); + } else { + 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}` : ''; + 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}` : ''; + headers = { + 'X-Sentry-Auth': `Sentry sentry_version=${SENTRY_API_VERSION}, sentry_key=${publicKey}${sentryClient}`, + }; + } + + let exporter: SpanExporter; + try { + exporter = new OTLPTraceExporter({ + url: endpoint, + headers, + }); + } 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(); + }); + } +}) 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 (or to a custom collector) + */ +export const otlpIntegration = defineIntegration(_otlpIntegration); diff --git a/packages/node-core/test/light/integrations/otlpIntegration.test.ts b/packages/node-core/test/light/integrations/otlpIntegration.test.ts new file mode 100644 index 000000000000..8d40bfad18cf --- /dev/null +++ b/packages/node-core/test/light/integrations/otlpIntegration.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { otlpIntegration } from '../../../src/light/integrations/otlpIntegration'; +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, + collectorUrl: 'https://my-collector.example.com/v1/traces', + }); + expect(integration.name).toBe('OtlpIntegration'); + }); + + describe('endpoint construction', () => { + it('constructs correct endpoint from DSN', () => { + const client = mockLightSdkInit({ + integrations: [otlpIntegration()], + }); + + 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()], + }); + + 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()], + }); + + 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\/.+$/, + ); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index c7f7d47befef..124189050dd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6218,6 +6218,17 @@ dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" +"@opentelemetry/exporter-trace-otlp-http@^0.213.0": + version "0.213.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.213.0.tgz#7bba861a71787361b83a03746ed4bf5c18048775" + integrity sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA== + dependencies: + "@opentelemetry/core" "2.6.0" + "@opentelemetry/otlp-exporter-base" "0.213.0" + "@opentelemetry/otlp-transformer" "0.213.0" + "@opentelemetry/resources" "2.6.0" + "@opentelemetry/sdk-trace-base" "2.6.0" + "@opentelemetry/instrumentation-amqplib@0.60.0": version "0.60.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.60.0.tgz#a2b2abe3cf433bea166c18a703c8ddf6accf83da" @@ -6453,6 +6464,27 @@ import-in-the-middle "^2.0.6" require-in-the-middle "^8.0.0" +"@opentelemetry/otlp-exporter-base@0.213.0": + version "0.213.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.213.0.tgz#e9a7c1dfaecc2573b9c5fbcd7ccc0086513c1350" + integrity sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg== + dependencies: + "@opentelemetry/core" "2.6.0" + "@opentelemetry/otlp-transformer" "0.213.0" + +"@opentelemetry/otlp-transformer@0.213.0": + version "0.213.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz#e830244d21817805b8967963ffc4651b8f5c96ee" + integrity sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw== + dependencies: + "@opentelemetry/api-logs" "0.213.0" + "@opentelemetry/core" "2.6.0" + "@opentelemetry/resources" "2.6.0" + "@opentelemetry/sdk-logs" "0.213.0" + "@opentelemetry/sdk-metrics" "2.6.0" + "@opentelemetry/sdk-trace-base" "2.6.0" + protobufjs "^7.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" @@ -6466,7 +6498,25 @@ "@opentelemetry/core" "2.6.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-trace-base@^2.6.0": +"@opentelemetry/sdk-logs@0.213.0": + version "0.213.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz#babf51dfd3e2bc882a41a0de2a13a2077d6df764" + integrity sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g== + dependencies: + "@opentelemetry/api-logs" "0.213.0" + "@opentelemetry/core" "2.6.0" + "@opentelemetry/resources" "2.6.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-metrics@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz#c9f63eb68a5c7600a4ffc84bdce3ef59c9b1af47" + integrity sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw== + dependencies: + "@opentelemetry/core" "2.6.0" + "@opentelemetry/resources" "2.6.0" + +"@opentelemetry/sdk-trace-base@2.6.0", "@opentelemetry/sdk-trace-base@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz#d7e752a0906f2bcae3c1261e224aef3e3b3746f9" integrity sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ== @@ -9662,12 +9712,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" @@ -21039,7 +21089,7 @@ long@^4.0.0: resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== -long@^5.3.2: +long@^5.0.0, long@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== @@ -25410,6 +25460,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@^7.0.0: + version "7.5.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" + integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== + 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" @@ -28294,7 +28362,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" @@ -29400,10 +29467,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 33f4e09def9044a003c07814f74485da6a07d1e5 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 13 Mar 2026 16:51:25 +0100 Subject: [PATCH 4/5] Add E2E test app for node-core-light-otlp --- .../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 | 90 +++++++++++++++++++ .../start-event-proxy.mjs | 6 ++ .../node-core-light-otlp/start-otel-proxy.mjs | 6 ++ .../node-core-light-otlp/tests/errors.test.ts | 32 +++++++ .../tests/otel-spans.test.ts | 16 ++++ .../tests/request-isolation.test.ts | 60 +++++++++++++ .../node-core-light-otlp/tsconfig.json | 18 ++++ 11 files changed, 308 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..d8cb48eab19c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/src/app.ts @@ -0,0 +1,90 @@ +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()], +}); + +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('/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..9dd6b76a5e15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts @@ -0,0 +1,32 @@ +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}/); +}); 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 7cc6de292f55bc939a6f43e43a2960826f41f7e7 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 13 Mar 2026 16:51:49 +0100 Subject: [PATCH 5/5] Add changelog entry for OTLP integration --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b5452514ffa..37d295a827d4 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/bullmq` `@Processor` decorator** Automatically capture exceptions and create transactions for BullMQ queue processors in NestJS applications. @@ -48,6 +50,33 @@ changes. We cannot yet guarantee full support for server-islands, due to a [bug in Astro v6](https://github.com/withastro/astro/issues/15753) but we'll follow up on this once the bug is fixed. +- **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, + }), + ], + }); + ``` + + The integration links Sentry errors to OTel traces and exports spans to Sentry via OTLP. + ## 10.43.0 ### Important Changes