Skip to content

Commit

Permalink
feat: enable node.js stack locals capture
Browse files Browse the repository at this point in the history
  • Loading branch information
waltjones committed Nov 4, 2020
1 parent eb2cfed commit 3547511
Show file tree
Hide file tree
Showing 6 changed files with 358 additions and 6 deletions.
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ declare namespace Rollbar {
includeItemsInTelemetry?: boolean;
inspectAnonymousErrors?: boolean;
itemsPerMinute?: number;
locals?: boolean;
logLevel?: Level;
maxItems?: number;
maxTelemetryEvents?: number;
Expand Down
161 changes: 161 additions & 0 deletions src/server/locals.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/* globals Map */
var inspector = require('inspector');
var async = require('async');

function Locals(config) {
this.config = config;
this.errorCounter = 0;
this.currentErrors = new Map();

this.initSession();
}

Locals.prototype.initSession = function() {
this.session = new inspector.Session();
this.session.connect();

this.session.on('Debugger.paused', ({ params }) => {
if (params.reason == 'promiseRejection' || params.reason == 'exception') {
var key = params.data.description;
this.currentErrors.set(key, params);

if (this.currentErrors.size > 4) {
var firstKey = this.currentErrors.keys()[0];
this.currentErrors.delete(firstKey);
}
}
});

this.session.post('Debugger.enable', (_err, _result) => {
this.session.post('Debugger.setPauseOnExceptions', { state: 'all'}, (_err, _result) => {
});
});
}

Locals.prototype.currentLocalsMap = function() {
if (this.currentErrors.size) {
return new Map(this.currentErrors);
}
}

Locals.prototype.mergeLocals = function(localsMap, stack, key, callback) {
var matchedFrames;

try {
var localParams = localsMap.get(key);
matchedFrames = this.matchFrames(localParams, stack.reverse());
} catch (e) {
console.log(e);
return callback(e);
}

this.getLocalScopesForFrames(matchedFrames, function(err) {
callback(err);
});
}

// Finds frames in localParams that match file and line locations in stack.
Locals.prototype.matchFrames = function(localParams, stack) {
var matchedFrames = [];
var localIndex = 0, stackIndex = 0;
var stackLength = stack.length;
var callFrames = localParams.callFrames;
var callFramesLength = callFrames.length;

for (; stackIndex < stackLength; stackIndex++) {
while (localIndex < callFramesLength) {
if (this.firstFrame(localIndex, stackIndex) || this.matchedFrame(callFrames[localIndex], stack[stackIndex])) {
matchedFrames.push({
stackLocation: stack[stackIndex],
callFrame: callFrames[localIndex]
});
localIndex++;
break;
} else {
localIndex++;
}
}
}

return matchedFrames;
}

Locals.prototype.firstFrame = function(localIndex, stackIndex) {
return !localIndex && !stackIndex;
}

Locals.prototype.matchedFrame = function(callFrame, stackLocation) {
if (!callFrame || !stackLocation) {
return false;
}

var position = stackLocation.runtimePosition;

// Node.js prefixes filename some URLs with 'file:///' in Debugger.callFrame,
// but with only '/' in the error.stack string. Remove the prefix to facilitate a match.
var callFrameUrl = callFrame.url.replace(/file:\/\//,'');

// lineNumber is zero indexed, so offset it.
var callFrameLine = callFrame.location.lineNumber + 1;
var callFrameColumn = callFrame.location.columnNumber;

return callFrameUrl === position.source &&
callFrameLine === position.line &&
callFrameColumn === position.column;
}

Locals.prototype.getLocalScopesForFrames = function(matchedFrames, callback) {
async.each(matchedFrames, this.getLocalScopeForFrame.bind(this), function (err) {
callback(err);
});
}

Locals.prototype.getLocalScopeForFrame = function(matchedFrame, callback) {
var scopes = matchedFrame.callFrame.scopeChain;

var _this = this;
for (var i = 0; i < scopes.length; i++) {
var scope = scopes[i]
if (scope.type === 'local') {
this.getProperties(scope.object.objectId, function(err, response){
if (err) {
return callback(err);
}

var locals = response.result;
matchedFrame.stackLocation.locals = {};
for (var local of locals) {
matchedFrame.stackLocation.locals[local.name] = _this.getLocalValue(local);
}

callback(null);
});
}
}
}

Locals.prototype.getLocalValue = function(local) {
var value;

switch (local.value.type) {
case 'undefined': value = 'undefined'; break;
case 'object': value = this.getObjectValue(local); break;
default: value = local.value.value; break;
}

return value;
}

Locals.prototype.getObjectValue = function(local) {
if (local.value.className) {
return '<' + local.value.className + ' object>'
} else {
return '<object>'
}
}

Locals.prototype.getProperties = function(objectId, callback) {
this.session.post('Runtime.getProperties', { objectId : objectId, ownProperties: true }, callback);
}

module.exports = Locals;
25 changes: 20 additions & 5 deletions src/server/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ function mapPosition(position, diagnostic) {
}

function parseFrameLine(line, callback) {
var matched, curLine, data, frame;
var matched, curLine, data, frame, position;

curLine = line;
matched = curLine.match(jadeTracePattern);
Expand All @@ -141,20 +141,23 @@ function parseFrameLine(line, callback) {
}

data = matched.slice(1);
var position = {
var runtimePosition = {
source: data[1],
line: Math.floor(data[2]),
column: Math.floor(data[3]) - 1
};
if (this.useSourceMaps) {
position = mapPosition(position, this.diagnostic);
position = mapPosition(runtimePosition, this.diagnostic);
} else {
position = runtimePosition;
}

frame = {
method: data[0] || '<unknown>',
filename: position.source,
lineno: position.line,
colno: position.column
colno: position.column,
runtimePosition: runtimePosition // Used to match frames for locals
};

// For coffeescript, lineno and colno refer to the .coffee positions
Expand Down Expand Up @@ -306,7 +309,19 @@ exports.parseException = function (exc, options, item, callback) {
ret.message = jadeData.message;
ret.frames.push(jadeData.frame);
}
return callback(null, ret);

if (item.localsMap) {
item.notifier.locals.mergeLocals(item.localsMap, stack, exc.stack, function (err) {
if (err) {
logger.error('could not parse locals, err: ' + err);
return callback(err);
}

return callback(null, ret);
});
} else {
return callback(null, ret);
}
});
};

Expand Down
16 changes: 15 additions & 1 deletion src/server/rollbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ var transforms = require('./transforms');
var sharedTransforms = require('../transforms');
var sharedPredicates = require('../predicates');
var truncation = require('../truncation');
var Locals = require('./locals');
var polyfillJSON = require('../../vendor/JSON-js/json3');

function Rollbar(options, client) {
Expand All @@ -40,6 +41,13 @@ function Rollbar(options, client) {
var api = new API(this.options, transport, urllib, truncation, jsonBackup);
var telemeter = new Telemeter(this.options)
this.client = client || new Client(this.options, api, logger, telemeter, 'server');
if (options.locals) {
// Capturing stack local variables is only supported in Node 10 and higher.
var nodeMajorVersion = process.versions.node.split('.')[0];
if (nodeMajorVersion >= 10) {
this.locals = new Locals(this.options)
}
}
addTransformsToNotifier(this.client.notifier);
addPredicatesToQueue(this.client.queue);
this.setupUnhandledCapture();
Expand Down Expand Up @@ -519,7 +527,13 @@ function addPredicatesToQueue(queue) {

Rollbar.prototype._createItem = function (args) {
var requestKeys = ['headers', 'protocol', 'url', 'method', 'body', 'route'];
return _.createItem(args, logger, this, requestKeys, this.lambdaContext);
var item = _.createItem(args, logger, this, requestKeys, this.lambdaContext);

if (item.err && item.notifier.locals) {
item.localsMap = item.notifier.locals.currentLocalsMap();
}

return item;
};

function _getFirstFunction(args) {
Expand Down
Loading

0 comments on commit 3547511

Please sign in to comment.