Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions packages/node-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,7 @@
"@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/exporter-trace-otlp-http": ">=0.57.0 <1"
"@opentelemetry/semantic-conventions": "^1.39.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
Expand All @@ -103,9 +102,6 @@
},
"@opentelemetry/semantic-conventions": {
"optional": true
},
"@opentelemetry/exporter-trace-otlp-http": {
"optional": true
}
},
"dependencies": {
Expand All @@ -116,7 +112,6 @@
"devDependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/core": "^2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/instrumentation": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-trace-base": "^2.6.1",
Expand Down
144 changes: 32 additions & 112 deletions packages/node-core/src/light/integrations/otlpIntegration.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,16 @@
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;
}
import {
debug,
defineIntegration,
dsnFromString,
registerExternalPropagationContext,
SENTRY_API_VERSION,
} from '@sentry/core';

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;

const _otlpIntegration = (() => {
return {
name: INTEGRATION_NAME,

Expand All @@ -48,95 +28,35 @@ const _otlpIntegration = ((userOptions: OtlpIntegrationOptions = {}) => {

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<string, string> | 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<string, unknown>)?._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)
* Bridges an existing OpenTelemetry setup with Sentry by linking Sentry
* error/log events to the active OTel trace context.
*/
export const otlpIntegration = defineIntegration(_otlpIntegration);

/**
* Returns the OTLP traces endpoint URL and auth headers for a given Sentry DSN.
* Use this to configure your own `OTLPTraceExporter`.
*/
export function getOtlpTracesEndpoint(dsn: string): { url: string; headers: Record<string, string> } | undefined {
const parsed = dsnFromString(dsn);
if (!parsed) {
return undefined;
}

const { protocol, host, port, path, projectId, publicKey } = parsed;
const basePath = path ? `/${path}` : '';
const portStr = port ? `:${port}` : '';

return {
url: `${protocol}://${host}${portStr}${basePath}/api/${projectId}/integration/otlp/v1/traces/`,
headers: {
'X-Sentry-Auth': `Sentry sentry_version=${SENTRY_API_VERSION}, sentry_key=${publicKey}`,
},
};
}
77 changes: 29 additions & 48 deletions packages/node-core/test/light/integrations/otlpIntegration.test.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,54 @@
import { hasExternalPropagationContext, registerExternalPropagationContext } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { otlpIntegration } from '../../../src/light/integrations/otlpIntegration';
import { getOtlpTracesEndpoint, otlpIntegration } from '../../../src/light/integrations/otlpIntegration';
import { cleanupLightSdk, mockLightSdkInit } from '../../helpers/mockLightSdkInit';

describe('Light Mode | otlpIntegration', () => {
afterEach(() => {
cleanupLightSdk();
// Reset external propagation context
registerExternalPropagationContext(() => undefined);
});

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',
it('registers external propagation context on setup', () => {
mockLightSdkInit({
integrations: [otlpIntegration()],
});
expect(integration.name).toBe('OtlpIntegration');

expect(hasExternalPropagationContext()).toBe(true);
});
});

describe('endpoint construction', () => {
it('constructs correct endpoint from DSN', () => {
const client = mockLightSdkInit({
integrations: [otlpIntegration()],
});
describe('getOtlpTracesEndpoint', () => {
it('returns correct endpoint and headers from DSN', () => {
const result = getOtlpTracesEndpoint('https://abc123@o0.ingest.sentry.io/456');

const dsn = client?.getDsn();
expect(dsn).toBeDefined();
expect(dsn?.host).toBe('domain');
expect(dsn?.projectId).toBe('123');
expect(result).toEqual({
url: 'https://o0.ingest.sentry.io/api/456/integration/otlp/v1/traces/',
headers: {
'X-Sentry-Auth': 'Sentry sentry_version=7, sentry_key=abc123',
},
});
});

it('handles DSN with port and path', () => {
const client = mockLightSdkInit({
dsn: 'https://key@sentry.example.com:9000/mypath/456',
integrations: [otlpIntegration()],
});
it('handles DSN with port and path', () => {
const result = getOtlpTracesEndpoint('https://key@sentry.example.com:9000/mypath/789');

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');
expect(result).toEqual({
url: 'https://sentry.example.com:9000/mypath/api/789/integration/otlp/v1/traces/',
headers: {
'X-Sentry-Auth': 'Sentry sentry_version=7, sentry_key=key',
},
});
});

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\/.+$/,
);
});
it('returns undefined for invalid DSN', () => {
const result = getOtlpTracesEndpoint('not-a-dsn');
expect(result).toBeUndefined();
});
});
Loading
Loading