Skip to content

Commit 2a8c1d6

Browse files
andreiborzaclaude
andauthored
feat(node-core): Add OTLP integration for node-core/light (#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, // 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. <hr> Split up for easier reviewing: External propagation context support: 1ec99378b5 OTLP integration: 70d58adff4 E2E test app: 19904655a2 CHANGELOG entry: b43c9de861 --------- Co-authored-by: Claude claude-opus-4-6 <noreply@anthropic.com>
1 parent c8e56ff commit 2a8c1d6

File tree

23 files changed

+818
-12
lines changed

23 files changed

+818
-12
lines changed

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,33 @@
44

55
### Important Changes
66

7+
- **feat(node-core): Add OTLP integration for node-core/light ([#19729](https://github.com/getsentry/sentry-javascript/pull/19729))**
8+
9+
Added `otlpIntegration` at `@sentry/node-core/light/otlp` for users who manage
10+
their own OpenTelemetry setup and want to send trace data to Sentry without
11+
adopting the full `@sentry/node` SDK.
12+
13+
```js
14+
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
15+
import * as Sentry from '@sentry/node-core/light';
16+
import { otlpIntegration } from '@sentry/node-core/light/otlp';
17+
18+
const provider = new NodeTracerProvider();
19+
provider.register();
20+
21+
Sentry.init({
22+
dsn: '__DSN__',
23+
integrations: [
24+
otlpIntegration({
25+
// Export OTel spans to Sentry via OTLP (default: true)
26+
setupOtlpTracesExporter: true,
27+
}),
28+
],
29+
});
30+
```
31+
32+
The integration links Sentry errors to OTel traces and exports spans to Sentry via OTLP.
33+
734
- **feat(node, bun): Add runtime metrics integrations for Node.js and Bun ([#19923](https://github.com/getsentry/sentry-javascript/pull/19923), [#19979](https://github.com/getsentry/sentry-javascript/pull/19979))**
835

936
New `nodeRuntimeMetricsIntegration` and `bunRuntimeMetricsIntegration` automatically collect runtime health metrics and send them to Sentry on a configurable interval (default: 30s). Collected metrics include memory (RSS, heap used/total), CPU utilization, event loop utilization, and process uptime. Node additionally collects event loop delay percentiles (p50, p99). Extra metrics like CPU time and external memory are available as opt-in.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules
2+
dist
3+
.env
4+
pnpm-lock.yaml
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"name": "node-core-light-otlp-app",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"build": "tsc",
8+
"start": "node dist/app.js",
9+
"test": "playwright test",
10+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
11+
"test:build": "pnpm install && pnpm build",
12+
"test:assert": "pnpm test"
13+
},
14+
"dependencies": {
15+
"@opentelemetry/api": "^1.9.0",
16+
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
17+
"@opentelemetry/sdk-trace-base": "^2.5.1",
18+
"@opentelemetry/sdk-trace-node": "^2.5.1",
19+
"@sentry/node-core": "latest || *",
20+
"@types/express": "^4.17.21",
21+
"@types/node": "^22.0.0",
22+
"express": "^4.21.2",
23+
"typescript": "~5.0.0"
24+
},
25+
"devDependencies": {
26+
"@playwright/test": "~1.56.0",
27+
"@sentry-internal/test-utils": "link:../../../test-utils",
28+
"@sentry/core": "latest || *"
29+
},
30+
"volta": {
31+
"node": "22.18.0"
32+
},
33+
"sentryTest": {
34+
"variants": [
35+
{
36+
"label": "node 22 (light mode + OTLP integration)"
37+
}
38+
]
39+
}
40+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig(
4+
{
5+
startCommand: 'pnpm start',
6+
},
7+
{
8+
webServer: [
9+
{
10+
command: 'node ./start-event-proxy.mjs',
11+
port: 3031,
12+
stdout: 'pipe',
13+
stderr: 'pipe',
14+
},
15+
{
16+
command: 'node ./start-otel-proxy.mjs',
17+
port: 3032,
18+
stdout: 'pipe',
19+
stderr: 'pipe',
20+
},
21+
{
22+
command: 'pnpm start',
23+
port: 3030,
24+
stdout: 'pipe',
25+
stderr: 'pipe',
26+
env: {
27+
PORT: '3030',
28+
},
29+
},
30+
],
31+
},
32+
);
33+
34+
export default config;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { trace } from '@opentelemetry/api';
2+
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
3+
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
4+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
5+
import * as Sentry from '@sentry/node-core/light';
6+
import { otlpIntegration } from '@sentry/node-core/light/otlp';
7+
import express from 'express';
8+
9+
const provider = new NodeTracerProvider({
10+
spanProcessors: [
11+
// The user's own exporter (sends to test proxy for verification)
12+
new BatchSpanProcessor(
13+
new OTLPTraceExporter({
14+
url: 'http://localhost:3032/',
15+
}),
16+
),
17+
],
18+
});
19+
20+
provider.register();
21+
22+
Sentry.init({
23+
dsn: process.env.E2E_TEST_DSN,
24+
debug: true,
25+
tracesSampleRate: 1.0,
26+
tunnel: 'http://localhost:3031/', // Use event proxy for testing
27+
integrations: [otlpIntegration()],
28+
});
29+
30+
const app = express();
31+
const port = 3030;
32+
const tracer = trace.getTracer('test-app');
33+
34+
app.get('/test-error', (_req, res) => {
35+
Sentry.setTag('test', 'error');
36+
Sentry.captureException(new Error('Test error from light+otel'));
37+
res.status(500).json({ error: 'Error captured' });
38+
});
39+
40+
app.get('/test-otel-span', (_req, res) => {
41+
tracer.startActiveSpan('test-span', span => {
42+
Sentry.captureException(new Error('Error inside OTel span'));
43+
span.end();
44+
});
45+
46+
res.json({ ok: true });
47+
});
48+
49+
app.get('/test-isolation/:userId', async (req, res) => {
50+
const userId = req.params.userId;
51+
52+
// The light httpIntegration provides request isolation via diagnostics_channel.
53+
// This should still work alongside the OTLP integration.
54+
Sentry.setUser({ id: userId });
55+
Sentry.setTag('user_id', userId);
56+
57+
// Simulate async work
58+
await new Promise(resolve => setTimeout(resolve, Math.random() * 200 + 50));
59+
60+
const isolationScope = Sentry.getIsolationScope();
61+
const scopeData = isolationScope.getScopeData();
62+
63+
const isIsolated = scopeData.user?.id === userId && scopeData.tags?.user_id === userId;
64+
65+
res.json({
66+
userId,
67+
isIsolated,
68+
scope: {
69+
userId: scopeData.user?.id,
70+
userIdTag: scopeData.tags?.user_id,
71+
},
72+
});
73+
});
74+
75+
app.get('/test-isolation-error/:userId', (req, res) => {
76+
const userId = req.params.userId;
77+
Sentry.setTag('user_id', userId);
78+
Sentry.setUser({ id: userId });
79+
80+
Sentry.captureException(new Error(`Error for user ${userId}`));
81+
res.json({ userId, captured: true });
82+
});
83+
84+
app.get('/health', (_req, res) => {
85+
res.json({ status: 'ok' });
86+
});
87+
88+
app.listen(port, () => {
89+
console.log(`Example app listening on port ${port}`);
90+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'node-core-light-otlp',
6+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startProxyServer } from '@sentry-internal/test-utils';
2+
3+
startProxyServer({
4+
port: 3032,
5+
proxyServerName: 'node-core-light-otlp-otel',
6+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
4+
test('should capture errors with correct tags', async ({ request }) => {
5+
const errorEventPromise = waitForError('node-core-light-otlp', event => {
6+
return event?.exception?.values?.[0]?.value === 'Test error from light+otel';
7+
});
8+
9+
const response = await request.get('/test-error');
10+
expect(response.status()).toBe(500);
11+
12+
const errorEvent = await errorEventPromise;
13+
expect(errorEvent).toBeDefined();
14+
expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from light+otel');
15+
expect(errorEvent.tags?.test).toBe('error');
16+
});
17+
18+
test('should link error events to the active OTel trace context', async ({ request }) => {
19+
const errorEventPromise = waitForError('node-core-light-otlp', event => {
20+
return event?.exception?.values?.[0]?.value === 'Error inside OTel span';
21+
});
22+
23+
await request.get('/test-otel-span');
24+
25+
const errorEvent = await errorEventPromise;
26+
expect(errorEvent).toBeDefined();
27+
28+
// The error event should have trace context from the OTel span
29+
expect(errorEvent.contexts?.trace).toBeDefined();
30+
expect(errorEvent.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/);
31+
expect(errorEvent.contexts?.trace?.span_id).toMatch(/[a-f0-9]{16}/);
32+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForPlainRequest } from '@sentry-internal/test-utils';
3+
4+
test('User OTel exporter still receives spans', async ({ request }) => {
5+
// The user's own OTel exporter sends spans to port 3032 (our test proxy).
6+
// Verify that OTel span export still works alongside the Sentry OTLP integration.
7+
const otelPromise = waitForPlainRequest('node-core-light-otlp-otel', data => {
8+
const json = JSON.parse(data) as { resourceSpans: unknown[] };
9+
return json.resourceSpans.length > 0;
10+
});
11+
12+
await request.get('/test-otel-span');
13+
14+
const otelData = await otelPromise;
15+
expect(otelData).toBeDefined();
16+
});

0 commit comments

Comments
 (0)