Skip to content
Open
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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
.env
pnpm-lock.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@sentry:registry=http://127.0.0.1:4873
@sentry-internal:registry=http://127.0.0.1:4873
Original file line number Diff line number Diff line change
@@ -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)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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}`);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'node-core-light-otlp',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startProxyServer } from '@sentry-internal/test-utils';

startProxyServer({
port: 3032,
proxyServerName: 'node-core-light-otlp-otel',
});
Original file line number Diff line number Diff line change
@@ -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}/);
});
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
@@ -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');
});
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading
Loading