-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(node-core): Add node-core/light #18502
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f86b68d
7ba3eb2
d305c83
deb6488
63b2863
01a4339
4e3785e
f1084ce
ed91667
96ecc88
9743db0
48c9f19
6abe083
0ddd14f
eae82cf
9433879
b1ef70d
efc79f8
e8ebcc3
e3c2355
2f793ce
1b398a8
89ce7f9
62497c6
b333218
4ac28d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| }); |
| 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 }) => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. m: can we still test againt the traceId in
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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-traceandbaggageheaders?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, added in 48c9f19.