Skip to content
This repository has been archived by the owner on Oct 23, 2023. It is now read-only.

Commit

Permalink
feat: Sensible non-Error exception serializer (#416)
Browse files Browse the repository at this point in the history
* feat: Sensible non-Error exception serializer
* feat: Use serialized keys for non-error messages
* test: Integration test for non-error ex serializer
* test: Fix non-errors exception tests for node < 6
  • Loading branch information
kamilogorek authored Jan 15, 2018
1 parent a0e7da9 commit af36fcc
Show file tree
Hide file tree
Showing 5 changed files with 428 additions and 44 deletions.
34 changes: 27 additions & 7 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ var transports = require('./transports');
var nodeUtil = require('util'); // nodeUtil to avoid confusion with "utils"
var events = require('events');
var domain = require('domain');
var md5 = require('md5');

var instrumentor = require('./instrumentation/instrumentor');

Expand Down Expand Up @@ -355,20 +356,39 @@ extend(Raven.prototype, {
},

captureException: function captureException(err, kwargs, cb) {
if (!(err instanceof Error)) {
// This handles when someone does:
// throw "something awesome";
// We synthesize an Error here so we can extract a (rough) stack trace.
err = new Error(err);
}

if (!cb && typeof kwargs === 'function') {
cb = kwargs;
kwargs = {};
} else {
kwargs = kwargs || {};
}

if (!(err instanceof Error)) {
if (utils.isPlainObject(err)) {
// This will allow us to group events based on top-level keys
// which is much better than creating new group when any key/value change
var keys = Object.keys(err).sort();
var hash = md5(keys);
var message =
'Non-Error exception captured with keys: ' +
utils.serializeKeysForMessage(keys);
var serializedException = utils.serializeException(err);

kwargs.message = message;
kwargs.fingerprint = [hash];
kwargs.extra = {
__serialized__: serializedException
};

err = new Error(message);
} else {
// This handles when someone does:
// throw "something awesome";
// We synthesize an Error here so we can extract a (rough) stack trace.
err = new Error(err);
}
}

var self = this;
var eventId = this.generateEventId();
parsers.parseError(err, kwargs, function(kw) {
Expand Down
103 changes: 103 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ var transports = require('./transports');
var path = require('path');
var lsmod = require('lsmod');
var stacktrace = require('stack-trace');
var stringify = require('../vendor/json-stringify-safe');

var ravenVersion = require('../package.json').version;

Expand All @@ -16,6 +17,108 @@ var protocolMap = {

var consoleAlerts = {};

// Default Node.js REPL depth
var MAX_SERIALIZE_EXCEPTION_DEPTH = 3;
// 50kB, as 100kB is max payload size, so half sounds reasonable
var MAX_SERIALIZE_EXCEPTION_SIZE = 50 * 1024;
var MAX_SERIALIZE_KEYS_LENGTH = 40;

function utf8Length(value) {
return ~-encodeURI(value).split(/%..|./).length;
}

function jsonSize(value) {
return utf8Length(JSON.stringify(value));
}

function isPlainObject(what) {
return Object.prototype.toString.call(what) === '[object Object]';
}

module.exports.isPlainObject = isPlainObject;

function serializeValue(value) {
var maxLength = 40;

if (typeof value === 'string') {
return value.length <= maxLength ? value : value.substr(0, maxLength - 1) + '\u2026';
} else if (
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'undefined'
) {
return value;
}

var type = Object.prototype.toString.call(value);

// Node.js REPL notation
if (type === '[object Object]') return '[Object]';
if (type === '[object Array]') return '[Array]';
if (type === '[object Function]')
return value.name ? '[Function: ' + value.name + ']' : '[Function]';

return value;
}

function serializeObject(value, depth) {
if (depth === 0) return serializeValue(value);

if (isPlainObject(value)) {
return Object.keys(value).reduce(function(acc, key) {
acc[key] = serializeObject(value[key], depth - 1);
return acc;
}, {});
} else if (Array.isArray(value)) {
return value.map(function(val) {
return serializeObject(val, depth - 1);
});
}

return serializeValue(value);
}

function serializeException(ex, depth, maxSize) {
if (!isPlainObject(ex)) return ex;

depth = typeof depth !== 'number' ? MAX_SERIALIZE_EXCEPTION_DEPTH : depth;
maxSize = typeof depth !== 'number' ? MAX_SERIALIZE_EXCEPTION_SIZE : maxSize;

var serialized = serializeObject(ex, depth);

if (jsonSize(stringify(serialized)) > maxSize) {
return serializeException(ex, depth - 1);
}

return serialized;
}

module.exports.serializeException = serializeException;

function serializeKeysForMessage(keys, maxLength) {
if (typeof keys === 'number' || typeof keys === 'string') return keys.toString();
if (!Array.isArray(keys)) return '';

keys = keys.filter(function(key) {
return typeof key === 'string';
});
if (keys.length === 0) return '[object has no keys]';

maxLength = typeof maxLength !== 'number' ? MAX_SERIALIZE_KEYS_LENGTH : maxLength;
if (keys[0].length >= maxLength) return keys[0];

for (var usedKeys = keys.length; usedKeys > 0; usedKeys--) {
var serialized = keys.slice(0, usedKeys).join(', ');
if (serialized.length > maxLength) continue;
if (usedKeys === keys.length) return serialized;
return serialized + '\u2026';
}

return '';
}

module.exports.serializeKeysForMessage = serializeKeysForMessage;

module.exports.disableConsoleAlerts = function disableConsoleAlerts() {
consoleAlerts = false;
};
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"dependencies": {
"cookie": "0.3.1",
"lsmod": "1.0.0",
"md5": "^2.2.1",
"stack-trace": "0.0.9",
"timed-out": "4.0.1",
"uuid": "3.0.0"
Expand Down
99 changes: 69 additions & 30 deletions test/raven.client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
/* global Promise */
'use strict';

var versionRegexp = /^v(\d+)\.(\d+)\.(\d+)$/i;
var majorVersion = parseInt(versionRegexp.exec(process.version)[1], 10);

var raven = require('../'),
nock = require('nock'),
url = require('url'),
Expand Down Expand Up @@ -246,6 +249,62 @@ describe('raven.Client', function() {
client.captureException('wtf?');
});

it('should serialize non-error exceptions', function(done) {
var old = client.send;
client.send = function mockSend(kwargs) {
client.send = old;

kwargs.message.should.equal(
'Non-Error exception captured with keys: aKeyOne, bKeyTwo, cKeyThree, dKeyFour\u2026'
);

// Remove superfluous node version data to simplify the test itself
delete kwargs.extra.node;
kwargs.extra.should.have.property('__serialized__', {
aKeyOne: 'a',
bKeyTwo: 42,
cKeyThree: {},
dKeyFour: ['d'],
eKeyFive: '[Function: foo]',
fKeySix: {
levelTwo: {
levelThreeObject: '[Object]',
levelThreeArray: '[Array]',
// Node < 6 is not capable of pulling function name from unnamed object methods
levelThreeAnonymousFunction:
majorVersion < 6
? '[Function]'
: '[Function: levelThreeAnonymousFunction]',
levelThreeNamedFunction: '[Function: bar]',
levelThreeString: 'foo',
levelThreeNumber: 42
}
}
});

done();
};
client.captureException({
aKeyOne: 'a',
bKeyTwo: 42,
cKeyThree: {},
dKeyFour: ['d'],
eKeyFive: function foo() {},
fKeySix: {
levelTwo: {
levelThreeObject: {
enough: 42
},
levelThreeArray: [42],
levelThreeAnonymousFunction: function() {},
levelThreeNamedFunction: function bar() {},
levelThreeString: 'foo',
levelThreeNumber: 42
}
}
});
});

it('should send an Error to Sentry server on another port', function(done) {
var scope = nock('https://app.getsentry.com:8443')
.filteringRequestBody(/.*/, '*')
Expand Down Expand Up @@ -380,19 +439,15 @@ describe('raven.Client', function() {

describe('exit conditions', function() {
var exitStr = 'exit test assertions complete\n';
it('should catch an uncaughtException and capture it before exiting', function(
done
) {
it('should catch an uncaughtException and capture it before exiting', function(done) {
child_process.exec('node test/exit/capture.js', function(err, stdout, stderr) {
stdout.should.equal(exitStr);
stderr.should.startWith('Error: derp');
done();
});
});

it('should catch an uncaughtException and capture it before calling a provided callback', function(
done
) {
it('should catch an uncaughtException and capture it before calling a provided callback', function(done) {
child_process.exec('node test/exit/capture_callback.js', function(
err,
stdout,
Expand All @@ -405,9 +460,7 @@ describe('raven.Client', function() {
});
});

it('should catch an uncaughtException and capture it without a second followup exception causing premature shutdown', function(
done
) {
it('should catch an uncaughtException and capture it without a second followup exception causing premature shutdown', function(done) {
child_process.exec('node test/exit/capture_with_second_error.js', function(
err,
stdout,
Expand All @@ -419,9 +472,7 @@ describe('raven.Client', function() {
});
});

it('should treat an error thrown by captureException from uncaughtException handler as a sending error passed to onFatalError', function(
done
) {
it('should treat an error thrown by captureException from uncaughtException handler as a sending error passed to onFatalError', function(done) {
this.timeout(4000);
child_process.exec('node test/exit/throw_on_send.js', function(
err,
Expand All @@ -447,9 +498,7 @@ describe('raven.Client', function() {
});
});

it('should catch a domain exception and capture it before calling a provided callback', function(
done
) {
it('should catch a domain exception and capture it before calling a provided callback', function(done) {
child_process.exec('node test/exit/domain_capture_callback.js', function(
err,
stdout,
Expand All @@ -462,9 +511,7 @@ describe('raven.Client', function() {
});
});

it('should catch a domain exception and capture it without a second followup exception causing premature shutdown', function(
done
) {
it('should catch a domain exception and capture it without a second followup exception causing premature shutdown', function(done) {
child_process.exec('node test/exit/domain_capture_with_second_error.js', function(
err,
stdout,
Expand All @@ -476,9 +523,7 @@ describe('raven.Client', function() {
});
});

it('should treat an error thrown by captureException from domain exception handler as a sending error passed to onFatalError', function(
done
) {
it('should treat an error thrown by captureException from domain exception handler as a sending error passed to onFatalError', function(done) {
this.timeout(4000);
child_process.exec('node test/exit/domain_throw_on_send.js', function(
err,
Expand All @@ -492,9 +537,7 @@ describe('raven.Client', function() {
});
});

it('should catch an uncaughtException and capture it without a second followup domain exception causing premature shutdown', function(
done
) {
it('should catch an uncaughtException and capture it without a second followup domain exception causing premature shutdown', function(done) {
child_process.exec('node test/exit/capture_with_second_domain_error.js', function(
err,
stdout,
Expand All @@ -506,9 +549,7 @@ describe('raven.Client', function() {
});
});

it('should catch an uncaughtException and capture it and failsafe shutdown if onFatalError throws', function(
done
) {
it('should catch an uncaughtException and capture it and failsafe shutdown if onFatalError throws', function(done) {
child_process.exec('node test/exit/throw_on_fatal.js', function(
err,
stdout,
Expand Down Expand Up @@ -579,9 +620,7 @@ describe('raven.Client', function() {
);
});

it('should pass original shouldSendCallback to newer shouldSendCallback', function(
done
) {
it('should pass original shouldSendCallback to newer shouldSendCallback', function(done) {
var cb1 = function(data) {
return false;
};
Expand Down
Loading

0 comments on commit af36fcc

Please sign in to comment.