diff --git a/dev-packages/rollup-utils/utils.mjs b/dev-packages/rollup-utils/utils.mjs index 81fed5949f97..94b8c4483c23 100644 --- a/dev-packages/rollup-utils/utils.mjs +++ b/dev-packages/rollup-utils/utils.mjs @@ -21,7 +21,7 @@ export function mergePlugins(pluginsA, pluginsB) { // here. // Additionally, the excludeReplay plugin must run before TS/Sucrase so that we can eliminate the replay code // before anything is type-checked (TS-only) and transpiled. - const order = ['excludeReplay', 'typescript', 'sucrase', '...', 'terser', 'license']; + const order = ['excludeReplay', 'typescript', 'sucrase', '...', 'terser', 'license', 'output-base64-worker-script']; const sortKeyA = order.includes(a.name) ? a.name : '...'; const sortKeyB = order.includes(b.name) ? b.name : '...'; diff --git a/packages/node/rollup.anr-worker.config.mjs b/packages/node/rollup.anr-worker.config.mjs index bd3c1d4b825c..260f889cacb6 100644 --- a/packages/node/rollup.anr-worker.config.mjs +++ b/packages/node/rollup.anr-worker.config.mjs @@ -1,17 +1,18 @@ import { makeBaseBundleConfig } from '@sentry-internal/rollup-utils'; -export function createAnrWorkerCode() { +export function createWorkerCodeBuilder(entry, outDir) { let base64Code; - return { - workerRollupConfig: makeBaseBundleConfig({ + return [ + makeBaseBundleConfig({ bundleType: 'node-worker', - entrypoints: ['src/integrations/anr/worker.ts'], + entrypoints: [entry], + sucrase: { disableESTransforms: true }, licenseTitle: '@sentry/node', outputFileBase: () => 'worker-script.js', packageSpecificConfig: { output: { - dir: 'build/esm/integrations/anr', + dir: outDir, sourcemap: false, }, plugins: [ @@ -24,8 +25,8 @@ export function createAnrWorkerCode() { ], }, }), - getBase64Code() { + () => { return base64Code; }, - }; + ]; } diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 9622acb20112..c4a621cf7ee3 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -1,13 +1,22 @@ import replace from '@rollup/plugin-replace'; import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils'; -import { createAnrWorkerCode } from './rollup.anr-worker.config.mjs'; +import { createWorkerCodeBuilder } from './rollup.anr-worker.config.mjs'; -const { workerRollupConfig, getBase64Code } = createAnrWorkerCode(); +const [anrWorkerConfig, getAnrBase64Code] = createWorkerCodeBuilder( + 'src/integrations/anr/worker.ts', + 'build/esm/integrations/anr', +); + +const [localVariablesWorkerConfig, getLocalVariablesBase64Code] = createWorkerCodeBuilder( + 'src/integrations/local-variables/worker.ts', + 'build/esm/integrations/local-variables', +); export default [ ...makeOtelLoaders('./build', 'otel'), - // The worker needs to be built first since it's output is used in the main bundle. - workerRollupConfig, + // The workers needs to be built first since it's their output is copied in the main bundle. + anrWorkerConfig, + localVariablesWorkerConfig, ...makeNPMConfigVariants( makeBaseNPMConfig({ packageSpecificConfig: { @@ -23,10 +32,11 @@ export default [ plugins: [ replace({ delimiters: ['###', '###'], - // removes some webpack warnings + // removes some rollup warnings preventAssignment: true, values: { - base64WorkerScript: () => getBase64Code(), + AnrWorkerScript: () => getAnrBase64Code(), + LocalVariablesWorkerScript: () => getLocalVariablesBase64Code(), }, }), ], diff --git a/packages/node/src/integrations/anr/index.ts b/packages/node/src/integrations/anr/index.ts index 7dbe9e905cb4..2cf32289f082 100644 --- a/packages/node/src/integrations/anr/index.ts +++ b/packages/node/src/integrations/anr/index.ts @@ -7,7 +7,9 @@ import { getCurrentScope, getGlobalScope, getIsolationScope } from '../..'; import { NODE_VERSION } from '../../nodeVersion'; import type { NodeClient } from '../../sdk/client'; import type { AnrIntegrationOptions, WorkerStartData } from './common'; -import { base64WorkerScript } from './worker-script'; + +// This string is a placeholder that gets overwritten with the worker code. +export const base64WorkerScript = '###AnrWorkerScript###'; const DEFAULT_INTERVAL = 50; const DEFAULT_HANG_THRESHOLD = 5000; diff --git a/packages/node/src/integrations/anr/worker-script.ts b/packages/node/src/integrations/anr/worker-script.ts deleted file mode 100644 index c70323e0fc50..000000000000 --- a/packages/node/src/integrations/anr/worker-script.ts +++ /dev/null @@ -1,2 +0,0 @@ -// This string is a placeholder that gets overwritten with the worker code. -export const base64WorkerScript = '###base64WorkerScript###'; diff --git a/packages/node/src/integrations/local-variables/common.ts b/packages/node/src/integrations/local-variables/common.ts index 3ffee8c0a824..990a3d71b061 100644 --- a/packages/node/src/integrations/local-variables/common.ts +++ b/packages/node/src/integrations/local-variables/common.ts @@ -117,3 +117,16 @@ export interface LocalVariablesIntegrationOptions { */ maxExceptionsPerSecond?: number; } + +export interface LocalVariablesWorkerArgs extends LocalVariablesIntegrationOptions { + /** + * Whether to enable debug logging. + */ + debug: boolean; + /** + * Base path used to calculate module name. + * + * Defaults to `dirname(process.argv[1])` and falls back to `process.cwd()` + */ + basePath?: string; +} diff --git a/packages/node/src/integrations/local-variables/index.ts b/packages/node/src/integrations/local-variables/index.ts index 60649b03118f..36db00bdb9aa 100644 --- a/packages/node/src/integrations/local-variables/index.ts +++ b/packages/node/src/integrations/local-variables/index.ts @@ -1,3 +1,9 @@ +import type { Integration } from '@sentry/types'; +import { NODE_VERSION } from '../../nodeVersion'; +import type { LocalVariablesIntegrationOptions } from './common'; +import { localVariablesAsyncIntegration } from './local-variables-async'; import { localVariablesSyncIntegration } from './local-variables-sync'; -export const localVariablesIntegration = localVariablesSyncIntegration; +export const localVariablesIntegration = (options: LocalVariablesIntegrationOptions = {}): Integration => { + return NODE_VERSION.major < 19 ? localVariablesSyncIntegration(options) : localVariablesAsyncIntegration(options); +}; diff --git a/packages/node/src/integrations/local-variables/inspector.d.ts b/packages/node/src/integrations/local-variables/inspector.d.ts new file mode 100644 index 000000000000..9ac6b857dcc0 --- /dev/null +++ b/packages/node/src/integrations/local-variables/inspector.d.ts @@ -0,0 +1,31 @@ +/** + * @types/node doesn't have a `node:inspector/promises` module, maybe because it's still experimental? + */ +declare module 'node:inspector/promises' { + /** + * Async Debugger session + */ + class Session { + public constructor(); + + public connect(): void; + public connectToMainThread(): void; + + public post(method: 'Debugger.pause' | 'Debugger.resume' | 'Debugger.enable' | 'Debugger.disable'): Promise; + public post( + method: 'Debugger.setPauseOnExceptions', + params: Debugger.SetPauseOnExceptionsParameterType, + ): Promise; + public post( + method: 'Runtime.getProperties', + params: Runtime.GetPropertiesParameterType, + ): Promise; + + public on( + event: 'Debugger.paused', + listener: (message: InspectorNotification) => void, + ): Session; + + public on(event: 'Debugger.resumed', listener: () => void): Session; + } +} diff --git a/packages/node/src/integrations/local-variables/local-variables-async.ts b/packages/node/src/integrations/local-variables/local-variables-async.ts new file mode 100644 index 000000000000..b8e827909ea8 --- /dev/null +++ b/packages/node/src/integrations/local-variables/local-variables-async.ts @@ -0,0 +1,141 @@ +import { defineIntegration } from '@sentry/core'; +import type { Event, Exception, IntegrationFn } from '@sentry/types'; +import { LRUMap, logger } from '@sentry/utils'; +import { Worker } from 'worker_threads'; + +import type { NodeClient } from '../../sdk/client'; +import type { FrameVariables, LocalVariablesIntegrationOptions, LocalVariablesWorkerArgs } from './common'; +import { functionNamesMatch, hashFrames } from './common'; + +// This string is a placeholder that gets overwritten with the worker code. +export const base64WorkerScript = '###LocalVariablesWorkerScript###'; + +function log(...args: unknown[]): void { + logger.log('[LocalVariables]', ...args); +} + +/** + * Adds local variables to exception frames + */ +export const localVariablesAsyncIntegration = defineIntegration((( + integrationOptions: LocalVariablesIntegrationOptions = {}, +) => { + const cachedFrames: LRUMap = new LRUMap(20); + + function addLocalVariablesToException(exception: Exception): void { + const hash = hashFrames(exception?.stacktrace?.frames); + + if (hash === undefined) { + return; + } + + // Check if we have local variables for an exception that matches the hash + // remove is identical to get but also removes the entry from the cache + const cachedFrame = cachedFrames.remove(hash); + + if (cachedFrame === undefined) { + return; + } + + // Filter out frames where the function name is `new Promise` since these are in the error.stack frames + // but do not appear in the debugger call frames + const frames = (exception.stacktrace?.frames || []).filter(frame => frame.function !== 'new Promise'); + + for (let i = 0; i < frames.length; i++) { + // Sentry frames are in reverse order + const frameIndex = frames.length - i - 1; + + // Drop out if we run out of frames to match up + if (!frames[frameIndex] || !cachedFrame[i]) { + break; + } + + if ( + // We need to have vars to add + cachedFrame[i].vars === undefined || + // We're not interested in frames that are not in_app because the vars are not relevant + frames[frameIndex].in_app === false || + // The function names need to match + !functionNamesMatch(frames[frameIndex].function, cachedFrame[i].function) + ) { + continue; + } + + frames[frameIndex].vars = cachedFrame[i].vars; + } + } + + function addLocalVariablesToEvent(event: Event): Event { + for (const exception of event.exception?.values || []) { + addLocalVariablesToException(exception); + } + + return event; + } + + async function startInspector(): Promise { + // We load inspector dynamically because on some platforms Node is built without inspector support + const inspector = await import('inspector'); + if (!inspector.url()) { + inspector.open(0); + } + } + + function startWorker(options: LocalVariablesWorkerArgs): void { + const worker = new Worker(new URL(`data:application/javascript;base64,${base64WorkerScript}`), { + workerData: options, + }); + + process.on('exit', () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + worker.terminate(); + }); + + worker.on('message', ({ exceptionHash, frames }) => { + cachedFrames.set(exceptionHash, frames); + }); + + worker.once('error', (err: Error) => { + log('Worker error', err); + }); + + worker.once('exit', (code: number) => { + log('Worker exit', code); + }); + + // Ensure this thread can't block app exit + worker.unref(); + } + + return { + name: 'LocalVariablesAsync', + setup(client: NodeClient) { + const clientOptions = client.getOptions(); + + if (!clientOptions.includeLocalVariables) { + return; + } + + const options: LocalVariablesWorkerArgs = { + ...integrationOptions, + debug: logger.isEnabled(), + }; + + startInspector().then( + () => { + try { + startWorker(options); + } catch (e) { + logger.error('Failed to start worker', e); + } + }, + e => { + logger.error('Failed to start inspector', e); + }, + ); + }, + processEvent(event: Event): Event { + return addLocalVariablesToEvent(event); + }, + }; +}) satisfies IntegrationFn); diff --git a/packages/node/src/integrations/local-variables/worker.ts b/packages/node/src/integrations/local-variables/worker.ts new file mode 100644 index 000000000000..5a104ce74f5b --- /dev/null +++ b/packages/node/src/integrations/local-variables/worker.ts @@ -0,0 +1,182 @@ +import { Session } from 'node:inspector/promises'; +import type { StackParser } from '@sentry/types'; +import { createStackParser, nodeStackLineParser } from '@sentry/utils'; +import type { Debugger, InspectorNotification, Runtime } from 'inspector'; +import { parentPort, workerData } from 'worker_threads'; +import { createGetModuleFromFilename } from '../../utils/module'; +import type { LocalVariablesWorkerArgs, PausedExceptionEvent, RateLimitIncrement, Variables } from './common'; +import { createRateLimiter, hashFromStack } from './common'; + +const options: LocalVariablesWorkerArgs = workerData; + +const stackParser = createStackParser(nodeStackLineParser(createGetModuleFromFilename(options.basePath))); + +function log(...args: unknown[]): void { + if (options.debug) { + // eslint-disable-next-line no-console + console.log('[LocalVariables Worker]', ...args); + } +} + +async function unrollArray(session: Session, objectId: string, name: string, vars: Variables): Promise { + const properties: Runtime.GetPropertiesReturnType = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true, + }); + + vars[name] = properties.result + .filter(v => v.name !== 'length' && !isNaN(parseInt(v.name, 10))) + .sort((a, b) => parseInt(a.name, 10) - parseInt(b.name, 10)) + .map(v => v.value?.value); +} + +async function unrollObject(session: Session, objectId: string, name: string, vars: Variables): Promise { + const properties: Runtime.GetPropertiesReturnType = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true, + }); + + vars[name] = properties.result + .map<[string, unknown]>(v => [v.name, v.value?.value]) + .reduce((obj, [key, val]) => { + obj[key] = val; + return obj; + }, {} as Variables); +} + +function unrollOther(prop: Runtime.PropertyDescriptor, vars: Variables): void { + if (!prop.value) { + return; + } + + if ('value' in prop.value) { + if (prop.value.value === undefined || prop.value.value === null) { + vars[prop.name] = `<${prop.value.value}>`; + } else { + vars[prop.name] = prop.value.value; + } + } else if ('description' in prop.value && prop.value.type !== 'function') { + vars[prop.name] = `<${prop.value.description}>`; + } else if (prop.value.type === 'undefined') { + vars[prop.name] = ''; + } +} + +async function getLocalVariables(session: Session, objectId: string): Promise { + const properties: Runtime.GetPropertiesReturnType = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true, + }); + const variables = {}; + + for (const prop of properties.result) { + if (prop?.value?.objectId && prop?.value.className === 'Array') { + const id = prop.value.objectId; + await unrollArray(session, id, prop.name, variables); + } else if (prop?.value?.objectId && prop?.value?.className === 'Object') { + const id = prop.value.objectId; + await unrollObject(session, id, prop.name, variables); + } else if (prop?.value) { + unrollOther(prop, variables); + } + } + + return variables; +} + +let rateLimiter: RateLimitIncrement | undefined; + +async function handlePaused( + session: Session, + stackParser: StackParser, + { reason, data, callFrames }: PausedExceptionEvent, +): Promise { + if (reason !== 'exception' && reason !== 'promiseRejection') { + return; + } + + rateLimiter?.(); + + // data.description contains the original error.stack + const exceptionHash = hashFromStack(stackParser, data?.description); + + if (exceptionHash == undefined) { + return; + } + + const frames = []; + + for (let i = 0; i < callFrames.length; i++) { + const { scopeChain, functionName, this: obj } = callFrames[i]; + + const localScope = scopeChain.find(scope => scope.type === 'local'); + + // obj.className is undefined in ESM modules + const fn = obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; + + if (localScope?.object.objectId === undefined) { + frames[i] = { function: fn }; + } else { + const vars = await getLocalVariables(session, localScope.object.objectId); + frames[i] = { function: fn, vars }; + } + } + + parentPort?.postMessage({ exceptionHash, frames }); +} + +async function startDebugger(): Promise { + const session = new Session(); + session.connectToMainThread(); + + log('Connected to main thread'); + + let isPaused = false; + + session.on('Debugger.resumed', () => { + isPaused = false; + }); + + session.on('Debugger.paused', (event: InspectorNotification) => { + isPaused = true; + + handlePaused(session, stackParser, event.params as PausedExceptionEvent).then( + () => { + // After the pause work is complete, resume execution! + return isPaused ? session.post('Debugger.resume') : Promise.resolve(); + }, + _ => { + // ignore + }, + ); + }); + + await session.post('Debugger.enable'); + + const captureAll = options.captureAllExceptions !== false; + await session.post('Debugger.setPauseOnExceptions', { state: captureAll ? 'all' : 'uncaught' }); + + if (captureAll) { + const max = options.maxExceptionsPerSecond || 50; + + rateLimiter = createRateLimiter( + max, + async () => { + log('Rate-limit lifted.'); + await session.post('Debugger.setPauseOnExceptions', { state: 'all' }); + }, + async seconds => { + log(`Rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`); + await session.post('Debugger.setPauseOnExceptions', { state: 'uncaught' }); + }, + ); + } +} + +startDebugger().catch(e => { + log('Failed to start debugger', e); +}); + +setInterval(() => { + // Stop the worker from exiting +}, 10_000);