Skip to content
Merged
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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@

### Important Changes

- **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.

- **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))**

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.
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,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}`);
});
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,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}/);
});
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"]
}
2 changes: 1 addition & 1 deletion packages/core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading