Skip to content

Commit e04e7c5

Browse files
authored
Enable node.js stack locals capture (#902)
1 parent 3a7c33d commit e04e7c5

File tree

7 files changed

+1030
-6
lines changed

7 files changed

+1030
-6
lines changed

index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ declare namespace Rollbar {
6767
includeItemsInTelemetry?: boolean;
6868
inspectAnonymousErrors?: boolean;
6969
itemsPerMinute?: number;
70+
locals?: boolean;
7071
logLevel?: Level;
7172
maxItems?: number;
7273
maxTelemetryEvents?: number;

src/server/locals.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/* globals Map */
2+
var inspector = require('inspector');
3+
var async = require('async');
4+
5+
function Locals(config) {
6+
if (!(this instanceof Locals)) {
7+
return new Locals(config);
8+
}
9+
10+
this.config = config;
11+
12+
this.initSession();
13+
}
14+
15+
Locals.prototype.initSession = function() {
16+
if (Locals.session) { return; }
17+
18+
Locals.session = new inspector.Session();
19+
Locals.session.connect();
20+
Locals.currentErrors = new Map();
21+
22+
Locals.session.on('Debugger.paused', ({ params }) => {
23+
if (params.reason == 'promiseRejection' || params.reason == 'exception') {
24+
var key = params.data.description;
25+
Locals.currentErrors.set(key, params);
26+
27+
// Set the max size of the current errors array.
28+
// The value should be large enough to preserve each of the errors in
29+
// the current cause chain.
30+
var CURRENT_ERRORS_MAX_SIZE = 4;
31+
32+
if (Locals.currentErrors.size > CURRENT_ERRORS_MAX_SIZE) {
33+
var firstKey = Locals.currentErrors.keys()[0];
34+
Locals.currentErrors.delete(firstKey);
35+
}
36+
}
37+
});
38+
39+
Locals.session.post('Debugger.enable', (_err, _result) => {
40+
Locals.session.post('Debugger.setPauseOnExceptions', { state: 'all'}, (_err, _result) => {
41+
});
42+
});
43+
}
44+
45+
Locals.prototype.currentLocalsMap = function() {
46+
return new Map(Locals.currentErrors);
47+
}
48+
49+
Locals.prototype.mergeLocals = function(localsMap, stack, key, callback) {
50+
var matchedFrames;
51+
52+
try {
53+
var localParams = localsMap.get(key);
54+
55+
// If a mapping isn't found return success without mapped locals.
56+
if (!localParams) {
57+
return callback(null);
58+
}
59+
60+
matchedFrames = matchFrames(localParams, stack.slice().reverse());
61+
} catch (e) {
62+
return callback(e);
63+
}
64+
65+
getLocalScopesForFrames(matchedFrames, callback);
66+
}
67+
68+
// Finds frames in localParams that match file and line locations in stack.
69+
function matchFrames(localParams, stack) {
70+
var matchedFrames = [];
71+
var localIndex = 0, stackIndex = 0;
72+
var stackLength = stack.length;
73+
var callFrames = localParams.callFrames;
74+
var callFramesLength = callFrames.length;
75+
76+
for (; stackIndex < stackLength; stackIndex++) {
77+
while (localIndex < callFramesLength) {
78+
if (firstFrame(localIndex, stackIndex) || matchedFrame(callFrames[localIndex], stack[stackIndex])) {
79+
matchedFrames.push({
80+
stackLocation: stack[stackIndex],
81+
callFrame: callFrames[localIndex]
82+
});
83+
localIndex++;
84+
break;
85+
} else {
86+
localIndex++;
87+
}
88+
}
89+
}
90+
91+
return matchedFrames;
92+
}
93+
94+
function firstFrame(localIndex, stackIndex) {
95+
return !localIndex && !stackIndex;
96+
}
97+
98+
function matchedFrame(callFrame, stackLocation) {
99+
if (!callFrame || !stackLocation) {
100+
return false;
101+
}
102+
103+
var position = stackLocation.runtimePosition;
104+
105+
// Node.js prefixes filename some URLs with 'file:///' in Debugger.callFrame,
106+
// but with only '/' in the error.stack string. Remove the prefix to facilitate a match.
107+
var callFrameUrl = callFrame.url.replace(/file:\/\//,'');
108+
109+
// lineNumber is zero indexed, so offset it.
110+
var callFrameLine = callFrame.location.lineNumber + 1;
111+
var callFrameColumn = callFrame.location.columnNumber;
112+
113+
return callFrameUrl === position.source &&
114+
callFrameLine === position.line &&
115+
callFrameColumn === position.column;
116+
}
117+
118+
function getLocalScopesForFrames(matchedFrames, callback) {
119+
async.each(matchedFrames, getLocalScopeForFrame, callback);
120+
}
121+
122+
function getLocalScopeForFrame(matchedFrame, callback) {
123+
var scopes = matchedFrame.callFrame.scopeChain;
124+
125+
var scope = scopes.find(scope => scope.type === 'local');
126+
127+
if (!scope) {
128+
return callback(null); // Do nothing return success.
129+
}
130+
131+
getProperties(scope.object.objectId, function(err, response){
132+
if (err) {
133+
return callback(err);
134+
}
135+
136+
var locals = response.result;
137+
matchedFrame.stackLocation.locals = {};
138+
for (var local of locals) {
139+
matchedFrame.stackLocation.locals[local.name] = getLocalValue(local);
140+
}
141+
142+
callback(null);
143+
});
144+
}
145+
146+
function getLocalValue(local) {
147+
var value;
148+
149+
switch (local.value.type) {
150+
case 'undefined': value = 'undefined'; break;
151+
case 'object': value = getObjectValue(local); break;
152+
case 'array': value = getObjectValue(local); break;
153+
default: value = local.value.value; break;
154+
}
155+
156+
return value;
157+
}
158+
159+
function getObjectValue(local) {
160+
if (local.value.className) {
161+
return '<' + local.value.className + ' object>'
162+
} else {
163+
return '<object>'
164+
}
165+
}
166+
167+
function getProperties(objectId, callback) {
168+
Locals.session.post('Runtime.getProperties', { objectId : objectId, ownProperties: true }, callback);
169+
}
170+
171+
module.exports = Locals;

src/server/parser.js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ function mapPosition(position, diagnostic) {
127127
}
128128

129129
function parseFrameLine(line, callback) {
130-
var matched, curLine, data, frame;
130+
var matched, curLine, data, frame, position;
131131

132132
curLine = line;
133133
matched = curLine.match(jadeTracePattern);
@@ -141,20 +141,23 @@ function parseFrameLine(line, callback) {
141141
}
142142

143143
data = matched.slice(1);
144-
var position = {
144+
var runtimePosition = {
145145
source: data[1],
146146
line: Math.floor(data[2]),
147147
column: Math.floor(data[3]) - 1
148148
};
149149
if (this.useSourceMaps) {
150-
position = mapPosition(position, this.diagnostic);
150+
position = mapPosition(runtimePosition, this.diagnostic);
151+
} else {
152+
position = runtimePosition;
151153
}
152154

153155
frame = {
154156
method: data[0] || '<unknown>',
155157
filename: position.source,
156158
lineno: position.line,
157-
colno: position.column
159+
colno: position.column,
160+
runtimePosition: runtimePosition // Used to match frames for locals
158161
};
159162

160163
// For coffeescript, lineno and colno refer to the .coffee positions
@@ -306,7 +309,19 @@ exports.parseException = function (exc, options, item, callback) {
306309
ret.message = jadeData.message;
307310
ret.frames.push(jadeData.frame);
308311
}
309-
return callback(null, ret);
312+
313+
if (item.localsMap) {
314+
item.notifier.locals.mergeLocals(item.localsMap, stack, exc.stack, function (err) {
315+
if (err) {
316+
logger.error('could not parse locals, err: ' + err);
317+
return callback(err);
318+
}
319+
320+
return callback(null, ret);
321+
});
322+
} else {
323+
return callback(null, ret);
324+
}
310325
});
311326
};
312327

src/server/rollbar.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ var transforms = require('./transforms');
1616
var sharedTransforms = require('../transforms');
1717
var sharedPredicates = require('../predicates');
1818
var truncation = require('../truncation');
19+
var Locals = require('./locals');
1920
var polyfillJSON = require('../../vendor/JSON-js/json3');
2021

2122
function Rollbar(options, client) {
@@ -40,6 +41,13 @@ function Rollbar(options, client) {
4041
var api = new API(this.options, transport, urllib, truncation, jsonBackup);
4142
var telemeter = new Telemeter(this.options)
4243
this.client = client || new Client(this.options, api, logger, telemeter, 'server');
44+
if (options.locals) {
45+
// Capturing stack local variables is only supported in Node 10 and higher.
46+
var nodeMajorVersion = process.versions.node.split('.')[0];
47+
if (nodeMajorVersion >= 10) {
48+
this.locals = new Locals(this.options)
49+
}
50+
}
4351
addTransformsToNotifier(this.client.notifier);
4452
addPredicatesToQueue(this.client.queue);
4553
this.setupUnhandledCapture();
@@ -521,7 +529,13 @@ function addPredicatesToQueue(queue) {
521529

522530
Rollbar.prototype._createItem = function (args) {
523531
var requestKeys = ['headers', 'protocol', 'url', 'method', 'body', 'route'];
524-
return _.createItem(args, logger, this, requestKeys, this.lambdaContext);
532+
var item = _.createItem(args, logger, this, requestKeys, this.lambdaContext);
533+
534+
if (item.err && item.notifier.locals) {
535+
item.localsMap = item.notifier.locals.currentLocalsMap();
536+
}
537+
538+
return item;
525539
};
526540

527541
function _getFirstFunction(args) {

0 commit comments

Comments
 (0)