diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-express/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-light-express/.gitignore new file mode 100644 index 000000000000..f5bd8548c7aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-express/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-express/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-light-express/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-express/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-express/package.json b/dev-packages/e2e-tests/test-applications/node-core-light-express/package.json new file mode 100644 index 000000000000..d1902f528561 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-express/package.json @@ -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)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-express/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-core-light-express/playwright.config.ts new file mode 100644 index 000000000000..b52ff06a5105 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-express/playwright.config.ts @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: 'pnpm start', + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-express/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-light-express/src/app.ts new file mode 100644 index 000000000000..d00d01eaa23d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-express/src/app.ts @@ -0,0 +1,78 @@ +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('/health', (_req, res) => { + res.json({ status: 'ok' }); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-express/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-light-express/start-event-proxy.mjs new file mode 100644 index 000000000000..3bba4670fcff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-express/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-light-express', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-express/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-light-express/tests/errors.test.ts new file mode 100644 index 000000000000..ecc90638b97c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-express/tests/errors.test.ts @@ -0,0 +1,16 @@ +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'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-express/tests/request-isolation.test.ts b/dev-packages/e2e-tests/test-applications/node-core-light-express/tests/request-isolation.test.ts new file mode 100644 index 000000000000..0e8cdc78ed16 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-express/tests/request-isolation.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('should isolate scope data across concurrent requests', async ({ request }) => { + // 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'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-express/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-light-express/tsconfig.json new file mode 100644 index 000000000000..a2a82225afca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-express/tsconfig.json @@ -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"] +} diff --git a/packages/node-core/README.md b/packages/node-core/README.md index fa3bd7946ec0..6ef4f4ef41a9 100644 --- a/packages/node-core/README.md +++ b/packages/node-core/README.md @@ -116,6 +116,117 @@ If it is not possible for you to pass the `--import` flag to the Node.js binary, NODE_OPTIONS="--import ./instrument.mjs" npm run start ``` +## Errors-only Lightweight Mode + +If you only need error monitoring without performance tracing, you can use the lightweight mode which doesn't require OpenTelemetry dependencies. This mode is ideal for: + +- Applications that only need error tracking +- Reducing bundle size and runtime overhead +- Environments where OpenTelemetry isn't needed + +### Installation (Light Mode) + +```bash +npm install @sentry/node-core + +# Or yarn +yarn add @sentry/node-core +``` + +### Usage (Light Mode) + +Import from `@sentry/node-core/light` instead of `@sentry/node-core`: + +```js +// ESM +import * as Sentry from '@sentry/node-core/light'; + +// CJS +const Sentry = require('@sentry/node-core/light'); + +// Initialize Sentry BEFORE creating your HTTP server +Sentry.init({ + dsn: '__DSN__', + // ... +}); + +// Then create your server (Express, Fastify, etc.) +const app = express(); +``` + +**Important:** Initialize Sentry **before** creating your HTTP server to enable automatic request isolation. + +### Features in Light Mode + +**Included:** + +- Error tracking and reporting +- Automatic request isolation (Node.js 22+) +- Breadcrumbs +- Context and user data +- Local variables capture +- Distributed tracing (via `sentry-trace` and `baggage` headers) + +**Not included:** + +- Performance monitoring (no spans/transactions) + +### Automatic Request Isolation + +Light mode includes automatic request isolation for HTTP servers (requires Node.js 22+). This ensures that context (tags, user data, breadcrumbs) set during a request doesn't leak to other concurrent requests. + +No manual middleware or `--import` flag is required - just initialize Sentry before creating your server: + +```js +import * as Sentry from '@sentry/node-core/light'; +import express from 'express'; + +// Initialize FIRST +Sentry.init({ dsn: '__DSN__' }); + +// Then create server +const app = express(); + +app.get('/error', (req, res) => { + // This data is automatically isolated per request + Sentry.setTag('userId', req.params.id); + Sentry.captureException(new Error('Something went wrong')); + res.status(500).send('Error'); +}); +``` + +### Manual Request Isolation (Node.js < 22) + +If you're using Node.js versions below 22, automatic request isolation is not available. You'll need to manually wrap your request handlers with `withIsolationScope`: + +```js +import * as Sentry from '@sentry/node-core/light'; +import express from 'express'; + +Sentry.init({ dsn: '__DSN__' }); + +const app = express(); + +// Add middleware to manually isolate requests +app.use((req, res, next) => { + Sentry.withIsolationScope(() => { + next(); + }); +}); + +app.get('/error', (req, res) => { + Sentry.setTag('userId', req.params.id); + Sentry.captureException(new Error('Something went wrong')); + res.status(500).send('Error'); +}); +``` + +**Caveats:** + +- Manual isolation prevents scope data leakage between requests +- However, **distributed tracing will not work correctly** - incoming `sentry-trace` and `baggage` headers won't be automatically extracted and propagated +- For full distributed tracing support, use Node.js 22+ or the full `@sentry/node` SDK with OpenTelemetry + ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 36082c44eb81..8e50b6c789c6 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -27,6 +27,16 @@ "default": "./build/cjs/index.js" } }, + "./light": { + "import": { + "types": "./build/types/light/index.d.ts", + "default": "./build/esm/light/index.js" + }, + "require": { + "types": "./build/types/light/index.d.ts", + "default": "./build/cjs/light/index.js" + } + }, "./import": { "import": { "default": "./build/import-hook.mjs" @@ -63,12 +73,38 @@ "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0", - "@opentelemetry/semantic-conventions": "^1.37.0" + "@opentelemetry/semantic-conventions": "^1.37.0", + "@sentry/opentelemetry": "10.31.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/context-async-hooks": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + }, + "@sentry/opentelemetry": { + "optional": true + } }, "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", "@sentry/core": "10.32.0", - "@sentry/opentelemetry": "10.32.0", "import-in-the-middle": "^2" }, "devDependencies": { @@ -80,6 +116,7 @@ "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", + "@sentry/opentelemetry": "10.32.0", "@types/node": "^18.19.1" }, "scripts": { diff --git a/packages/node-core/rollup.npm.config.mjs b/packages/node-core/rollup.npm.config.mjs index 8e18333836ef..9bae67fd2dd8 100644 --- a/packages/node-core/rollup.npm.config.mjs +++ b/packages/node-core/rollup.npm.config.mjs @@ -19,7 +19,7 @@ export default [ localVariablesWorkerConfig, ...makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/init.ts'], + entrypoints: ['src/index.ts', 'src/init.ts', 'src/light/index.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/packages/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts index f37ddc07a125..f5833f1b007b 100644 --- a/packages/node-core/src/integrations/http/httpServerIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -18,7 +18,7 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { NodeClient } from '../../sdk/client'; -import { MAX_BODY_BYTE_LENGTH } from './constants'; +import { patchRequestToCaptureBody } from '../../utils/captureRequestBody'; type ServerEmit = typeof Server.prototype.emit; @@ -128,6 +128,10 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => /** * This integration handles request isolation, trace continuation and other core Sentry functionality around incoming http requests * handled via the node `http` module. + * + * This version uses OpenTelemetry for context propagation and span management. + * + * @see {@link ../../light/integrations/httpServerIntegration.ts} for the lightweight version without OpenTelemetry */ export const httpServerIntegration = _httpServerIntegration as ( options?: HttpServerIntegrationOptions, @@ -189,7 +193,7 @@ function instrumentServer( const url = request.url || '/'; if (maxRequestBodySize !== 'none' && !ignoreRequestBody?.(url, request)) { - patchRequestToCaptureBody(request, isolationScope, maxRequestBodySize); + patchRequestToCaptureBody(request, isolationScope, maxRequestBodySize, INTEGRATION_NAME); } // Update the isolation scope, isolate this request @@ -315,122 +319,3 @@ export function recordRequestSession( } }); } - -/** - * This method patches the request object to capture the body. - * Instead of actually consuming the streamed body ourselves, which has potential side effects, - * we monkey patch `req.on('data')` to intercept the body chunks. - * This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways. - */ -function patchRequestToCaptureBody( - req: IncomingMessage, - isolationScope: Scope, - maxIncomingRequestBodySize: 'small' | 'medium' | 'always', -): void { - let bodyByteLength = 0; - const chunks: Buffer[] = []; - - DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Patching request.on'); - - /** - * We need to keep track of the original callbacks, in order to be able to remove listeners again. - * Since `off` depends on having the exact same function reference passed in, we need to be able to map - * original listeners to our wrapped ones. - */ - const callbackMap = new WeakMap(); - - const maxBodySize = - maxIncomingRequestBodySize === 'small' - ? 1_000 - : maxIncomingRequestBodySize === 'medium' - ? 10_000 - : MAX_BODY_BYTE_LENGTH; - - try { - // eslint-disable-next-line @typescript-eslint/unbound-method - req.on = new Proxy(req.on, { - apply: (target, thisArg, args: Parameters) => { - const [event, listener, ...restArgs] = args; - - if (event === 'data') { - DEBUG_BUILD && - debug.log(INTEGRATION_NAME, `Handling request.on("data") with maximum body size of ${maxBodySize}b`); - - const callback = new Proxy(listener, { - apply: (target, thisArg, args: Parameters) => { - try { - const chunk = args[0] as Buffer | string; - const bufferifiedChunk = Buffer.from(chunk); - - if (bodyByteLength < maxBodySize) { - chunks.push(bufferifiedChunk); - bodyByteLength += bufferifiedChunk.byteLength; - } else if (DEBUG_BUILD) { - debug.log( - INTEGRATION_NAME, - `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`, - ); - } - } catch (err) { - DEBUG_BUILD && debug.error(INTEGRATION_NAME, 'Encountered error while storing body chunk.'); - } - - return Reflect.apply(target, thisArg, args); - }, - }); - - callbackMap.set(listener, callback); - - return Reflect.apply(target, thisArg, [event, callback, ...restArgs]); - } - - return Reflect.apply(target, thisArg, args); - }, - }); - - // Ensure we also remove callbacks correctly - // eslint-disable-next-line @typescript-eslint/unbound-method - req.off = new Proxy(req.off, { - apply: (target, thisArg, args: Parameters) => { - const [, listener] = args; - - const callback = callbackMap.get(listener); - if (callback) { - callbackMap.delete(listener); - - const modifiedArgs = args.slice(); - modifiedArgs[1] = callback; - return Reflect.apply(target, thisArg, modifiedArgs); - } - - return Reflect.apply(target, thisArg, args); - }, - }); - - req.on('end', () => { - try { - const body = Buffer.concat(chunks).toString('utf-8'); - if (body) { - // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long - const bodyByteLength = Buffer.byteLength(body, 'utf-8'); - const truncatedBody = - bodyByteLength > maxBodySize - ? `${Buffer.from(body) - .subarray(0, maxBodySize - 3) - .toString('utf-8')}...` - : body; - - isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } }); - } - } catch (error) { - if (DEBUG_BUILD) { - debug.error(INTEGRATION_NAME, 'Error building captured request body', error); - } - } - }); - } catch (error) { - if (DEBUG_BUILD) { - debug.error(INTEGRATION_NAME, 'Error patching request to capture body', error); - } - } -} diff --git a/packages/node-core/src/light/asyncLocalStorageStrategy.ts b/packages/node-core/src/light/asyncLocalStorageStrategy.ts new file mode 100644 index 000000000000..af4808a091c5 --- /dev/null +++ b/packages/node-core/src/light/asyncLocalStorageStrategy.ts @@ -0,0 +1,81 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { Scope } from '@sentry/core'; +import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; + +/** + * Sets the async context strategy to use AsyncLocalStorage. + * + * This is a lightweight alternative to the OpenTelemetry-based strategy. + * It uses Node's native AsyncLocalStorage directly without any OpenTelemetry dependencies. + */ +export function setAsyncLocalStorageAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage<{ + scope: Scope; + isolationScope: Scope; + }>(); + + function getScopes(): { scope: Scope; isolationScope: Scope } { + const scopes = asyncStorage.getStore(); + + if (scopes) { + return scopes; + } + + // fallback behavior: + // if, for whatever reason, we can't find scopes on the context here, we have to fix this somehow + return { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + }; + } + + function withScope(callback: (scope: Scope) => T): T { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => { + return callback(scope); + }); + } + + function withSetScope(scope: Scope, callback: (scope: Scope) => T): T { + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => { + return callback(scope); + }); + } + + function withIsolationScope(callback: (isolationScope: Scope) => T): T { + // FIX: Clone current scope as well to prevent leakage between concurrent requests + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => { + return callback(isolationScope); + }); + } + + function withSetIsolationScope(isolationScope: Scope, callback: (isolationScope: Scope) => T): T { + // FIX: Clone current scope as well to prevent leakage between concurrent requests + const scope = getScopes().scope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => { + return callback(isolationScope); + }); + } + + // In contrast to the browser, we can rely on async context isolation here + function suppressTracing(callback: () => T): T { + return withScope(scope => { + scope.setSDKProcessingMetadata({ __SENTRY_SUPPRESS_TRACING__: true }); + return callback(); + }); + } + + setAsyncContextStrategy({ + suppressTracing, + withScope, + withSetScope, + withIsolationScope, + withSetIsolationScope, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + }); +} diff --git a/packages/node-core/src/light/client.ts b/packages/node-core/src/light/client.ts new file mode 100644 index 000000000000..fe97009419b4 --- /dev/null +++ b/packages/node-core/src/light/client.ts @@ -0,0 +1,113 @@ +import * as os from 'node:os'; +import type { ServerRuntimeClientOptions } from '@sentry/core'; +import { _INTERNAL_flushLogsBuffer, applySdkMetadata, debug, ServerRuntimeClient } from '@sentry/core'; +import { isMainThread, threadId } from 'worker_threads'; +import { DEBUG_BUILD } from '../debug-build'; +import type { NodeClientOptions } from '../types'; + +const DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS = 60_000; // 60s was chosen arbitrarily + +/** A lightweight client for using Sentry with Node without OpenTelemetry. */ +export class LightNodeClient extends ServerRuntimeClient { + private _clientReportInterval: NodeJS.Timeout | undefined; + private _clientReportOnExitFlushListener: (() => void) | undefined; + private _logOnExitFlushListener: (() => void) | undefined; + + public constructor(options: NodeClientOptions) { + const serverName = + options.includeServerName === false + ? undefined + : options.serverName || global.process.env.SENTRY_NAME || os.hostname(); + + const clientOptions: ServerRuntimeClientOptions = { + ...options, + platform: 'node', + runtime: { name: 'node', version: global.process.version }, + serverName, + }; + + applySdkMetadata(clientOptions, 'node'); + + debug.log(`Initializing Sentry: process: ${process.pid}, thread: ${isMainThread ? 'main' : `worker-${threadId}`}.`); + + super(clientOptions); + + if (this.getOptions().enableLogs) { + this._logOnExitFlushListener = () => { + _INTERNAL_flushLogsBuffer(this); + }; + + if (serverName) { + this.on('beforeCaptureLog', log => { + log.attributes = { + ...log.attributes, + 'server.address': serverName, + }; + }); + } + + process.on('beforeExit', this._logOnExitFlushListener); + } + } + + /** @inheritDoc */ + // @ts-expect-error - PromiseLike is a subset of Promise + public async flush(timeout?: number): PromiseLike { + if (this.getOptions().sendClientReports) { + this._flushOutcomes(); + } + + return super.flush(timeout); + } + + /** @inheritDoc */ + // @ts-expect-error - PromiseLike is a subset of Promise + public async close(timeout?: number | undefined): PromiseLike { + if (this._clientReportInterval) { + clearInterval(this._clientReportInterval); + } + + if (this._clientReportOnExitFlushListener) { + process.off('beforeExit', this._clientReportOnExitFlushListener); + } + + if (this._logOnExitFlushListener) { + process.off('beforeExit', this._logOnExitFlushListener); + } + + return super.close(timeout); + } + + /** + * Will start tracking client reports for this client. + * + * NOTICE: This method will create an interval that is periodically called and attach a `process.on('beforeExit')` + * hook. To clean up these resources, call `.close()` when you no longer intend to use the client. Not doing so will + * result in a memory leak. + */ + // The reason client reports need to be manually activated with this method instead of just enabling them in a + // constructor, is that if users periodically and unboundedly create new clients, we will create more and more + // intervals and beforeExit listeners, thus leaking memory. In these situations, users are required to call + // `client.close()` in order to dispose of the acquired resources. + // We assume that calling this method in Sentry.init() is a sensible default, because calling Sentry.init() over and + // over again would also result in memory leaks. + // Note: We have experimented with using `FinalizationRegisty` to clear the interval when the client is garbage + // collected, but it did not work, because the cleanup function never got called. + public startClientReportTracking(): void { + const clientOptions = this.getOptions(); + if (clientOptions.sendClientReports) { + this._clientReportOnExitFlushListener = () => { + this._flushOutcomes(); + }; + + this._clientReportInterval = setInterval(() => { + DEBUG_BUILD && debug.log('Flushing client reports based on interval.'); + this._flushOutcomes(); + }, clientOptions.clientReportFlushInterval ?? DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS) + // Unref is critical for not preventing the process from exiting because the interval is active. + .unref(); + + process.on('beforeExit', this._clientReportOnExitFlushListener); + } + } +} diff --git a/packages/node-core/src/light/index.ts b/packages/node-core/src/light/index.ts new file mode 100644 index 000000000000..e5a53e328fa9 --- /dev/null +++ b/packages/node-core/src/light/index.ts @@ -0,0 +1,146 @@ +import * as logger from '../logs/exports'; + +// Light-specific exports +export { LightNodeClient } from './client'; +export { init, getDefaultIntegrations, initWithoutDefaultIntegrations } from './sdk'; +export { setAsyncLocalStorageAsyncContextStrategy } from './asyncLocalStorageStrategy'; +export { httpServerIntegration } from './integrations/httpServerIntegration'; + +// Note: httpIntegration, httpServerSpansIntegration, nativeNodeFetchIntegration, +// and their instrumentation classes are NOT exported as they require OpenTelemetry +export { nodeContextIntegration } from '../integrations/context'; +export { contextLinesIntegration } from '../integrations/contextlines'; +export { localVariablesIntegration } from '../integrations/local-variables'; +export { modulesIntegration } from '../integrations/modules'; +export { onUncaughtExceptionIntegration } from '../integrations/onuncaughtexception'; +export { onUnhandledRejectionIntegration } from '../integrations/onunhandledrejection'; +// eslint-disable-next-line deprecation/deprecation +export { anrIntegration, disableAnrDetectionForCallback } from '../integrations/anr'; +export { spotlightIntegration } from '../integrations/spotlight'; +export { systemErrorIntegration } from '../integrations/systemError'; +export { childProcessIntegration } from '../integrations/childProcess'; +export { createSentryWinstonTransport } from '../integrations/winston'; +export { pinoIntegration } from '../integrations/pino'; + +// SDK utilities (excluding OTEL-dependent ones) +// Note: SentryContextManager, setupOpenTelemetryLogger, generateInstrumentOnce, +// instrumentWhenWrapped, INSTRUMENTED, validateOpenTelemetrySetup, setIsolationScope, +// and ensureIsWrapped are NOT exported as they require OpenTelemetry +export { getSentryRelease, defaultStackParser } from '../sdk/api'; +export { createGetModuleFromFilename } from '../utils/module'; +export { addOriginToSpan } from '../utils/addOriginToSpan'; +export { getRequestUrl } from '../utils/getRequestUrl'; +export { initializeEsmLoader } from '../sdk/esmLoader'; +export { isCjs } from '../utils/detection'; +export { createMissingInstrumentationContext } from '../utils/createMissingInstrumentationContext'; +export { envToBool } from '../utils/envToBool'; +export { makeNodeTransport, type NodeTransportOptions } from '../transports'; +export type { HTTPModuleRequestIncomingMessage } from '../transports/http-module'; +export { cron } from '../cron'; +export { NODE_VERSION } from '../nodeVersion'; + +export type { NodeOptions } from '../types'; + +// Re-export everything from @sentry/core that's safe to use +export { + addBreadcrumb, + isInitialized, + isEnabled, + getGlobalScope, + lastEventId, + close, + createTransport, + flush, + SDK_VERSION, + getSpanStatusFromHttpCode, + setHttpStatus, + captureCheckIn, + withMonitor, + requestDataIntegration, + functionToStringIntegration, + // eslint-disable-next-line deprecation/deprecation + inboundFiltersIntegration, + eventFiltersIntegration, + linkedErrorsIntegration, + addEventProcessor, + setContext, + setExtra, + setExtras, + setTag, + setTags, + setUser, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + setCurrentClient, + Scope, + setMeasurement, + getSpanDescendants, + parameterize, + getClient, + getCurrentScope, + getIsolationScope, + getTraceData, + getTraceMetaTags, + continueTrace, + withScope, + withIsolationScope, + captureException, + captureEvent, + captureMessage, + captureFeedback, + captureConsoleIntegration, + dedupeIntegration, + extraErrorDataIntegration, + rewriteFramesIntegration, + startSession, + captureSession, + endSession, + addIntegration, + startSpan, + startSpanManual, + startInactiveSpan, + startNewTrace, + suppressTracing, + getActiveSpan, + withActiveSpan, + getRootSpan, + spanToJSON, + spanToTraceHeader, + spanToBaggageHeader, + trpcMiddleware, + updateSpanName, + supabaseIntegration, + instrumentSupabaseClient, + zodErrorsIntegration, + profiler, + consoleLoggingIntegration, + createConsolaReporter, + consoleIntegration, + wrapMcpServerWithSentry, + featureFlagsIntegration, + metrics, +} from '@sentry/core'; + +export type { + Breadcrumb, + BreadcrumbHint, + PolymorphicRequest, + RequestEventData, + SdkInfo, + Event, + EventHint, + ErrorEvent, + Exception, + Session, + SeverityLevel, + StackFrame, + Stacktrace, + Thread, + User, + Span, + FeatureFlagsIntegration, +} from '@sentry/core'; + +export { logger }; diff --git a/packages/node-core/src/light/integrations/httpServerIntegration.ts b/packages/node-core/src/light/integrations/httpServerIntegration.ts new file mode 100644 index 000000000000..5b82841d21d8 --- /dev/null +++ b/packages/node-core/src/light/integrations/httpServerIntegration.ts @@ -0,0 +1,176 @@ +import type { ChannelListener } from 'node:diagnostics_channel'; +import { subscribe } from 'node:diagnostics_channel'; +import type { IncomingMessage, RequestOptions, Server } from 'node:http'; +import type { Integration, IntegrationFn } from '@sentry/core'; +import { + continueTrace, + debug, + generateSpanId, + getCurrentScope, + getIsolationScope, + httpRequestToRequestData, + stripUrlQueryAndFragment, + withIsolationScope, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { NodeClient } from '../../sdk/client'; +import { patchRequestToCaptureBody } from '../../utils/captureRequestBody'; + +const INTEGRATION_NAME = 'Http.Server'; + +// We keep track of emit functions we wrapped, to avoid double wrapping +const wrappedEmitFns = new WeakSet(); + +export interface HttpServerIntegrationOptions { + /** + * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * This can be useful for long running requests where the body is not needed and we want to avoid capturing it. + * + * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the incoming request. + * @param request Contains the {@type RequestOptions} object used to make the incoming request. + */ + ignoreRequestBody?: (url: string, request: RequestOptions) => boolean; + + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * Available options: + * - 'none': No request bodies will be attached + * - 'small': Request bodies up to 1,000 bytes will be attached + * - 'medium': Request bodies up to 10,000 bytes will be attached (default) + * - 'always': Request bodies will always be attached + * + * Note that even with 'always' setting, bodies exceeding 1MB will never be attached + * for performance and security reasons. + * + * @default 'medium' + */ + maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; +} + +const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => { + const _options = { + maxRequestBodySize: options.maxRequestBodySize ?? 'medium', + ignoreRequestBody: options.ignoreRequestBody, + }; + + return { + name: INTEGRATION_NAME, + setupOnce() { + const onHttpServerRequestStart = ((_data: unknown) => { + const data = _data as { server: Server }; + + instrumentServer(data.server, _options); + }) satisfies ChannelListener; + + subscribe('http.server.request.start', onHttpServerRequestStart); + }, + }; +}) satisfies IntegrationFn; + +/** + * This integration handles request isolation and trace continuation for incoming http requests + * in light mode (without OpenTelemetry). + * + * This is a lightweight alternative to the OpenTelemetry-based httpServerIntegration. + * It uses Node's native AsyncLocalStorage for scope isolation and Sentry's continueTrace for propagation. + * + * Note: This integration requires Node.js 22+ (for http.server.request.start diagnostics channel). + * + * @see {@link ../../integrations/http/httpServerIntegration.ts} for the OpenTelemetry-based version + */ +export const httpServerIntegration = _httpServerIntegration as ( + options?: HttpServerIntegrationOptions, +) => Integration & { + name: 'Http.Server'; + setupOnce: () => void; +}; + +/** + * Instrument a server to capture incoming requests. + */ +function instrumentServer( + server: Server, + { + ignoreRequestBody, + maxRequestBodySize, + }: { + ignoreRequestBody?: (url: string, request: IncomingMessage) => boolean; + maxRequestBodySize: 'small' | 'medium' | 'always' | 'none'; + }, +): void { + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalEmit: typeof Server.prototype.emit = server.emit; + + if (wrappedEmitFns.has(originalEmit)) { + return; + } + + const newEmit = new Proxy(originalEmit, { + apply(target, thisArg, args: [event: string, ...args: unknown[]]) { + // Only handle request events + if (args[0] !== 'request') { + return target.apply(thisArg, args); + } + + const client = getCurrentScope().getClient(); + + if (!client) { + return target.apply(thisArg, args); + } + + DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Handling incoming request (light mode)'); + + const isolationScope = getIsolationScope().clone(); + const request = args[1] as IncomingMessage; + + const normalizedRequest = httpRequestToRequestData(request); + + // request.ip is non-standard but some frameworks set this + const ipAddress = (request as { ip?: string }).ip || request.socket?.remoteAddress; + + const url = request.url || '/'; + if (maxRequestBodySize !== 'none' && !ignoreRequestBody?.(url, request)) { + patchRequestToCaptureBody(request, isolationScope, maxRequestBodySize, INTEGRATION_NAME); + } + + // Update the isolation scope, isolate this request + isolationScope.setSDKProcessingMetadata({ normalizedRequest, ipAddress }); + + // attempt to update the scope's `transactionName` based on the request URL + // Ideally, framework instrumentations coming after the HttpInstrumentation + // update the transactionName once we get a parameterized route. + const httpMethod = (request.method || 'GET').toUpperCase(); + const httpTargetWithoutQueryFragment = stripUrlQueryAndFragment(url); + + const bestEffortTransactionName = `${httpMethod} ${httpTargetWithoutQueryFragment}`; + + isolationScope.setTransactionName(bestEffortTransactionName); + + return withIsolationScope(isolationScope, () => { + // Set a new propagationSpanId for this request + // We rely on the fact that `withIsolationScope()` will implicitly also fork the current scope + // This way we can save an "unnecessary" `withScope()` invocation + getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId(); + + // Handle trace propagation using Sentry's continueTrace + // This replaces OpenTelemetry's propagation.extract() + context.with() + const sentryTrace = normalizedRequest.headers?.['sentry-trace']; + const baggage = normalizedRequest.headers?.['baggage']; + + return continueTrace( + { + sentryTrace: Array.isArray(sentryTrace) ? sentryTrace[0] : sentryTrace, + baggage: Array.isArray(baggage) ? baggage[0] : baggage, + }, + () => { + return target.apply(thisArg, args); + }, + ); + }); + }, + }); + + wrappedEmitFns.add(newEmit); + server.emit = newEmit; +} diff --git a/packages/node-core/src/light/sdk.ts b/packages/node-core/src/light/sdk.ts new file mode 100644 index 000000000000..6237a11008a3 --- /dev/null +++ b/packages/node-core/src/light/sdk.ts @@ -0,0 +1,217 @@ +import type { Integration, Options } from '@sentry/core'; +import { + applySdkMetadata, + consoleIntegration, + consoleSandbox, + debug, + functionToStringIntegration, + getCurrentScope, + getIntegrationsToSetup, + GLOBAL_OBJ, + inboundFiltersIntegration, + linkedErrorsIntegration, + propagationContextFromHeaders, + requestDataIntegration, + stackParserFromStackParserOptions, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { childProcessIntegration } from '../integrations/childProcess'; +import { nodeContextIntegration } from '../integrations/context'; +import { contextLinesIntegration } from '../integrations/contextlines'; +import { localVariablesIntegration } from '../integrations/local-variables'; +import { modulesIntegration } from '../integrations/modules'; +import { onUncaughtExceptionIntegration } from '../integrations/onuncaughtexception'; +import { onUnhandledRejectionIntegration } from '../integrations/onunhandledrejection'; +import { processSessionIntegration } from '../integrations/processSession'; +import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from '../integrations/spotlight'; +import { systemErrorIntegration } from '../integrations/systemError'; +import { defaultStackParser, getSentryRelease } from '../sdk/api'; +import { initializeEsmLoader } from '../sdk/esmLoader'; +import { makeNodeTransport } from '../transports'; +import type { NodeClientOptions, NodeOptions } from '../types'; +import { isCjs } from '../utils/detection'; +import { envToBool } from '../utils/envToBool'; +import { setAsyncLocalStorageAsyncContextStrategy } from './asyncLocalStorageStrategy'; +import { LightNodeClient } from './client'; +import { httpServerIntegration } from './integrations/httpServerIntegration'; + +/** + * Get default integrations for the Light Node-Core SDK. + * Note: HTTP and fetch integrations that require OpenTelemetry are not included. + * The httpServerIntegration is included for automatic request isolation (requires Node.js 22+). + */ +export function getDefaultIntegrations(): Integration[] { + return [ + // Common + // TODO(v11): Replace with `eventFiltersIntegration` once we remove the deprecated `inboundFiltersIntegration` + // eslint-disable-next-line deprecation/deprecation + inboundFiltersIntegration(), + functionToStringIntegration(), + linkedErrorsIntegration(), + requestDataIntegration(), + systemErrorIntegration(), + // Native Wrappers + consoleIntegration(), + // HTTP Server (automatic request isolation, requires Node.js 22+) + httpServerIntegration(), + // Note: httpIntegration() and nativeNodeFetchIntegration() are not included in light mode as they require OpenTelemetry + // Global Handlers + onUncaughtExceptionIntegration(), + onUnhandledRejectionIntegration(), + // Event Info + contextLinesIntegration(), + localVariablesIntegration(), + nodeContextIntegration(), + childProcessIntegration(), + processSessionIntegration(), + modulesIntegration(), + ]; +} + +/** + * Initialize Sentry for Node in light mode (without OpenTelemetry). + */ +export function init(options: NodeOptions | undefined = {}): LightNodeClient | undefined { + return _init(options, getDefaultIntegrations); +} + +/** + * Initialize Sentry for Node in light mode, without any integrations added by default. + */ +export function initWithoutDefaultIntegrations(options: NodeOptions | undefined = {}): LightNodeClient { + return _init(options, () => []); +} + +/** + * Initialize Sentry for Node in light mode. + */ +function _init( + _options: NodeOptions | undefined = {}, + getDefaultIntegrationsImpl: (options: Options) => Integration[], +): LightNodeClient { + const options = getClientOptions(_options, getDefaultIntegrationsImpl); + + if (options.debug === true) { + if (DEBUG_BUILD) { + debug.enable(); + } else { + // use `console.warn` rather than `debug.warn` since by non-debug bundles have all `debug.x` statements stripped + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('[Sentry] Cannot initialize SDK with `debug` option using a non-debug bundle.'); + }); + } + } + + if (options.registerEsmLoaderHooks !== false) { + initializeEsmLoader(); + } + + // Use AsyncLocalStorage-based context strategy instead of OpenTelemetry + setAsyncLocalStorageAsyncContextStrategy(); + + const scope = getCurrentScope(); + scope.update(options.initialScope); + + if (options.spotlight && !options.integrations.some(({ name }) => name === SPOTLIGHT_INTEGRATION_NAME)) { + options.integrations.push( + spotlightIntegration({ + sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined, + }), + ); + } + + applySdkMetadata(options, 'node-core', ['node-core-light']); + + const client = new LightNodeClient(options); + // The client is on the current scope, from where it generally is inherited + getCurrentScope().setClient(client); + + client.init(); + + GLOBAL_OBJ._sentryInjectLoaderHookRegister?.(); + + debug.log(`SDK initialized from ${isCjs() ? 'CommonJS' : 'ESM'} (light mode)`); + + client.startClientReportTracking(); + + updateScopeFromEnvVariables(); + + return client; +} + +function getClientOptions( + options: NodeOptions, + getDefaultIntegrationsImpl: (options: Options) => Integration[], +): NodeClientOptions { + const release = getRelease(options.release); + const spotlight = + options.spotlight ?? envToBool(process.env.SENTRY_SPOTLIGHT, { strict: true }) ?? process.env.SENTRY_SPOTLIGHT; + const tracesSampleRate = getTracesSampleRate(options.tracesSampleRate); + + const mergedOptions = { + ...options, + dsn: options.dsn ?? process.env.SENTRY_DSN, + environment: options.environment ?? process.env.SENTRY_ENVIRONMENT, + sendClientReports: options.sendClientReports ?? true, + transport: options.transport ?? makeNodeTransport, + stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), + release, + tracesSampleRate, + spotlight, + debug: envToBool(options.debug ?? process.env.SENTRY_DEBUG), + }; + + const integrations = options.integrations; + const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(mergedOptions); + + return { + ...mergedOptions, + integrations: getIntegrationsToSetup({ + defaultIntegrations, + integrations, + }), + }; +} + +function getRelease(release: NodeOptions['release']): string | undefined { + if (release !== undefined) { + return release; + } + + const detectedRelease = getSentryRelease(); + if (detectedRelease !== undefined) { + return detectedRelease; + } + + return undefined; +} + +function getTracesSampleRate(tracesSampleRate: NodeOptions['tracesSampleRate']): number | undefined { + if (tracesSampleRate !== undefined) { + return tracesSampleRate; + } + + const sampleRateFromEnv = process.env.SENTRY_TRACES_SAMPLE_RATE; + if (!sampleRateFromEnv) { + return undefined; + } + + const parsed = parseFloat(sampleRateFromEnv); + return isFinite(parsed) ? parsed : undefined; +} + +/** + * Update scope and propagation context based on environmental variables. + * + * See https://github.com/getsentry/rfcs/blob/main/text/0071-continue-trace-over-process-boundaries.md + * for more details. + */ +function updateScopeFromEnvVariables(): void { + if (envToBool(process.env.SENTRY_USE_ENVIRONMENT) !== false) { + const sentryTraceEnv = process.env.SENTRY_TRACE; + const baggageEnv = process.env.SENTRY_BAGGAGE; + const propagationContext = propagationContextFromHeaders(sentryTraceEnv, baggageEnv); + getCurrentScope().setPropagationContext(propagationContext); + } +} diff --git a/packages/node-core/src/utils/captureRequestBody.ts b/packages/node-core/src/utils/captureRequestBody.ts new file mode 100644 index 000000000000..3d5eea7678fe --- /dev/null +++ b/packages/node-core/src/utils/captureRequestBody.ts @@ -0,0 +1,126 @@ +import type { IncomingMessage } from 'node:http'; +import type { Scope } from '@sentry/core'; +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +export const MAX_BODY_BYTE_LENGTH = 1_000_000; + +/** + * This method patches the request object to capture the body. + * Instead of actually consuming the streamed body ourselves, which has potential side effects, + * we monkey patch `req.on('data')` to intercept the body chunks. + * This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways. + */ +export function patchRequestToCaptureBody( + req: IncomingMessage, + isolationScope: Scope, + maxIncomingRequestBodySize: 'small' | 'medium' | 'always', + integrationName: string, +): void { + let bodyByteLength = 0; + const chunks: Buffer[] = []; + + DEBUG_BUILD && debug.log(integrationName, 'Patching request.on'); + + /** + * We need to keep track of the original callbacks, in order to be able to remove listeners again. + * Since `off` depends on having the exact same function reference passed in, we need to be able to map + * original listeners to our wrapped ones. + */ + const callbackMap = new WeakMap(); + + const maxBodySize = + maxIncomingRequestBodySize === 'small' + ? 1_000 + : maxIncomingRequestBodySize === 'medium' + ? 10_000 + : MAX_BODY_BYTE_LENGTH; + + try { + // eslint-disable-next-line @typescript-eslint/unbound-method + req.on = new Proxy(req.on, { + apply: (target, thisArg, args: Parameters) => { + const [event, listener, ...restArgs] = args; + + if (event === 'data') { + DEBUG_BUILD && + debug.log(integrationName, `Handling request.on("data") with maximum body size of ${maxBodySize}b`); + + const callback = new Proxy(listener, { + apply: (target, thisArg, args: Parameters) => { + try { + const chunk = args[0] as Buffer | string; + const bufferifiedChunk = Buffer.from(chunk); + + if (bodyByteLength < maxBodySize) { + chunks.push(bufferifiedChunk); + bodyByteLength += bufferifiedChunk.byteLength; + } else if (DEBUG_BUILD) { + debug.log( + integrationName, + `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`, + ); + } + } catch (err) { + DEBUG_BUILD && debug.error(integrationName, 'Encountered error while storing body chunk.'); + } + + return Reflect.apply(target, thisArg, args); + }, + }); + + callbackMap.set(listener, callback); + + return Reflect.apply(target, thisArg, [event, callback, ...restArgs]); + } + + return Reflect.apply(target, thisArg, args); + }, + }); + + // Ensure we also remove callbacks correctly + // eslint-disable-next-line @typescript-eslint/unbound-method + req.off = new Proxy(req.off, { + apply: (target, thisArg, args: Parameters) => { + const [, listener] = args; + + const callback = callbackMap.get(listener); + if (callback) { + callbackMap.delete(listener); + + const modifiedArgs = args.slice(); + modifiedArgs[1] = callback; + return Reflect.apply(target, thisArg, modifiedArgs); + } + + return Reflect.apply(target, thisArg, args); + }, + }); + + req.on('end', () => { + try { + const body = Buffer.concat(chunks).toString('utf-8'); + if (body) { + // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long + const bodyByteLength = Buffer.byteLength(body, 'utf-8'); + const truncatedBody = + bodyByteLength > maxBodySize + ? `${Buffer.from(body) + .subarray(0, maxBodySize - 3) + .toString('utf-8')}...` + : body; + + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } }); + } + } catch (error) { + if (DEBUG_BUILD) { + debug.error(integrationName, 'Error building captured request body', error); + } + } + }); + } catch (error) { + if (DEBUG_BUILD) { + debug.error(integrationName, 'Error patching request to capture body', error); + } + } +}