Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f86b68d
feat(node-core): Add node-core/light entry point
andreiborza Jan 30, 2026
7ba3eb2
Add unit tests for node-core/light
andreiborza Jan 30, 2026
d305c83
Add e2e test app for node-core/light
andreiborza Jan 30, 2026
deb6488
Add changelog entry
andreiborza Jan 30, 2026
63b2863
Update CHANGELOG.md
andreiborza Feb 5, 2026
01a4339
Apply sdk name as sentry.javascript.node-light (with npm package
andreiborza Feb 6, 2026
4e3785e
Clarify jsdoc
andreiborza Feb 6, 2026
f1084ce
Remove light-mode message from http integration on incoming requests
andreiborza Feb 6, 2026
ed91667
Guard ipAddress with sendDefaultPii in light httpServerIntegration
andreiborza Feb 6, 2026
96ecc88
Move propagationSpanId assignment after continueTrace
andreiborza Feb 6, 2026
9743db0
Remove stale FIX comments from asyncLocalStorageStrategy
andreiborza Feb 6, 2026
48c9f19
Add e2e tests for trace continuation and trace isolation
andreiborza Feb 6, 2026
6abe083
Add integration test for trace continuation from incoming headers
andreiborza Feb 6, 2026
0ddd14f
Import MAX_BODY_BYTE_LENGTH from constants instead of redeclaring
andreiborza Feb 6, 2026
eae82cf
Remove deprecated exports and use eventFiltersIntegration
andreiborza Feb 6, 2026
9433879
Update README to not undersell light mode
andreiborza Feb 6, 2026
b1ef70d
Add integration tests for logs and metrics in light mode
andreiborza Feb 6, 2026
efc79f8
Extract common exports shared between main and light entry points
andreiborza Feb 6, 2026
e8ebcc3
Fix expected integrations test in node-core light
andreiborza Feb 9, 2026
e3c2355
Remove guarding of ip in http server integration
andreiborza Feb 9, 2026
2f793ce
Ensure spotlight is set up correctly for node-core/light too
andreiborza Feb 9, 2026
1b398a8
Add integration tests for trace propagation for outgoing http/fetch r…
andreiborza Feb 9, 2026
89ce7f9
Update changelog entry
andreiborza Feb 9, 2026
62497c6
Add vercel sigterm flush
andreiborza Feb 9, 2026
b333218
Update README with suggestions
andreiborza Feb 9, 2026
4ac28d5
Remove initializing loader hooks in light sdk
andreiborza Feb 9, 2026
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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

Work in this release was contributed by @limbonaut. Thank you for your contribution!

### Important Changes

- **feat(tanstackstart-react): Auto-instrument server function middleware ([#19001](https://github.com/getsentry/sentry-javascript/pull/19001))**

The `sentryTanstackStart` Vite plugin now automatically instruments middleware in `createServerFn().middleware([...])` calls. This captures performance data without requiring manual wrapping with `wrapMiddlewaresWithSentry()`.
Expand All @@ -25,6 +27,33 @@ export default withSentryConfig(nextConfig, {
});
```

- **feat(node-core): Add node-core/light ([#18502](https://github.com/getsentry/sentry-javascript/pull/18502))**

This release adds a new light-weight `@sentry/node-core/light` export to `@sentry/node-core`. The export acts as a light-weight SDK that does not depend on OpenTelemetry and emits no spans.

Use this SDK when:
- You only need error tracking, logs or metrics without tracing data (no spans)
- You want to minimize bundle size and runtime overhead
- You don't need spans emitted by OpenTelemetry instrumentation

It supports error tracking and reporting, logs, metrics, automatic request isolation (requires Node.js 22+) and basic tracing via our `Sentry.startSpan*` APIs.

Install the SDK by running

```bash
npm install @sentry/node-core
```

and add Sentry at the top of your application's entry file:

```js
import * as Sentry from '@sentry/node-core/light';

Sentry.init({
dsn: '__DSN__',
});
```

## 10.38.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,36 @@
{
"name": "node-core-light-express-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": {
"@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, requires Node 22+ for diagnostics_channel)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig({
startCommand: 'pnpm start',
port: 3030,
});

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import * as Sentry from '@sentry/node-core/light';
import express from 'express';

// IMPORTANT: Initialize Sentry BEFORE creating the Express app
// This is required for automatic request isolation to work
Sentry.init({
dsn: process.env.E2E_TEST_DSN,
debug: true,
tracesSampleRate: 1.0,
tunnel: 'http://localhost:3031/', // Use event proxy for testing
});

// Create Express app AFTER Sentry.init()
const app = express();
const port = 3030;

app.get('/test-error', (_req, res) => {
Sentry.setTag('test', 'error');
Sentry.captureException(new Error('Test error from light mode'));
res.status(500).json({ error: 'Error captured' });
});

app.get('/test-isolation/:userId', async (req, res) => {
const userId = req.params.userId;

const isolationScope = Sentry.getIsolationScope();
const currentScope = Sentry.getCurrentScope();

Sentry.setUser({ id: userId });
Sentry.setTag('user_id', userId);

currentScope.setTag('processing_user', userId);
currentScope.setContext('api_context', {
userId,
timestamp: Date.now(),
});

// Simulate async work with variance so we run into cases where
// the next request comes in before the async work is complete
// to showcase proper request isolation
await new Promise(resolve => setTimeout(resolve, Math.random() * 500 + 100));

// Verify isolation after async operations
const finalIsolationData = isolationScope.getScopeData();
const finalCurrentData = currentScope.getScopeData();

const isIsolated =
finalIsolationData.user?.id === userId &&
finalIsolationData.tags?.user_id === userId &&
finalCurrentData.contexts?.api_context?.userId === userId;

res.json({
userId,
isIsolated,
scope: {
userId: finalIsolationData.user?.id,
userIdTag: finalIsolationData.tags?.user_id,
currentUserId: finalCurrentData.contexts?.api_context?.userId,
},
});
});

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-trace-continuation', (_req, res) => {
Sentry.captureException(new Error('Trace continuation error'));
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-express',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test('should capture errors', async ({ request }) => {
const errorEventPromise = waitForError('node-core-light-express', event => {
return event?.exception?.values?.[0]?.value === 'Test error from light mode';
});

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 mode');
expect(errorEvent.tags?.test).toBe('error');

// Ensure IP address is not leaked when sendDefaultPii is not set
expect(errorEvent.user?.ip_address).toBeUndefined();
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: another test scenario I think we should cover: What happens when a service instrumented with node-code/light would receive incoming sentry-trace and baggage headers?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, added in 48c9f19.

Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import crypto from 'crypto';
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test('should isolate scope data across concurrent requests', async ({ request }) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: can we still test againt the traceId in event.contexts.trace? IIUC Node-light doesn't start any spans but it should still recycle trace ids for errors. Otherwise the product will show related errors that have nothing to do with each other.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! Added in 48c9f19.

// Make 3 concurrent requests with different user IDs
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();

// Each response should be properly isolated
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(data1.scope.currentUserId).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(data2.scope.currentUserId).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');
expect(data3.scope.currentUserId).toBe('user-3');
});

test('should isolate errors across concurrent requests', async ({ request }) => {
const errorPromises = [
waitForError('node-core-light-express', event => {
return event?.exception?.values?.[0]?.value === 'Error for user user-1';
}),
waitForError('node-core-light-express', event => {
return event?.exception?.values?.[0]?.value === 'Error for user user-2';
}),
waitForError('node-core-light-express', event => {
return event?.exception?.values?.[0]?.value === 'Error for user user-3';
}),
];

// Make 3 concurrent requests that trigger errors
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);

// Each error should have the correct user data
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');

// Each error should have a trace context with a trace_id
const traceId1 = error1?.contexts?.trace?.trace_id;
const traceId2 = error2?.contexts?.trace?.trace_id;
const traceId3 = error3?.contexts?.trace?.trace_id;

expect(traceId1).toBeDefined();
expect(traceId2).toBeDefined();
expect(traceId3).toBeDefined();

// Trace IDs from different requests should be different (isolation)
expect(traceId1).not.toBe(traceId2);
expect(traceId1).not.toBe(traceId3);
expect(traceId2).not.toBe(traceId3);
});

test('should continue trace from incoming sentry-trace and baggage headers', async ({ request }) => {
const traceId = crypto.randomUUID().replace(/-/g, '');
const parentSpanId = traceId.substring(0, 16);

const errorPromise = waitForError('node-core-light-express', event => {
return event?.exception?.values?.[0]?.value === 'Trace continuation error';
});

await request.get('/test-trace-continuation', {
headers: {
'sentry-trace': `${traceId}-${parentSpanId}-1`,
baggage: `sentry-trace_id=${traceId},sentry-environment=test,sentry-public_key=public`,
},
});

const error = await errorPromise;

// The error should inherit the trace ID from the incoming sentry-trace header
expect(error?.contexts?.trace?.trace_id).toBe(traceId);
expect(error?.contexts?.trace?.parent_span_id).toBe(parentSpanId);
});
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"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { afterAll, expect, test } from 'vitest';
import { conditionalTest } from '../../../utils';
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';

afterAll(() => {
cleanupChildProcesses();
});

conditionalTest({ min: 22 })('light mode ipAddress handling', () => {
test('does not include ip_address on events when sendDefaultPii is not set', async () => {
const runner = createRunner(__dirname, 'without-sendDefaultPii/server.js')
.expect({
event: event => {
expect(event.exception?.values?.[0]?.value).toBe('test error');
expect(event.user?.ip_address).toBeUndefined();
},
})
.start();

runner.makeRequest('get', '/test-error');
await runner.completed();
});

test('includes ip_address on events when sendDefaultPii is true', async () => {
const runner = createRunner(__dirname, 'with-sendDefaultPii/server.js')
.expect({
event: event => {
expect(event.exception?.values?.[0]?.value).toBe('test error');
expect(event.user?.ip_address).toBeDefined();
},
})
.start();

runner.makeRequest('get', '/test-error');
await runner.completed();
});

// Even with sendDefaultPii: true, if requestDataIntegration is removed, ipAddress should not
// leak onto the event. The ipAddress is stored in sdkProcessingMetadata on the isolation scope,
// and only requestDataIntegration promotes it to event.user.ip_address. Without it,
// sdkProcessingMetadata is stripped before envelope serialization (in envelope.ts).
test('does not include ip_address on events when requestDataIntegration is removed', async () => {
const runner = createRunner(__dirname, 'without-requestDataIntegration/server.js')
.expect({
event: event => {
expect(event.exception?.values?.[0]?.value).toBe('test error');
expect(event.user?.ip_address).toBeUndefined();
},
})
.start();

runner.makeRequest('get', '/test-error');
await runner.completed();
});
});
Loading
Loading