From f86b68d4b72a36246b11bf0b074b33ae6b099be2 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 30 Jan 2026 18:51:21 +0100 Subject: [PATCH 01/26] feat(node-core): Add node-core/light entry point --- packages/node-core/README.md | 113 +++++++++ packages/node-core/package.json | 40 +++- packages/node-core/rollup.npm.config.mjs | 2 +- .../http/httpServerIntegration.ts | 127 +--------- .../src/light/asyncLocalStorageStrategy.ts | 81 +++++++ packages/node-core/src/light/client.ts | 113 +++++++++ packages/node-core/src/light/index.ts | 146 ++++++++++++ .../integrations/httpServerIntegration.ts | 176 ++++++++++++++ packages/node-core/src/light/sdk.ts | 217 ++++++++++++++++++ .../node-core/src/utils/captureRequestBody.ts | 126 ++++++++++ 10 files changed, 1017 insertions(+), 124 deletions(-) create mode 100644 packages/node-core/src/light/asyncLocalStorageStrategy.ts create mode 100644 packages/node-core/src/light/client.ts create mode 100644 packages/node-core/src/light/index.ts create mode 100644 packages/node-core/src/light/integrations/httpServerIntegration.ts create mode 100644 packages/node-core/src/light/sdk.ts create mode 100644 packages/node-core/src/utils/captureRequestBody.ts diff --git a/packages/node-core/README.md b/packages/node-core/README.md index fa3bd7946ec0..76a07606a9d3 100644 --- a/packages/node-core/README.md +++ b/packages/node-core/README.md @@ -116,6 +116,119 @@ 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 + +> **⚠️ Experimental**: The `@sentry/node-core/light` subpath export is experimental and may receive breaking changes in minor or patch releases. + +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 6727558470a8..732e3b010ee2 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", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" + "@opentelemetry/semantic-conventions": "^1.39.0", + "@sentry/opentelemetry": "10.38.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.38.0", - "@sentry/opentelemetry": "10.38.0", "import-in-the-middle": "^2.0.6" }, "devDependencies": { 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..f4f1157c1d23 --- /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 { patchRequestToCaptureBody } from '../../utils/captureRequestBody'; +import type { LightNodeClient } from '../client'; + +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..89b59a1de4b7 --- /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 = 1024 * 1024; + +/** + * 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); + } + } +} From 7ba3eb2c75fb862416d59ede3cf1b5d41f581462 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 30 Jan 2026 18:54:23 +0100 Subject: [PATCH 02/26] Add unit tests for node-core/light --- .../test/helpers/mockLightSdkInit.ts | 32 ++ .../light/asyncLocalStorageStrategy.test.ts | 208 ++++++++++ packages/node-core/test/light/scope.test.ts | 358 ++++++++++++++++++ packages/node-core/test/light/sdk.test.ts | 127 +++++++ 4 files changed, 725 insertions(+) create mode 100644 packages/node-core/test/helpers/mockLightSdkInit.ts create mode 100644 packages/node-core/test/light/asyncLocalStorageStrategy.test.ts create mode 100644 packages/node-core/test/light/scope.test.ts create mode 100644 packages/node-core/test/light/sdk.test.ts diff --git a/packages/node-core/test/helpers/mockLightSdkInit.ts b/packages/node-core/test/helpers/mockLightSdkInit.ts new file mode 100644 index 000000000000..04e0c7ed5587 --- /dev/null +++ b/packages/node-core/test/helpers/mockLightSdkInit.ts @@ -0,0 +1,32 @@ +import { createTransport, getCurrentScope, getGlobalScope, getIsolationScope, resolvedSyncPromise } from '@sentry/core'; +import { init } from '../../src/light/sdk'; +import type { NodeClientOptions } from '../../src/types'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +export function resetGlobals(): void { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); +} + +export function mockLightSdkInit(options?: Partial) { + resetGlobals(); + const client = init({ + dsn: PUBLIC_DSN, + defaultIntegrations: false, + // We are disabling client reports because we would be acquiring resources with every init call and that would leak + // memory every time we call init in the tests + sendClientReports: false, + // Use a mock transport to prevent network calls + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})), + ...options, + }); + + return client; +} + +export function cleanupLightSdk(): void { + resetGlobals(); +} diff --git a/packages/node-core/test/light/asyncLocalStorageStrategy.test.ts b/packages/node-core/test/light/asyncLocalStorageStrategy.test.ts new file mode 100644 index 000000000000..66d51f6dd6ef --- /dev/null +++ b/packages/node-core/test/light/asyncLocalStorageStrategy.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as Sentry from '../../src/light'; +import { cleanupLightSdk, mockLightSdkInit, resetGlobals } from '../helpers/mockLightSdkInit'; + +describe('Light Mode | AsyncLocalStorage Strategy', () => { + afterEach(() => { + cleanupLightSdk(); + }); + + describe('scope isolation with setTimeout', () => { + it('maintains scope across setTimeout', async () => { + mockLightSdkInit(); + + const result = await new Promise(resolve => { + Sentry.withScope(scope => { + scope.setTag('asyncTag', 'asyncValue'); + + setTimeout(() => { + const tag = Sentry.getCurrentScope().getScopeData().tags?.asyncTag; + resolve(tag as string); + }, 10); + }); + }); + + expect(result).toBe('asyncValue'); + }); + + it('isolates scopes across concurrent setTimeout calls', async () => { + mockLightSdkInit(); + + const results = await Promise.all([ + new Promise(resolve => { + Sentry.withScope(scope => { + scope.setTag('id', 'first'); + setTimeout(() => { + resolve(Sentry.getCurrentScope().getScopeData().tags?.id as string); + }, 20); + }); + }), + new Promise(resolve => { + Sentry.withScope(scope => { + scope.setTag('id', 'second'); + setTimeout(() => { + resolve(Sentry.getCurrentScope().getScopeData().tags?.id as string); + }, 10); + }); + }), + ]); + + expect(results).toEqual(['first', 'second']); + }); + }); + + describe('scope isolation with Promises', () => { + it('maintains scope across Promise chains', async () => { + mockLightSdkInit(); + + const result = await Sentry.withScope(async scope => { + scope.setTag('promiseTag', 'promiseValue'); + + await Promise.resolve(); + + return Sentry.getCurrentScope().getScopeData().tags?.promiseTag; + }); + + expect(result).toBe('promiseValue'); + }); + + it('isolates scopes across concurrent Promise.all', async () => { + mockLightSdkInit(); + + const results = await Promise.all( + [1, 2, 3].map(id => + Sentry.withScope(async scope => { + scope.setTag('id', `value-${id}`); + + // Simulate async work + await new Promise(resolve => setTimeout(resolve, Math.random() * 20)); + + return Sentry.getCurrentScope().getScopeData().tags?.id; + }), + ), + ); + + expect(results).toEqual(['value-1', 'value-2', 'value-3']); + }); + }); + + describe('scope isolation with async/await', () => { + it('maintains isolation scope across async/await', async () => { + mockLightSdkInit(); + + const result = await Sentry.withIsolationScope(async isolationScope => { + isolationScope.setUser({ id: 'async-user' }); + + await Promise.resolve(); + + return Sentry.getIsolationScope().getScopeData().user?.id; + }); + + expect(result).toBe('async-user'); + }); + + it('maintains both current and isolation scope across async boundaries', async () => { + mockLightSdkInit(); + + const result = await Sentry.withIsolationScope(async isolationScope => { + isolationScope.setTag('isolationTag', 'isolationValue'); + + return Sentry.withScope(async currentScope => { + currentScope.setTag('currentTag', 'currentValue'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + isolationTag: Sentry.getIsolationScope().getScopeData().tags?.isolationTag, + currentTag: Sentry.getCurrentScope().getScopeData().tags?.currentTag, + }; + }); + }); + + expect(result).toEqual({ + isolationTag: 'isolationValue', + currentTag: 'currentValue', + }); + }); + }); + + describe('suppressTracing', () => { + it('sets suppression metadata on scope', () => { + mockLightSdkInit(); + + Sentry.suppressTracing(() => { + const metadata = Sentry.getCurrentScope().getScopeData().sdkProcessingMetadata; + expect(metadata?.__SENTRY_SUPPRESS_TRACING__).toBe(true); + }); + }); + + it('does not affect outer scope', () => { + mockLightSdkInit(); + + Sentry.suppressTracing(() => { + // Inside suppressTracing + }); + + const metadata = Sentry.getCurrentScope().getScopeData().sdkProcessingMetadata; + expect(metadata?.__SENTRY_SUPPRESS_TRACING__).toBeUndefined(); + }); + }); + + describe('nested withScope and withIsolationScope', () => { + it('correctly nests isolation and current scopes', async () => { + mockLightSdkInit(); + + const initialIsolationScope = Sentry.getIsolationScope(); + const initialCurrentScope = Sentry.getCurrentScope(); + + await Sentry.withIsolationScope(async isolationScope1 => { + expect(Sentry.getIsolationScope()).toBe(isolationScope1); + expect(Sentry.getIsolationScope()).not.toBe(initialIsolationScope); + // Current scope should also be forked + expect(Sentry.getCurrentScope()).not.toBe(initialCurrentScope); + + isolationScope1.setTag('level', '1'); + + await Sentry.withScope(async currentScope1 => { + expect(Sentry.getCurrentScope()).toBe(currentScope1); + currentScope1.setTag('current', '1'); + + await Sentry.withIsolationScope(async isolationScope2 => { + expect(Sentry.getIsolationScope()).toBe(isolationScope2); + expect(Sentry.getIsolationScope()).not.toBe(isolationScope1); + + // Should inherit from parent isolation scope + expect(isolationScope2.getScopeData().tags?.level).toBe('1'); + isolationScope2.setTag('level', '2'); + + // Parent should be unchanged + expect(isolationScope1.getScopeData().tags?.level).toBe('1'); + }); + + // After exiting nested isolation scope, we should be back to original + expect(Sentry.getIsolationScope()).toBe(isolationScope1); + }); + }); + + // After exiting all scopes, we should be back to initial + expect(Sentry.getIsolationScope()).toBe(initialIsolationScope); + expect(Sentry.getCurrentScope()).toBe(initialCurrentScope); + }); + }); + + describe('fallback behavior', () => { + it('returns default scopes when AsyncLocalStorage store is empty', () => { + resetGlobals(); + // Before init, should still return valid scopes + const currentScope = Sentry.getCurrentScope(); + const isolationScope = Sentry.getIsolationScope(); + + expect(currentScope).toBeDefined(); + expect(isolationScope).toBeDefined(); + + // Should be able to set data on them + currentScope.setTag('test', 'value'); + expect(currentScope.getScopeData().tags?.test).toBe('value'); + }); + }); +}); diff --git a/packages/node-core/test/light/scope.test.ts b/packages/node-core/test/light/scope.test.ts new file mode 100644 index 000000000000..0b8d484175ea --- /dev/null +++ b/packages/node-core/test/light/scope.test.ts @@ -0,0 +1,358 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as Sentry from '../../src/light'; +import { cleanupLightSdk, mockLightSdkInit, resetGlobals } from '../helpers/mockLightSdkInit'; + +describe('Light Mode | Scope', () => { + afterEach(() => { + cleanupLightSdk(); + }); + + describe('basic error capturing', () => { + it('captures exceptions with correct tags', async () => { + const beforeSend = vi.fn(() => null); + const client = mockLightSdkInit({ beforeSend }); + + const error = new Error('test error'); + + Sentry.getCurrentScope().setTag('tag1', 'val1'); + Sentry.captureException(error); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + }, + }), + expect.objectContaining({ + originalException: error, + }), + ); + }); + }); + + describe('withScope', () => { + it('isolates scope data within withScope callback', async () => { + const beforeSend = vi.fn(() => null); + const client = mockLightSdkInit({ beforeSend }); + + const error = new Error('test error'); + + Sentry.getCurrentScope().setTag('tag1', 'val1'); + + Sentry.withScope(scope => { + scope.setTag('tag2', 'val2'); + Sentry.captureException(error); + }); + + // Tag2 should not leak outside withScope + expect(Sentry.getCurrentScope().getScopeData().tags).toEqual({ tag1: 'val1' }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + }, + }), + expect.objectContaining({ + originalException: error, + }), + ); + }); + + it('can be deeply nested', async () => { + const beforeSend = vi.fn(() => null); + const client = mockLightSdkInit({ beforeSend }); + + const error = new Error('test error'); + + Sentry.getCurrentScope().setTag('tag1', 'val1'); + + Sentry.withScope(scope1 => { + scope1.setTag('tag2', 'val2'); + + Sentry.withScope(scope2 => { + scope2.setTag('tag3', 'val3'); + + Sentry.withScope(scope3 => { + scope3.setTag('tag4', 'val4'); + }); + + Sentry.captureException(error); + }); + }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + expect.objectContaining({ + originalException: error, + }), + ); + }); + }); + + describe('withIsolationScope', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('isolates isolation scope data', async () => { + const beforeSend = vi.fn(() => null); + const client = mockLightSdkInit({ beforeSend }); + + const initialIsolationScope = Sentry.getIsolationScope(); + initialIsolationScope.setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.withIsolationScope(newIsolationScope => { + expect(Sentry.getIsolationScope()).toBe(newIsolationScope); + expect(newIsolationScope).not.toBe(initialIsolationScope); + + // Data is forked off original isolation scope + expect(newIsolationScope.getScopeData().tags).toEqual({ tag1: 'val1' }); + newIsolationScope.setTag('tag2', 'val2'); + + Sentry.captureException(error); + }); + + // Tag2 should not leak to original isolation scope + expect(initialIsolationScope.getScopeData().tags).toEqual({ tag1: 'val1' }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + }, + }), + expect.objectContaining({ + originalException: error, + }), + ); + }); + + it('can be deeply nested', async () => { + const beforeSend = vi.fn(() => null); + const client = mockLightSdkInit({ beforeSend }); + + const initialIsolationScope = Sentry.getIsolationScope(); + initialIsolationScope.setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.withIsolationScope(scope1 => { + scope1.setTag('tag2', 'val2'); + + Sentry.withIsolationScope(scope2 => { + scope2.setTag('tag3', 'val3'); + + Sentry.withIsolationScope(scope3 => { + scope3.setTag('tag4', 'val4'); + }); + + Sentry.captureException(error); + }); + }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + expect.objectContaining({ + originalException: error, + }), + ); + }); + }); + + describe('concurrent async operations', () => { + it('maintains scope isolation across concurrent async operations', async () => { + const beforeSend = vi.fn(() => null); + const client = mockLightSdkInit({ beforeSend }); + + // Simulate concurrent requests + const promises = [1, 2, 3].map(async id => { + return Sentry.withIsolationScope(async isolationScope => { + isolationScope.setTag('requestId', `request-${id}`); + isolationScope.setUser({ id: `user-${id}` }); + + // Simulate async work with different delays + await new Promise(resolve => setTimeout(resolve, Math.random() * 10)); + + Sentry.captureException(new Error(`Error for request ${id}`)); + + // Verify scope is still correct after async work + expect(Sentry.getIsolationScope().getScopeData().tags?.requestId).toBe(`request-${id}`); + expect(Sentry.getIsolationScope().getScopeData().user?.id).toBe(`user-${id}`); + }); + }); + + await Promise.all(promises); + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(3); + + // Each error should have its own isolated context - check by matching error message to tags + for (let id = 1; id <= 3; id++) { + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + exception: expect.objectContaining({ + values: expect.arrayContaining([ + expect.objectContaining({ + value: `Error for request ${id}`, + }), + ]), + }), + tags: expect.objectContaining({ + requestId: `request-${id}`, + }), + user: expect.objectContaining({ + id: `user-${id}`, + }), + }), + expect.any(Object), + ); + } + }); + }); + + describe('global scope', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('works before calling init', () => { + const globalScope = Sentry.getGlobalScope(); + expect(globalScope).toBeDefined(); + + globalScope.setTag('tag1', 'val1'); + expect(globalScope.getScopeData().tags).toEqual({ tag1: 'val1' }); + + // Now when we call init, the global scope remains intact + // Note: We call init directly here instead of mockLightSdkInit because + // mockLightSdkInit calls resetGlobals() which would clear the tags we just set + Sentry.init({ dsn: 'https://username@domain/123', defaultIntegrations: false }); + + expect(Sentry.getGlobalScope()).toBe(globalScope); + expect(globalScope.getScopeData().tags).toEqual({ tag1: 'val1' }); + }); + + it('is applied to events', async () => { + const beforeSend = vi.fn(() => null); + const client = mockLightSdkInit({ beforeSend }); + + const globalScope = Sentry.getGlobalScope(); + globalScope.setTag('globalTag', 'globalValue'); + + const error = new Error('test error'); + Sentry.captureException(error); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ + globalTag: 'globalValue', + }), + }), + expect.any(Object), + ); + }); + }); + + describe('scope merging', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('merges data from global, isolation and current scope', async () => { + const beforeSend = vi.fn(() => null); + const client = mockLightSdkInit({ beforeSend }); + + Sentry.getGlobalScope().setTag('globalTag', 'globalValue'); + + const error = new Error('test error'); + + Sentry.withIsolationScope(isolationScope => { + isolationScope.setTag('isolationTag', 'isolationValue'); + + Sentry.withScope(currentScope => { + currentScope.setTag('currentTag', 'currentValue'); + + Sentry.captureException(error); + }); + }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + globalTag: 'globalValue', + isolationTag: 'isolationValue', + currentTag: 'currentValue', + }, + }), + expect.objectContaining({ + originalException: error, + }), + ); + }); + + it('current scope overrides isolation scope', async () => { + const beforeSend = vi.fn(() => null); + const client = mockLightSdkInit({ beforeSend }); + + const error = new Error('test error'); + + Sentry.withIsolationScope(isolationScope => { + isolationScope.setTag('tag', 'isolationValue'); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag', 'currentValue'); + Sentry.captureException(error); + }); + }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag: 'currentValue', + }, + }), + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/node-core/test/light/sdk.test.ts b/packages/node-core/test/light/sdk.test.ts new file mode 100644 index 000000000000..5c91e11db659 --- /dev/null +++ b/packages/node-core/test/light/sdk.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as Sentry from '../../src/light'; +import { LightNodeClient } from '../../src/light/client'; +import { cleanupLightSdk, mockLightSdkInit, resetGlobals } from '../helpers/mockLightSdkInit'; + +describe('Light Mode | SDK', () => { + afterEach(() => { + cleanupLightSdk(); + }); + + describe('init', () => { + it('returns a LightNodeClient', () => { + const client = mockLightSdkInit(); + + expect(client).toBeInstanceOf(LightNodeClient); + }); + + it('sets the client on the current scope', () => { + const client = mockLightSdkInit(); + + expect(Sentry.getClient()).toBe(client); + }); + + it('applies initialScope options', () => { + mockLightSdkInit({ + initialScope: { + tags: { initialTag: 'initialValue' }, + user: { id: 'test-user' }, + }, + }); + + const scope = Sentry.getCurrentScope(); + expect(scope.getScopeData().tags).toEqual({ initialTag: 'initialValue' }); + expect(scope.getScopeData().user).toEqual({ id: 'test-user' }); + }); + + it('respects environment from options', () => { + const client = mockLightSdkInit({ + environment: 'test-environment', + }); + + expect(client?.getOptions().environment).toBe('test-environment'); + }); + + it('respects release from options', () => { + const client = mockLightSdkInit({ + release: 'test-release@1.0.0', + }); + + expect(client?.getOptions().release).toBe('test-release@1.0.0'); + }); + }); + + describe('initWithoutDefaultIntegrations', () => { + it('initializes without default integrations', () => { + resetGlobals(); + const client = Sentry.initWithoutDefaultIntegrations({ + dsn: 'https://username@domain/123', + }); + + // Should have no integrations + const integrations = client.getOptions().integrations; + expect(integrations).toEqual([]); + }); + }); + + describe('getDefaultIntegrations', () => { + it('returns an array of integrations', () => { + const integrations = Sentry.getDefaultIntegrations(); + + expect(Array.isArray(integrations)).toBe(true); + expect(integrations.length).toBeGreaterThan(0); + + // Check that some expected integrations are present + const integrationNames = integrations.map(i => i.name); + expect(integrationNames).toContain('InboundFilters'); + expect(integrationNames).toContain('FunctionToString'); + expect(integrationNames).toContain('LinkedErrors'); + expect(integrationNames).toContain('OnUncaughtException'); + expect(integrationNames).toContain('OnUnhandledRejection'); + }); + + it('includes Http.Server integration for request isolation', () => { + const integrations = Sentry.getDefaultIntegrations(); + const integrationNames = integrations.map(i => i.name); + + expect(integrationNames).toContain('Http.Server'); + }); + }); + + describe('isInitialized', () => { + it('returns false before init', () => { + resetGlobals(); + expect(Sentry.isInitialized()).toBe(false); + }); + + it('returns true after init', () => { + mockLightSdkInit(); + expect(Sentry.isInitialized()).toBe(true); + }); + }); + + describe('close', () => { + it('flushes and closes the client', async () => { + const client = mockLightSdkInit(); + + const flushSpy = vi.spyOn(client!, 'flush'); + + await Sentry.close(); + + expect(flushSpy).toHaveBeenCalled(); + }); + }); + + describe('flush', () => { + it('flushes pending events', async () => { + const beforeSend = vi.fn(() => null); + mockLightSdkInit({ beforeSend }); + + Sentry.captureException(new Error('test')); + + await Sentry.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + }); + }); +}); From d305c834a25356ad0a36359dbdbcf99dc68104f3 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 30 Jan 2026 18:54:27 +0100 Subject: [PATCH 03/26] Add e2e test app for node-core/light --- .../node-core-light-express/.gitignore | 4 + .../node-core-light-express/.npmrc | 2 + .../node-core-light-express/package.json | 36 +++++++++ .../playwright.config.ts | 8 ++ .../node-core-light-express/src/app.ts | 78 +++++++++++++++++++ .../start-event-proxy.mjs | 6 ++ .../tests/errors.test.ts | 16 ++++ .../tests/request-isolation.test.ts | 67 ++++++++++++++++ .../node-core-light-express/tsconfig.json | 18 +++++ 9 files changed, 235 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-express/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-express/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-express/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-express/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-express/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-express/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-express/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-express/tests/request-isolation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-core-light-express/tsconfig.json 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"] +} From deb6488ece6c0c68a96d9526391a806bc3b72c58 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 30 Jan 2026 18:54:30 +0100 Subject: [PATCH 04/26] Add changelog entry --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86dc81075779..b398cd9c7186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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()`. @@ -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 errors-only SDK that does not depend on OpenTelemetry. + + Use this SDK when: + - You only need error tracking without performance monitoring + - You want to minimize bundle size and runtime overhead + - You don't need OpenTelemetry instrumentation + + It supports basic error tracking and report, 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 From 63b28634b710401137b6ddda98b7aaa05cf7b946 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:55:33 +0100 Subject: [PATCH 05/26] Update CHANGELOG.md Co-authored-by: Charly Gomez --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b398cd9c7186..224cd570aa60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ export default withSentryConfig(nextConfig, { - You want to minimize bundle size and runtime overhead - You don't need OpenTelemetry instrumentation - It supports basic error tracking and report, automatic request isolation (requires Node.js 22+) and basic tracing via our `Sentry.startSpan*` APIs. + It supports basic error tracking and reporting, automatic request isolation (requires Node.js 22+) and basic tracing via our `Sentry.startSpan*` APIs. Install the SDK by running From 01a43398e9800e9731b4017eed8ac4ff7c0ceebd Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 15:02:26 +0100 Subject: [PATCH 06/26] Apply sdk name as sentry.javascript.node-light (with npm package @sentry/node-core) --- packages/node-core/src/light/client.ts | 2 +- packages/node-core/src/light/sdk.ts | 2 +- packages/node-core/test/light/sdk.test.ts | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/node-core/src/light/client.ts b/packages/node-core/src/light/client.ts index fe97009419b4..074f5231009e 100644 --- a/packages/node-core/src/light/client.ts +++ b/packages/node-core/src/light/client.ts @@ -26,7 +26,7 @@ export class LightNodeClient extends ServerRuntimeClient { serverName, }; - applySdkMetadata(clientOptions, 'node'); + applySdkMetadata(clientOptions, 'node-light', ['node-core']); debug.log(`Initializing Sentry: process: ${process.pid}, thread: ${isMainThread ? 'main' : `worker-${threadId}`}.`); diff --git a/packages/node-core/src/light/sdk.ts b/packages/node-core/src/light/sdk.ts index 6237a11008a3..fd041a186c04 100644 --- a/packages/node-core/src/light/sdk.ts +++ b/packages/node-core/src/light/sdk.ts @@ -121,7 +121,7 @@ function _init( ); } - applySdkMetadata(options, 'node-core', ['node-core-light']); + applySdkMetadata(options, 'node-light', ['node-core']); const client = new LightNodeClient(options); // The client is on the current scope, from where it generally is inherited diff --git a/packages/node-core/test/light/sdk.test.ts b/packages/node-core/test/light/sdk.test.ts index 5c91e11db659..8fb25e4f2530 100644 --- a/packages/node-core/test/light/sdk.test.ts +++ b/packages/node-core/test/light/sdk.test.ts @@ -15,6 +15,19 @@ describe('Light Mode | SDK', () => { expect(client).toBeInstanceOf(LightNodeClient); }); + it('sets correct SDK metadata', () => { + const client = mockLightSdkInit(); + + const metadata = client?.getOptions()._metadata; + expect(metadata?.sdk?.name).toBe('sentry.javascript.node-light'); + expect(metadata?.sdk?.packages).toEqual([ + { + name: 'npm:@sentry/node-core', + version: expect.any(String), + }, + ]); + }); + it('sets the client on the current scope', () => { const client = mockLightSdkInit(); From 4e3785eb274b9c84923dc8d56359fedcec9dd271 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 15:40:35 +0100 Subject: [PATCH 07/26] Clarify jsdoc --- .../node-core/src/light/integrations/httpServerIntegration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-core/src/light/integrations/httpServerIntegration.ts b/packages/node-core/src/light/integrations/httpServerIntegration.ts index f4f1157c1d23..465f4b684878 100644 --- a/packages/node-core/src/light/integrations/httpServerIntegration.ts +++ b/packages/node-core/src/light/integrations/httpServerIntegration.ts @@ -73,7 +73,7 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => * 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. + * It uses Node's native AsyncLocalStorage for scope isolation and Sentry's continueTrace for trace propagation. * * Note: This integration requires Node.js 22+ (for http.server.request.start diagnostics channel). * From f1084ce2a863b6d403f53a3650c5aebb0a6bce0f Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 15:46:08 +0100 Subject: [PATCH 08/26] Remove light-mode message from http integration on incoming requests --- .../node-core/src/light/integrations/httpServerIntegration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-core/src/light/integrations/httpServerIntegration.ts b/packages/node-core/src/light/integrations/httpServerIntegration.ts index 465f4b684878..7401089579fd 100644 --- a/packages/node-core/src/light/integrations/httpServerIntegration.ts +++ b/packages/node-core/src/light/integrations/httpServerIntegration.ts @@ -119,7 +119,7 @@ function instrumentServer( return target.apply(thisArg, args); } - DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Handling incoming request (light mode)'); + DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Handling incoming request'); const isolationScope = getIsolationScope().clone(); const request = args[1] as IncomingMessage; From ed91667af9137415b40d2266bd39d465360133a8 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 19:06:35 +0100 Subject: [PATCH 09/26] Guard ipAddress with sendDefaultPii in light httpServerIntegration --- .../tests/errors.test.ts | 3 + .../suites/light-mode/ipAddress/test.ts | 55 +++++++++++++++++++ .../ipAddress/with-sendDefaultPii/server.js | 25 +++++++++ .../without-requestDataIntegration/server.js | 26 +++++++++ .../without-sendDefaultPii/server.js | 24 ++++++++ .../integrations/httpServerIntegration.ts | 5 +- 6 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/test.ts create mode 100644 dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/with-sendDefaultPii/server.js create mode 100644 dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/without-requestDataIntegration/server.js create mode 100644 dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/without-sendDefaultPii/server.js 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 index ecc90638b97c..38e6f4881a59 100644 --- 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 @@ -13,4 +13,7 @@ test('should capture errors', async ({ request }) => { 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(); }); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/test.ts new file mode 100644 index 000000000000..7c320fda6452 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/test.ts @@ -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(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/with-sendDefaultPii/server.js b/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/with-sendDefaultPii/server.js new file mode 100644 index 000000000000..769bc86ebc12 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/with-sendDefaultPii/server.js @@ -0,0 +1,25 @@ +const http = require('http'); +const Sentry = require('@sentry/node-core/light'); +const { loggingTransport, sendPortToRunner } = require('@sentry-internal/node-core-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + sendDefaultPii: true, +}); + +const server = http.createServer((req, res) => { + if (req.url === '/test-error') { + Sentry.captureException(new Error('test error')); + res.writeHead(200); + res.end('ok'); + } else { + res.writeHead(404); + res.end(); + } +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/without-requestDataIntegration/server.js b/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/without-requestDataIntegration/server.js new file mode 100644 index 000000000000..a0b1145f978d --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/without-requestDataIntegration/server.js @@ -0,0 +1,26 @@ +const http = require('http'); +const Sentry = require('@sentry/node-core/light'); +const { loggingTransport, sendPortToRunner } = require('@sentry-internal/node-core-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + sendDefaultPii: true, + integrations: integrations => integrations.filter(i => i.name !== 'RequestData'), +}); + +const server = http.createServer((req, res) => { + if (req.url === '/test-error') { + Sentry.captureException(new Error('test error')); + res.writeHead(200); + res.end('ok'); + } else { + res.writeHead(404); + res.end(); + } +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/without-sendDefaultPii/server.js b/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/without-sendDefaultPii/server.js new file mode 100644 index 000000000000..51bba537a20b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/ipAddress/without-sendDefaultPii/server.js @@ -0,0 +1,24 @@ +const http = require('http'); +const Sentry = require('@sentry/node-core/light'); +const { loggingTransport, sendPortToRunner } = require('@sentry-internal/node-core-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +const server = http.createServer((req, res) => { + if (req.url === '/test-error') { + Sentry.captureException(new Error('test error')); + res.writeHead(200); + res.end('ok'); + } else { + res.writeHead(404); + res.end(); + } +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/packages/node-core/src/light/integrations/httpServerIntegration.ts b/packages/node-core/src/light/integrations/httpServerIntegration.ts index 7401089579fd..81eb4d3b95d0 100644 --- a/packages/node-core/src/light/integrations/httpServerIntegration.ts +++ b/packages/node-core/src/light/integrations/httpServerIntegration.ts @@ -135,7 +135,10 @@ function instrumentServer( } // Update the isolation scope, isolate this request - isolationScope.setSDKProcessingMetadata({ normalizedRequest, ipAddress }); + isolationScope.setSDKProcessingMetadata({ + normalizedRequest, + ...(client.getOptions().sendDefaultPii && { ipAddress }), + }); // attempt to update the scope's `transactionName` based on the request URL // Ideally, framework instrumentations coming after the HttpInstrumentation From 96ecc889fedc0cec1609d33d625af167070b8044 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 19:27:47 +0100 Subject: [PATCH 10/26] Move propagationSpanId assignment after continueTrace --- .../suites/light-mode/propagation/server.js | 29 +++++++++++++++++++ .../suites/light-mode/propagation/test.ts | 22 ++++++++++++++ .../integrations/httpServerIntegration.ts | 8 ++--- 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js create mode 100644 dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js new file mode 100644 index 000000000000..74bb86d63cc8 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js @@ -0,0 +1,29 @@ +const http = require('http'); +const Sentry = require('@sentry/node-core/light'); +const { loggingTransport, sendPortToRunner } = require('@sentry-internal/node-core-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +const server = http.createServer((req, res) => { + if (req.url === '/test-propagation') { + const traceData1 = Sentry.getTraceData(); + const traceData2 = Sentry.getTraceData(); + + const spanId1 = traceData1['sentry-trace']?.split('-')[1]; + const spanId2 = traceData2['sentry-trace']?.split('-')[1]; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ spanId1, spanId2 })); + } else { + res.writeHead(404); + res.end(); + } +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts new file mode 100644 index 000000000000..4eb6c9c0d680 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts @@ -0,0 +1,22 @@ +import { afterAll, expect, test } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +conditionalTest({ min: 22 })('light mode propagationSpanId', () => { + test('getTraceData returns consistent span ID within a request', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + + const response = await runner.makeRequest<{ spanId1: string; spanId2: string }>( + 'get', + '/test-propagation', + ); + + expect(response?.spanId1).toBeDefined(); + expect(response?.spanId2).toBeDefined(); + expect(response?.spanId1).toBe(response?.spanId2); + }); +}); diff --git a/packages/node-core/src/light/integrations/httpServerIntegration.ts b/packages/node-core/src/light/integrations/httpServerIntegration.ts index 81eb4d3b95d0..e87adbf348eb 100644 --- a/packages/node-core/src/light/integrations/httpServerIntegration.ts +++ b/packages/node-core/src/light/integrations/httpServerIntegration.ts @@ -151,11 +151,6 @@ function instrumentServer( 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']; @@ -167,6 +162,9 @@ function instrumentServer( baggage: Array.isArray(baggage) ? baggage[0] : baggage, }, () => { + // Set propagationSpanId after continueTrace because it calls withScope + + // setPropagationContext internally, which would overwrite any previously set value. + getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId(); return target.apply(thisArg, args); }, ); From 9743db05199f12031b63de53eb0ab9555fabecf5 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 19:29:14 +0100 Subject: [PATCH 11/26] Remove stale FIX comments from asyncLocalStorageStrategy --- packages/node-core/src/light/asyncLocalStorageStrategy.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/node-core/src/light/asyncLocalStorageStrategy.ts b/packages/node-core/src/light/asyncLocalStorageStrategy.ts index af4808a091c5..1d6f3f413e59 100644 --- a/packages/node-core/src/light/asyncLocalStorageStrategy.ts +++ b/packages/node-core/src/light/asyncLocalStorageStrategy.ts @@ -45,7 +45,6 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { } 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 }, () => { @@ -54,7 +53,6 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { } 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); From 48c9f19ee6516300fbe3e322463eebdba0f63723 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 19:43:34 +0100 Subject: [PATCH 12/26] Add e2e tests for trace continuation and trace isolation Add test verifying incoming sentry-trace and baggage headers are correctly continued via continueTrace in light mode. Also add trace ID isolation assertions to the concurrent error test. --- .../node-core-light-express/src/app.ts | 5 +++ .../tests/request-isolation.test.ts | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+) 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 index d00d01eaa23d..389b3d0086c5 100644 --- 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 @@ -69,6 +69,11 @@ app.get('/test-isolation-error/:userId', (req, res) => { 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' }); }); 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 index 0e8cdc78ed16..daf554cb8765 100644 --- 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 @@ -1,3 +1,4 @@ +import crypto from 'crypto'; import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; @@ -64,4 +65,40 @@ test('should isolate errors across concurrent requests', async ({ request }) => 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); }); From 6abe083df08e5512a26c43a1c4d1152cdc964cea Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 19:46:23 +0100 Subject: [PATCH 13/26] Add integration test for trace continuation from incoming headers --- .../suites/light-mode/propagation/server.js | 4 +++ .../suites/light-mode/propagation/test.ts | 26 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js index 74bb86d63cc8..aa76417a3f0c 100644 --- a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js +++ b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js @@ -18,6 +18,10 @@ const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ spanId1, spanId2 })); + } else if (req.url === '/test-trace-continuation') { + Sentry.captureException(new Error('Trace continuation error')); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); } else { res.writeHead(404); res.end(); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts index 4eb6c9c0d680..f093f255a05d 100644 --- a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts +++ b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto'; import { afterAll, expect, test } from 'vitest'; import { conditionalTest } from '../../../utils'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; @@ -6,7 +7,7 @@ afterAll(() => { cleanupChildProcesses(); }); -conditionalTest({ min: 22 })('light mode propagationSpanId', () => { +conditionalTest({ min: 22 })('light mode propagation', () => { test('getTraceData returns consistent span ID within a request', async () => { const runner = createRunner(__dirname, 'server.js').start(); @@ -19,4 +20,27 @@ conditionalTest({ min: 22 })('light mode propagationSpanId', () => { expect(response?.spanId2).toBeDefined(); expect(response?.spanId1).toBe(response?.spanId2); }); + + test('continues trace from incoming sentry-trace and baggage headers', async () => { + const traceId = crypto.randomUUID().replace(/-/g, ''); + const parentSpanId = traceId.substring(0, 16); + + const runner = createRunner(__dirname, 'server.js') + .expect({ + event: event => { + expect(event.contexts?.trace?.trace_id).toBe(traceId); + expect(event.contexts?.trace?.parent_span_id).toBe(parentSpanId); + }, + }) + .start(); + + await runner.makeRequest('get', '/test-trace-continuation', { + headers: { + 'sentry-trace': `${traceId}-${parentSpanId}-1`, + baggage: `sentry-trace_id=${traceId},sentry-environment=test,sentry-public_key=public`, + }, + }); + + await runner.completed(); + }); }); From 0ddd14f743a9097e5219252845d3498ee395fd4b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 19:47:36 +0100 Subject: [PATCH 14/26] Import MAX_BODY_BYTE_LENGTH from constants instead of redeclaring --- packages/node-core/src/utils/captureRequestBody.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/node-core/src/utils/captureRequestBody.ts b/packages/node-core/src/utils/captureRequestBody.ts index 89b59a1de4b7..3382409e0991 100644 --- a/packages/node-core/src/utils/captureRequestBody.ts +++ b/packages/node-core/src/utils/captureRequestBody.ts @@ -2,8 +2,7 @@ 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 = 1024 * 1024; +import { MAX_BODY_BYTE_LENGTH } from '../integrations/http/constants'; /** * This method patches the request object to capture the body. From eae82cf4733080c5033a6bbae76f42dfd2a206d3 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 19:58:22 +0100 Subject: [PATCH 15/26] Remove deprecated exports and use eventFiltersIntegration Drop anrIntegration, disableAnrDetectionForCallback, and inboundFiltersIntegration from the light entry point since this is a new entry point with no existing users. Switch getDefaultIntegrations to use eventFiltersIntegration directly. --- packages/node-core/src/light/index.ts | 4 ---- packages/node-core/src/light/sdk.ts | 6 ++---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/node-core/src/light/index.ts b/packages/node-core/src/light/index.ts index e5a53e328fa9..62f168449d06 100644 --- a/packages/node-core/src/light/index.ts +++ b/packages/node-core/src/light/index.ts @@ -14,8 +14,6 @@ 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'; @@ -58,8 +56,6 @@ export { withMonitor, requestDataIntegration, functionToStringIntegration, - // eslint-disable-next-line deprecation/deprecation - inboundFiltersIntegration, eventFiltersIntegration, linkedErrorsIntegration, addEventProcessor, diff --git a/packages/node-core/src/light/sdk.ts b/packages/node-core/src/light/sdk.ts index fd041a186c04..d6e35f309278 100644 --- a/packages/node-core/src/light/sdk.ts +++ b/packages/node-core/src/light/sdk.ts @@ -4,11 +4,11 @@ import { consoleIntegration, consoleSandbox, debug, + eventFiltersIntegration, functionToStringIntegration, getCurrentScope, getIntegrationsToSetup, GLOBAL_OBJ, - inboundFiltersIntegration, linkedErrorsIntegration, propagationContextFromHeaders, requestDataIntegration, @@ -43,9 +43,7 @@ import { httpServerIntegration } from './integrations/httpServerIntegration'; export function getDefaultIntegrations(): Integration[] { return [ // Common - // TODO(v11): Replace with `eventFiltersIntegration` once we remove the deprecated `inboundFiltersIntegration` - // eslint-disable-next-line deprecation/deprecation - inboundFiltersIntegration(), + eventFiltersIntegration(), functionToStringIntegration(), linkedErrorsIntegration(), requestDataIntegration(), From 9433879d7503772f88f1ff7bbd1807883afb693d Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 19:58:27 +0100 Subject: [PATCH 16/26] Update README to not undersell light mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Light mode supports logs, metrics, and distributed tracing — not just error tracking. Rename section from "Errors-only" to "Lightweight Mode" and update feature list accordingly. --- packages/node-core/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/node-core/README.md b/packages/node-core/README.md index 76a07606a9d3..724ed3a83be5 100644 --- a/packages/node-core/README.md +++ b/packages/node-core/README.md @@ -116,13 +116,13 @@ 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 +## Lightweight Mode > **⚠️ Experimental**: The `@sentry/node-core/light` subpath export is experimental and may receive breaking changes in minor or patch releases. -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: +If you don't need automatic spans/transactions, you can use the lightweight mode which doesn't require OpenTelemetry dependencies. This mode is ideal for: -- Applications that only need error tracking +- Applications that don't need automatic performance monitoring - Reducing bundle size and runtime overhead - Environments where OpenTelemetry isn't needed @@ -163,6 +163,7 @@ const app = express(); **Included:** - Error tracking and reporting +- Logs and metrics - Automatic request isolation (Node.js 22+) - Breadcrumbs - Context and user data @@ -171,7 +172,7 @@ const app = express(); **Not included:** -- Performance monitoring (no spans/transactions) +- Automatic spans/transactions (no OpenTelemetry instrumentation) ### Automatic Request Isolation From b1ef70d9ce7708769fd4a916b211a6f8eed7515b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 20:31:13 +0100 Subject: [PATCH 17/26] Add integration tests for logs and metrics in light mode --- .../suites/light-mode/logs/subject.js | 18 +++++ .../suites/light-mode/logs/test.ts | 50 ++++++++++++++ .../suites/light-mode/metrics/subject.js | 19 ++++++ .../suites/light-mode/metrics/test.ts | 66 +++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 dev-packages/node-core-integration-tests/suites/light-mode/logs/subject.js create mode 100644 dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts create mode 100644 dev-packages/node-core-integration-tests/suites/light-mode/metrics/subject.js create mode 100644 dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/logs/subject.js b/dev-packages/node-core-integration-tests/suites/light-mode/logs/subject.js new file mode 100644 index 000000000000..a0810b7a9a41 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/logs/subject.js @@ -0,0 +1,18 @@ +const Sentry = require('@sentry/node-core/light'); +const { loggingTransport } = require('@sentry-internal/node-core-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + transport: loggingTransport, + enableLogs: true, +}); + +async function run() { + Sentry.logger.info('test info log', { key: 'value' }); + Sentry.logger.error('test error log'); + + await Sentry.flush(); +} + +void run(); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts new file mode 100644 index 000000000000..f1dfde5ecdf8 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/logs/test.ts @@ -0,0 +1,50 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('light mode logs', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('captures logs with trace context', async () => { + const runner = createRunner(__dirname, 'subject.js') + .expect({ + log: logsContainer => { + expect(logsContainer).toEqual({ + items: [ + { + attributes: { + key: { type: 'string', value: 'value' }, + 'sentry.release': { type: 'string', value: '1.0.0' }, + 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node-light' }, + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'server.address': { type: 'string', value: expect.any(String) }, + }, + body: 'test info log', + level: 'info', + severity_number: 9, + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }, + { + attributes: { + 'sentry.release': { type: 'string', value: '1.0.0' }, + 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node-light' }, + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'server.address': { type: 'string', value: expect.any(String) }, + }, + body: 'test error log', + level: 'error', + severity_number: 17, + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }, + ], + }); + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/metrics/subject.js b/dev-packages/node-core-integration-tests/suites/light-mode/metrics/subject.js new file mode 100644 index 000000000000..0ed06631fce6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/metrics/subject.js @@ -0,0 +1,19 @@ +const Sentry = require('@sentry/node-core/light'); +const { loggingTransport } = require('@sentry-internal/node-core-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, +}); + +async function run() { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + Sentry.metrics.gauge('test.gauge', 42, { unit: 'millisecond', attributes: { server: 'test-1' } }); + Sentry.metrics.distribution('test.distribution', 200, { unit: 'second', attributes: { priority: 'high' } }); + + await Sentry.flush(); +} + +void run(); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts new file mode 100644 index 000000000000..c0c9d291de78 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/metrics/test.ts @@ -0,0 +1,66 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('light mode metrics', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('captures all metric types with trace context', async () => { + const runner = createRunner(__dirname, 'subject.js') + .unignore('trace_metric') + .expect({ + trace_metric: { + items: [ + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/api/test', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node-light', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.gauge', + type: 'gauge', + unit: 'millisecond', + value: 42, + attributes: { + server: { value: 'test-1', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node-light', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.distribution', + type: 'distribution', + unit: 'second', + value: 200, + attributes: { + priority: { value: 'high', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node-light', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); +}); From efc79f8eb4ea2b3afdfd4f3801ed870266a796ce Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 6 Feb 2026 21:04:02 +0100 Subject: [PATCH 18/26] Extract common exports shared between main and light entry points Avoids drift between the two entry points by keeping shared exports in a single file. Entry-point-specific and deprecated exports remain in their respective index files. --- .../suites/light-mode/propagation/test.ts | 5 +- packages/node-core/src/common-exports.ts | 142 +++++++++++++++++ packages/node-core/src/index.ts | 146 ++---------------- packages/node-core/src/light/index.ts | 138 +---------------- 4 files changed, 157 insertions(+), 274 deletions(-) create mode 100644 packages/node-core/src/common-exports.ts diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts index f093f255a05d..345e743ffbb9 100644 --- a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts +++ b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts @@ -11,10 +11,7 @@ conditionalTest({ min: 22 })('light mode propagation', () => { test('getTraceData returns consistent span ID within a request', async () => { const runner = createRunner(__dirname, 'server.js').start(); - const response = await runner.makeRequest<{ spanId1: string; spanId2: string }>( - 'get', - '/test-propagation', - ); + const response = await runner.makeRequest<{ spanId1: string; spanId2: string }>('get', '/test-propagation'); expect(response?.spanId1).toBeDefined(); expect(response?.spanId2).toBeDefined(); diff --git a/packages/node-core/src/common-exports.ts b/packages/node-core/src/common-exports.ts new file mode 100644 index 000000000000..5fd9df08c808 --- /dev/null +++ b/packages/node-core/src/common-exports.ts @@ -0,0 +1,142 @@ +/** + * Common exports shared between the main entry point (index.ts) and the light entry point (light/index.ts). + * + * Add exports here that should be available in both entry points. Entry-point-specific exports + * (e.g., OTel-dependent ones for index.ts, or light-specific ones for light/index.ts) should + * remain in their respective index files. + * + * Deprecated exports should NOT go in this file — they belong in the entry point that still + * needs to ship them for backwards compatibility. + */ +import * as logger from './logs/exports'; + +// Node-core integrations (not OTel-dependent) +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'; +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 +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 from @sentry/core +export { + addBreadcrumb, + isInitialized, + isEnabled, + getGlobalScope, + lastEventId, + close, + createTransport, + flush, + SDK_VERSION, + getSpanStatusFromHttpCode, + setHttpStatus, + captureCheckIn, + withMonitor, + requestDataIntegration, + functionToStringIntegration, + 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/index.ts b/packages/node-core/src/index.ts index c028e8b5e22c..a9633b94c25d 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -1,5 +1,4 @@ -import * as logger from './logs/exports'; - +// OTel-specific exports (not available in light mode) export { httpIntegration } from './integrations/http'; export { httpServerSpansIntegration } from './integrations/http/httpServerSpansIntegration'; export { httpServerIntegration } from './integrations/http/httpServerIntegration'; @@ -14,151 +13,30 @@ export { type SentryNodeFetchInstrumentationOptions, } from './integrations/node-fetch/SentryNodeFetchInstrumentation'; -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 { processSessionIntegration } from './integrations/processSession'; -export { createSentryWinstonTransport } from './integrations/winston'; -export { pinoIntegration } from './integrations/pino'; - export { SentryContextManager } from './otel/contextManager'; export { setupOpenTelemetryLogger } from './otel/logger'; export { generateInstrumentOnce, instrumentWhenWrapped, INSTRUMENTED } from './otel/instrument'; export { init, getDefaultIntegrations, initWithoutDefaultIntegrations, validateOpenTelemetrySetup } from './sdk'; export { setIsolationScope } from './sdk/scope'; -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 { ensureIsWrapped } from './utils/ensureIsWrapped'; -export { createMissingInstrumentationContext } from './utils/createMissingInstrumentationContext'; -export { envToBool } from './utils/envToBool'; -export { makeNodeTransport, type NodeTransportOptions } from './transports'; -export type { HTTPModuleRequestIncomingMessage } from './transports/http-module'; export { NodeClient } from './sdk/client'; -export { cron } from './cron'; -export { NODE_VERSION } from './nodeVersion'; +export { ensureIsWrapped } from './utils/ensureIsWrapped'; +export { processSessionIntegration } from './integrations/processSession'; -export type { NodeOptions, OpenTelemetryServerRuntimeOptions } from './types'; +export type { OpenTelemetryServerRuntimeOptions } from './types'; export { // This needs exporting so the NodeClient can be used without calling init setOpenTelemetryContextAsyncContextStrategy as setNodeAsyncContextStrategy, } from '@sentry/opentelemetry'; -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'; +// Deprecated exports (do not add to common-exports.ts) +// eslint-disable-next-line deprecation/deprecation +export { anrIntegration, disableAnrDetectionForCallback } from './integrations/anr'; +// eslint-disable-next-line deprecation/deprecation +export { inboundFiltersIntegration } from '@sentry/core'; -export type { - Breadcrumb, - BreadcrumbHint, - PolymorphicRequest, - RequestEventData, - SdkInfo, - Event, - EventHint, - ErrorEvent, - Exception, - Session, - SeverityLevel, - StackFrame, - Stacktrace, - Thread, - User, - Span, - FeatureFlagsIntegration, - ExclusiveEventHintOrCaptureContext, - CaptureContext, -} from '@sentry/core'; +export type { ExclusiveEventHintOrCaptureContext, CaptureContext } from '@sentry/core'; -export { logger }; +// Common exports shared with the light entry point +export * from './common-exports'; diff --git a/packages/node-core/src/light/index.ts b/packages/node-core/src/light/index.ts index 62f168449d06..55ba169dfc42 100644 --- a/packages/node-core/src/light/index.ts +++ b/packages/node-core/src/light/index.ts @@ -1,142 +1,8 @@ -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'; -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, - 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 }; +// Common exports shared with the main entry point +export * from '../common-exports'; From e8ebcc35d99e75bf4f8be0c69832461cb80b220b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 9 Feb 2026 10:13:16 +0100 Subject: [PATCH 19/26] Fix expected integrations test in node-core light --- packages/node-core/test/light/sdk.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-core/test/light/sdk.test.ts b/packages/node-core/test/light/sdk.test.ts index 8fb25e4f2530..95f4dc5b4f42 100644 --- a/packages/node-core/test/light/sdk.test.ts +++ b/packages/node-core/test/light/sdk.test.ts @@ -86,7 +86,7 @@ describe('Light Mode | SDK', () => { // Check that some expected integrations are present const integrationNames = integrations.map(i => i.name); - expect(integrationNames).toContain('InboundFilters'); + expect(integrationNames).toContain('EventFilters'); expect(integrationNames).toContain('FunctionToString'); expect(integrationNames).toContain('LinkedErrors'); expect(integrationNames).toContain('OnUncaughtException'); From e3c2355368fd8ec8bd4106caf321c610ce467a31 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 9 Feb 2026 11:14:11 +0100 Subject: [PATCH 20/26] Remove guarding of ip in http server integration --- .../src/light/integrations/httpServerIntegration.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/node-core/src/light/integrations/httpServerIntegration.ts b/packages/node-core/src/light/integrations/httpServerIntegration.ts index e87adbf348eb..4e8cd4c9c267 100644 --- a/packages/node-core/src/light/integrations/httpServerIntegration.ts +++ b/packages/node-core/src/light/integrations/httpServerIntegration.ts @@ -135,10 +135,7 @@ function instrumentServer( } // Update the isolation scope, isolate this request - isolationScope.setSDKProcessingMetadata({ - normalizedRequest, - ...(client.getOptions().sendDefaultPii && { ipAddress }), - }); + isolationScope.setSDKProcessingMetadata({ normalizedRequest, ipAddress }); // attempt to update the scope's `transactionName` based on the request URL // Ideally, framework instrumentations coming after the HttpInstrumentation From 2f793ceb5e504b32296a4c3c694f5c53d9b46eaa Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 9 Feb 2026 11:36:51 +0100 Subject: [PATCH 21/26] Ensure spotlight is set up correctly for node-core/light too --- packages/node-core/src/light/sdk.ts | 4 +- packages/node-core/src/sdk/index.ts | 18 +---- packages/node-core/src/utils/spotlight.ts | 25 +++++++ .../node-core/test/utils/spotlight.test.ts | 67 +++++++++++++++++++ 4 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 packages/node-core/src/utils/spotlight.ts create mode 100644 packages/node-core/test/utils/spotlight.test.ts diff --git a/packages/node-core/src/light/sdk.ts b/packages/node-core/src/light/sdk.ts index d6e35f309278..f63a0a73b11f 100644 --- a/packages/node-core/src/light/sdk.ts +++ b/packages/node-core/src/light/sdk.ts @@ -31,6 +31,7 @@ import { makeNodeTransport } from '../transports'; import type { NodeClientOptions, NodeOptions } from '../types'; import { isCjs } from '../utils/detection'; import { envToBool } from '../utils/envToBool'; +import { getSpotlightConfig } from '../utils/spotlight'; import { setAsyncLocalStorageAsyncContextStrategy } from './asyncLocalStorageStrategy'; import { LightNodeClient } from './client'; import { httpServerIntegration } from './integrations/httpServerIntegration'; @@ -143,8 +144,7 @@ function getClientOptions( 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 spotlight = getSpotlightConfig(options.spotlight); const tracesSampleRate = getTracesSampleRate(options.tracesSampleRate); const mergedOptions = { diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index 3d6b4c61619e..22dd7b38d657 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -39,6 +39,7 @@ import { makeNodeTransport } from '../transports'; import type { NodeClientOptions, NodeOptions } from '../types'; import { isCjs } from '../utils/detection'; import { envToBool } from '../utils/envToBool'; +import { getSpotlightConfig } from '../utils/spotlight'; import { defaultStackParser, getSentryRelease } from './api'; import { NodeClient } from './client'; import { initializeEsmLoader } from './esmLoader'; @@ -194,22 +195,7 @@ function getClientOptions( ): NodeClientOptions { const release = getRelease(options.release); - // Parse spotlight configuration with proper precedence per spec - let spotlight: boolean | string | undefined; - if (options.spotlight === false) { - spotlight = false; - } else if (typeof options.spotlight === 'string') { - spotlight = options.spotlight; - } else { - // options.spotlight is true or undefined - const envBool = envToBool(process.env.SENTRY_SPOTLIGHT, { strict: true }); - const envUrl = envBool === null && process.env.SENTRY_SPOTLIGHT ? process.env.SENTRY_SPOTLIGHT : undefined; - - spotlight = - options.spotlight === true - ? (envUrl ?? true) // true: use env URL if present, otherwise true - : (envBool ?? envUrl); // undefined: use env var (bool or URL) - } + const spotlight = getSpotlightConfig(options.spotlight); const tracesSampleRate = getTracesSampleRate(options.tracesSampleRate); diff --git a/packages/node-core/src/utils/spotlight.ts b/packages/node-core/src/utils/spotlight.ts new file mode 100644 index 000000000000..1aa01e57b4e7 --- /dev/null +++ b/packages/node-core/src/utils/spotlight.ts @@ -0,0 +1,25 @@ +import { envToBool } from './envToBool'; + +/** + * Parse the spotlight option with proper precedence: + * - `false` or explicit string from options: use as-is + * - `true`: enable spotlight, but prefer a custom URL from the env var if set + * - `undefined`: defer entirely to the env var (bool or URL) + */ +export function getSpotlightConfig(optionsSpotlight: boolean | string | undefined): boolean | string | undefined { + if (optionsSpotlight === false) { + return false; + } + + if (typeof optionsSpotlight === 'string') { + return optionsSpotlight; + } + + // optionsSpotlight is true or undefined + const envBool = envToBool(process.env.SENTRY_SPOTLIGHT, { strict: true }); + const envUrl = envBool === null && process.env.SENTRY_SPOTLIGHT ? process.env.SENTRY_SPOTLIGHT : undefined; + + return optionsSpotlight === true + ? (envUrl ?? true) // true: use env URL if present, otherwise true + : (envBool ?? envUrl); // undefined: use env var (bool or URL) +} diff --git a/packages/node-core/test/utils/spotlight.test.ts b/packages/node-core/test/utils/spotlight.test.ts new file mode 100644 index 000000000000..26a52bef7a9b --- /dev/null +++ b/packages/node-core/test/utils/spotlight.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getSpotlightConfig } from '../../src/utils/spotlight'; + +describe('getSpotlightConfig', () => { + const originalEnv = process.env.SENTRY_SPOTLIGHT; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.SENTRY_SPOTLIGHT; + } else { + process.env.SENTRY_SPOTLIGHT = originalEnv; + } + }); + + describe('when options.spotlight is false', () => { + it('returns false regardless of env var', () => { + process.env.SENTRY_SPOTLIGHT = 'true'; + expect(getSpotlightConfig(false)).toBe(false); + }); + }); + + describe('when options.spotlight is a string', () => { + it('returns the string regardless of env var', () => { + process.env.SENTRY_SPOTLIGHT = 'http://env-url:8080'; + expect(getSpotlightConfig('http://custom:9000')).toBe('http://custom:9000'); + }); + }); + + describe('when options.spotlight is true', () => { + it('returns true when env var is not set', () => { + delete process.env.SENTRY_SPOTLIGHT; + expect(getSpotlightConfig(true)).toBe(true); + }); + + it('returns true when env var is a boolean string', () => { + process.env.SENTRY_SPOTLIGHT = 'true'; + expect(getSpotlightConfig(true)).toBe(true); + }); + + it('returns the env URL when env var is a custom URL', () => { + process.env.SENTRY_SPOTLIGHT = 'http://localhost:8080'; + expect(getSpotlightConfig(true)).toBe('http://localhost:8080'); + }); + }); + + describe('when options.spotlight is undefined', () => { + it('returns undefined when env var is not set', () => { + delete process.env.SENTRY_SPOTLIGHT; + expect(getSpotlightConfig(undefined)).toBeUndefined(); + }); + + it('returns true when env var is "true"', () => { + process.env.SENTRY_SPOTLIGHT = 'true'; + expect(getSpotlightConfig(undefined)).toBe(true); + }); + + it('returns false when env var is "false"', () => { + process.env.SENTRY_SPOTLIGHT = 'false'; + expect(getSpotlightConfig(undefined)).toBe(false); + }); + + it('returns the env URL when env var is a custom URL', () => { + process.env.SENTRY_SPOTLIGHT = 'http://localhost:8080'; + expect(getSpotlightConfig(undefined)).toBe('http://localhost:8080'); + }); + }); +}); From 1b398a812a5f4ef5996be3074e5917b4016ac69f Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 9 Feb 2026 12:59:03 +0100 Subject: [PATCH 22/26] Add integration tests for trace propagation for outgoing http/fetch requests --- .../suites/light-mode/propagation/server.js | 98 +++++++++++++++---- .../suites/light-mode/propagation/test.ts | 48 +++++++++ 2 files changed, 126 insertions(+), 20 deletions(-) diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js index aa76417a3f0c..5ce067268e2c 100644 --- a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js +++ b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/server.js @@ -8,26 +8,84 @@ Sentry.init({ transport: loggingTransport, }); -const server = http.createServer((req, res) => { - if (req.url === '/test-propagation') { - const traceData1 = Sentry.getTraceData(); - const traceData2 = Sentry.getTraceData(); - - const spanId1 = traceData1['sentry-trace']?.split('-')[1]; - const spanId2 = traceData2['sentry-trace']?.split('-')[1]; - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ spanId1, spanId2 })); - } else if (req.url === '/test-trace-continuation') { - Sentry.captureException(new Error('Trace continuation error')); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true })); - } else { - res.writeHead(404); - res.end(); - } +function makeHttpRequest(url, headers) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const req = http.request( + { + hostname: urlObj.hostname, + port: urlObj.port, + path: urlObj.pathname, + method: 'GET', + headers, + }, + res => { + res.on('data', () => {}); + res.on('end', () => resolve()); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +// Target server that captures headers from outgoing requests +let capturedHeaders = {}; +const targetServer = http.createServer((req, res) => { + capturedHeaders = { + 'sentry-trace': req.headers['sentry-trace'], + baggage: req.headers['baggage'], + }; + res.writeHead(200); + res.end('ok'); }); -server.listen(0, () => { - sendPortToRunner(server.address().port); +targetServer.listen(0, () => { + const targetUrl = `http://localhost:${targetServer.address().port}/target`; + + const server = http.createServer(async (req, res) => { + switch (req.url) { + case '/test-propagation': { + const traceData1 = Sentry.getTraceData(); + const traceData2 = Sentry.getTraceData(); + + const spanId1 = traceData1['sentry-trace']?.split('-')[1]; + const spanId2 = traceData2['sentry-trace']?.split('-')[1]; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ spanId1, spanId2 })); + break; + } + case '/test-trace-continuation': { + Sentry.captureException(new Error('Trace continuation error')); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + break; + } + case '/test-outgoing-http': { + capturedHeaders = {}; + const traceHeaders = Sentry.getTraceData(); + await makeHttpRequest(targetUrl, traceHeaders); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(capturedHeaders)); + break; + } + case '/test-outgoing-fetch': { + capturedHeaders = {}; + const traceHeaders = Sentry.getTraceData(); + await fetch(targetUrl, { headers: traceHeaders }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(capturedHeaders)); + break; + } + default: { + res.writeHead(404); + res.end(); + } + } + }); + + server.listen(0, () => { + sendPortToRunner(server.address().port); + }); }); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts index 345e743ffbb9..cdf4a35667c5 100644 --- a/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts +++ b/dev-packages/node-core-integration-tests/suites/light-mode/propagation/test.ts @@ -40,4 +40,52 @@ conditionalTest({ min: 22 })('light mode propagation', () => { await runner.completed(); }); + + test('propagates trace via getTraceData to outgoing http requests', async () => { + const traceId = crypto.randomUUID().replace(/-/g, ''); + const parentSpanId = traceId.substring(0, 16); + + const runner = createRunner(__dirname, 'server.js').start(); + + const response = await runner.makeRequest<{ 'sentry-trace': string; baggage: string }>( + 'get', + '/test-outgoing-http', + { + headers: { + 'sentry-trace': `${traceId}-${parentSpanId}-1`, + baggage: `sentry-trace_id=${traceId},sentry-environment=test,sentry-public_key=public`, + }, + }, + ); + + // Outgoing request should carry the same trace ID with a new span ID + expect(response?.['sentry-trace']).toMatch(new RegExp(`^${traceId}-[a-f\\d]{16}-1$`)); + const outgoingSpanId = response?.['sentry-trace']?.split('-')[1]; + expect(outgoingSpanId).not.toBe(parentSpanId); + expect(response?.baggage).toContain(`sentry-trace_id=${traceId}`); + }); + + test('propagates trace via getTraceData to outgoing fetch requests', async () => { + const traceId = crypto.randomUUID().replace(/-/g, ''); + const parentSpanId = traceId.substring(0, 16); + + const runner = createRunner(__dirname, 'server.js').start(); + + const response = await runner.makeRequest<{ 'sentry-trace': string; baggage: string }>( + 'get', + '/test-outgoing-fetch', + { + headers: { + 'sentry-trace': `${traceId}-${parentSpanId}-1`, + baggage: `sentry-trace_id=${traceId},sentry-environment=test,sentry-public_key=public`, + }, + }, + ); + + // Outgoing request should carry the same trace ID with a new span ID + expect(response?.['sentry-trace']).toMatch(new RegExp(`^${traceId}-[a-f\\d]{16}-1$`)); + const outgoingSpanId = response?.['sentry-trace']?.split('-')[1]; + expect(outgoingSpanId).not.toBe(parentSpanId); + expect(response?.baggage).toContain(`sentry-trace_id=${traceId}`); + }); }); From 89ce7f9df662c2a88ea9ce1848db788b9d8af43f Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 9 Feb 2026 13:25:05 +0100 Subject: [PATCH 23/26] Update changelog entry --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 224cd570aa60..5067c165ae38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,14 +29,14 @@ 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 errors-only SDK that does not depend on OpenTelemetry. + 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 without performance monitoring + - 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 OpenTelemetry instrumentation + - You don't need spans emitted by OpenTelemetry instrumentation - It supports basic error tracking and reporting, automatic request isolation (requires Node.js 22+) and basic tracing via our `Sentry.startSpan*` APIs. + 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 From 62497c6e09c473958b9e4cb3f04b3962798f97e4 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 9 Feb 2026 13:25:29 +0100 Subject: [PATCH 24/26] Add vercel sigterm flush --- packages/node-core/src/light/sdk.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/node-core/src/light/sdk.ts b/packages/node-core/src/light/sdk.ts index f63a0a73b11f..2633ccf10efd 100644 --- a/packages/node-core/src/light/sdk.ts +++ b/packages/node-core/src/light/sdk.ts @@ -136,6 +136,15 @@ function _init( updateScopeFromEnvVariables(); + // Ensure we flush events when vercel functions are ended + // See: https://vercel.com/docs/functions/functions-api-reference#sigterm-signal + if (process.env.VERCEL) { + process.on('SIGTERM', async () => { + // We have 500ms for processing here, so we try to make sure to have enough time to send the events + await client.flush(200); + }); + } + return client; } From b3332185644741d260db73aaebacf1c046ffb1bf Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 9 Feb 2026 15:15:32 +0100 Subject: [PATCH 25/26] Update README with suggestions --- packages/node-core/README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/node-core/README.md b/packages/node-core/README.md index 724ed3a83be5..a6245cbd9b0e 100644 --- a/packages/node-core/README.md +++ b/packages/node-core/README.md @@ -118,13 +118,17 @@ NODE_OPTIONS="--import ./instrument.mjs" npm run start ## Lightweight Mode +> [!WARNING] > **⚠️ Experimental**: The `@sentry/node-core/light` subpath export is experimental and may receive breaking changes in minor or patch releases. -If you don't need automatic spans/transactions, you can use the lightweight mode which doesn't require OpenTelemetry dependencies. This mode is ideal for: +> [!IMPORTANT] +> This SDK requires Node 22.12.0+ for full functionality. If you're using lower Node versions, this SDK only offers limited tracing support. Consider using `@sentry/node` or `@sentry/node-core` instead. -- Applications that don't need automatic performance monitoring -- Reducing bundle size and runtime overhead -- Environments where OpenTelemetry isn't needed +If you don't need automatic spans/transactions, you can use the lightweight mode which doesn't require OpenTelemetry dependencies. This mode is ideal for 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 ### Installation (Light Mode) @@ -200,7 +204,7 @@ app.get('/error', (req, res) => { ### 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`: +If you're using Node.js versions below 22.12.0, 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'; @@ -228,7 +232,7 @@ app.get('/error', (req, res) => { - 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 +- For full distributed tracing support, use Node.js 22.12.0+ or the full `@sentry/node` SDK with OpenTelemetry ## Links From 4ac28d53293d9b0aa18e914cabc01c47189460eb Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 9 Feb 2026 15:38:24 +0100 Subject: [PATCH 26/26] Remove initializing loader hooks in light sdk --- packages/node-core/src/light/sdk.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/node-core/src/light/sdk.ts b/packages/node-core/src/light/sdk.ts index 2633ccf10efd..acbea4649ceb 100644 --- a/packages/node-core/src/light/sdk.ts +++ b/packages/node-core/src/light/sdk.ts @@ -26,7 +26,6 @@ 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'; @@ -102,10 +101,6 @@ function _init( } } - if (options.registerEsmLoaderHooks !== false) { - initializeEsmLoader(); - } - // Use AsyncLocalStorage-based context strategy instead of OpenTelemetry setAsyncLocalStorageAsyncContextStrategy();