@sentry/node
Version:
Sentry Node SDK using OpenTelemetry for performance instrumentation
406 lines (340 loc) • 11.8 kB
JavaScript
import { defineIntegration, LRUMap, getClient, logger } from '@sentry/core';
import { NODE_MAJOR } from '../../nodeVersion.js';
import { isDebuggerEnabled } from '../../utils/debug.js';
import { createRateLimiter, functionNamesMatch } from './common.js';
/** Creates a unique hash from stack frames */
function hashFrames(frames) {
if (frames === undefined) {
return;
}
// Only hash the 10 most recent frames (ie. the last 10)
return frames.slice(-10).reduce((acc, frame) => `${acc},${frame.function},${frame.lineno},${frame.colno}`, '');
}
/**
* We use the stack parser to create a unique hash from the exception stack trace
* This is used to lookup vars when the exception passes through the event processor
*/
function hashFromStack(stackParser, stack) {
if (stack === undefined) {
return undefined;
}
return hashFrames(stackParser(stack, 1));
}
/** Creates a container for callbacks to be called sequentially */
function createCallbackList(complete) {
// A collection of callbacks to be executed last to first
let callbacks = [];
let completedCalled = false;
function checkedComplete(result) {
callbacks = [];
if (completedCalled) {
return;
}
completedCalled = true;
complete(result);
}
// complete should be called last
callbacks.push(checkedComplete);
function add(fn) {
callbacks.push(fn);
}
function next(result) {
const popped = callbacks.pop() || checkedComplete;
try {
popped(result);
} catch (_) {
// If there is an error, we still want to call the complete callback
checkedComplete(result);
}
}
return { add, next };
}
/**
* Promise API is available as `Experimental` and in Node 19 only.
*
* Callback-based API is `Stable` since v14 and `Experimental` since v8.
* Because of that, we are creating our own `AsyncSession` class.
*
* https://nodejs.org/docs/latest-v19.x/api/inspector.html#promises-api
* https://nodejs.org/docs/latest-v14.x/api/inspector.html
*/
class AsyncSession {
/** Throws if inspector API is not available */
constructor( _session) {this._session = _session;
//
}
static async create(orDefault) {
if (orDefault) {
return orDefault;
}
const inspector = await import('node:inspector');
return new AsyncSession(new inspector.Session());
}
/** @inheritdoc */
configureAndConnect(onPause, captureAll) {
this._session.connect();
this._session.on('Debugger.paused', event => {
onPause(event, () => {
// After the pause work is complete, resume execution or the exception context memory is leaked
this._session.post('Debugger.resume');
});
});
this._session.post('Debugger.enable');
this._session.post('Debugger.setPauseOnExceptions', { state: captureAll ? 'all' : 'uncaught' });
}
setPauseOnExceptions(captureAll) {
this._session.post('Debugger.setPauseOnExceptions', { state: captureAll ? 'all' : 'uncaught' });
}
/** @inheritdoc */
getLocalVariables(objectId, complete) {
this._getProperties(objectId, props => {
const { add, next } = createCallbackList(complete);
for (const prop of props) {
if (prop.value?.objectId && prop.value.className === 'Array') {
const id = prop.value.objectId;
add(vars => this._unrollArray(id, prop.name, vars, next));
} else if (prop.value?.objectId && prop.value.className === 'Object') {
const id = prop.value.objectId;
add(vars => this._unrollObject(id, prop.name, vars, next));
} else if (prop.value) {
add(vars => this._unrollOther(prop, vars, next));
}
}
next({});
});
}
/**
* Gets all the PropertyDescriptors of an object
*/
_getProperties(objectId, next) {
this._session.post(
'Runtime.getProperties',
{
objectId,
ownProperties: true,
},
(err, params) => {
if (err) {
next([]);
} else {
next(params.result);
}
},
);
}
/**
* Unrolls an array property
*/
_unrollArray(objectId, name, vars, next) {
this._getProperties(objectId, props => {
vars[name] = props
.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);
next(vars);
});
}
/**
* Unrolls an object property
*/
_unrollObject(objectId, name, vars, next) {
this._getProperties(objectId, props => {
vars[name] = props
.map(v => [v.name, v.value?.value])
.reduce((obj, [key, val]) => {
obj[key] = val;
return obj;
}, {} );
next(vars);
});
}
/**
* Unrolls other properties
*/
_unrollOther(prop, vars, next) {
if (prop.value) {
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] = '<undefined>';
}
}
next(vars);
}
}
const INTEGRATION_NAME = 'LocalVariables';
/**
* Adds local variables to exception frames
*/
const _localVariablesSyncIntegration = ((
options = {},
sessionOverride,
) => {
const cachedFrames = new LRUMap(20);
let rateLimiter;
let shouldProcessEvent = false;
function addLocalVariablesToException(exception) {
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;
const cachedFrameVariable = cachedFrame[i];
const frameVariable = frames[frameIndex];
// Drop out if we run out of frames to match up
if (!frameVariable || !cachedFrameVariable) {
break;
}
if (
// We need to have vars to add
cachedFrameVariable.vars === undefined ||
// We're not interested in frames that are not in_app because the vars are not relevant
frameVariable.in_app === false ||
// The function names need to match
!functionNamesMatch(frameVariable.function, cachedFrameVariable.function)
) {
continue;
}
frameVariable.vars = cachedFrameVariable.vars;
}
}
function addLocalVariablesToEvent(event) {
for (const exception of event.exception?.values || []) {
addLocalVariablesToException(exception);
}
return event;
}
return {
name: INTEGRATION_NAME,
async setupOnce() {
const client = getClient();
const clientOptions = client?.getOptions();
if (!clientOptions?.includeLocalVariables) {
return;
}
// Only setup this integration if the Node version is >= v18
// https://github.com/getsentry/sentry-javascript/issues/7697
const unsupportedNodeVersion = NODE_MAJOR < 18;
if (unsupportedNodeVersion) {
logger.log('The `LocalVariables` integration is only supported on Node >= v18.');
return;
}
if (await isDebuggerEnabled()) {
logger.warn('Local variables capture has been disabled because the debugger was already enabled');
return;
}
AsyncSession.create(sessionOverride).then(
session => {
function handlePaused(
stackParser,
{ params: { reason, data, callFrames } },
complete,
) {
if (reason !== 'exception' && reason !== 'promiseRejection') {
complete();
return;
}
rateLimiter?.();
// data.description contains the original error.stack
const exceptionHash = hashFromStack(stackParser, data.description);
if (exceptionHash == undefined) {
complete();
return;
}
const { add, next } = createCallbackList(frames => {
cachedFrames.set(exceptionHash, frames);
complete();
});
// Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack
// For this reason we only attempt to get local variables for the first 5 frames
for (let i = 0; i < Math.min(callFrames.length, 5); i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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) {
add(frames => {
frames[i] = { function: fn };
next(frames);
});
} else {
const id = localScope.object.objectId;
add(frames =>
session.getLocalVariables(id, vars => {
frames[i] = { function: fn, vars };
next(frames);
}),
);
}
}
next([]);
}
const captureAll = options.captureAllExceptions !== false;
session.configureAndConnect(
(ev, complete) =>
handlePaused(clientOptions.stackParser, ev , complete),
captureAll,
);
if (captureAll) {
const max = options.maxExceptionsPerSecond || 50;
rateLimiter = createRateLimiter(
max,
() => {
logger.log('Local variables rate-limit lifted.');
session.setPauseOnExceptions(true);
},
seconds => {
logger.log(
`Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`,
);
session.setPauseOnExceptions(false);
},
);
}
shouldProcessEvent = true;
},
error => {
logger.log('The `LocalVariables` integration failed to start.', error);
},
);
},
processEvent(event) {
if (shouldProcessEvent) {
return addLocalVariablesToEvent(event);
}
return event;
},
// These are entirely for testing
_getCachedFramesCount() {
return cachedFrames.size;
},
_getFirstCachedFrame() {
return cachedFrames.values()[0];
},
};
}) ;
/**
* Adds local variables to exception frames.
*/
const localVariablesSyncIntegration = defineIntegration(_localVariablesSyncIntegration);
export { createCallbackList, hashFrames, hashFromStack, localVariablesSyncIntegration };
//# sourceMappingURL=local-variables-sync.js.map