Skip to content

Commit

Permalink
feat: Sensible non-Error exception serializer (#1253)
Browse files Browse the repository at this point in the history
* feat: Sensible non-Error exception serializer
* ref: Restructure captureException method
  • Loading branch information
kamilogorek authored Mar 13, 2018
1 parent 5e42970 commit add03a5
Show file tree
Hide file tree
Showing 6 changed files with 716 additions and 29 deletions.
60 changes: 44 additions & 16 deletions src/raven.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

var TraceKit = require('../vendor/TraceKit/tracekit');
var stringify = require('../vendor/json-stringify-safe/stringify');
var md5 = require('../vendor/md5/md5');
var RavenConfigError = require('./configError');

var utils = require('./utils');
var isError = utils.isError;
var isObject = utils.isObject;
var isPlainObject = utils.isPlainObject;
var isErrorEvent = utils.isErrorEvent;
var isUndefined = utils.isUndefined;
var isFunction = utils.isFunction;
Expand All @@ -28,6 +30,8 @@ var parseUrl = utils.parseUrl;
var fill = utils.fill;
var supportsFetch = utils.supportsFetch;
var supportsReferrerPolicy = utils.supportsReferrerPolicy;
var serializeKeysForMessage = utils.serializeKeysForMessage;
var serializeException = utils.serializeException;

var wrapConsoleMethod = require('./console').wrapMethod;

Expand Down Expand Up @@ -456,23 +460,34 @@ Raven.prototype = {
*/
captureException: function(ex, options) {
options = objectMerge({trimHeadFrames: 0}, options ? options : {});
// Cases for sending ex as a message, rather than an exception
var isNotError = !isError(ex);
var isNotErrorEvent = !isErrorEvent(ex);
var isErrorEventWithoutError = isErrorEvent(ex) && !ex.error;

if ((isNotError && isNotErrorEvent) || isErrorEventWithoutError) {
return this.captureMessage(
ex,
objectMerge(options, {
stacktrace: true, // if we fall back to captureMessage, default to attempting a new trace
trimHeadFrames: options.trimHeadFrames + 1
})
);
}

// Get actual Error from ErrorEvent
if (isErrorEvent(ex)) ex = ex.error;
if (isPlainObject(ex)) {
// If it is plain Object, serialize it manually and extract options
// 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
options = this._getCaptureExceptionOptionsFromPlainObject(options, ex);
ex = new Error(options.message);

} else if (isErrorEvent(ex) && ex.error) {
// If it is an ErrorEvent with `error` property, extract it to get actual Error
ex = ex.error;
} else if (isError(ex)){
// we have a real Error object
ex = ex;
} else {
// If none of previous checks were valid, then it means that
// it's not a plain Object
// it's not a valid ErrorEvent (one with an error property)
// it's not an Error
// So bail out and capture it as a simple message:
return this.captureMessage(
ex,
objectMerge(options, {
stacktrace: true, // if we fall back to captureMessage, default to attempting a new trace
trimHeadFrames: options.trimHeadFrames + 1
})
);
}

// Store the raw exception object for potential debugging and introspection
this._lastCapturedException = ex;
Expand All @@ -494,6 +509,19 @@ Raven.prototype = {
return this;
},

_getCaptureExceptionOptionsFromPlainObject: function(currentOptions, ex) {
var exKeys = Object.keys(ex).sort();
var options = objectMerge(currentOptions, {
message:
'Non-Error exception captured with keys: ' + serializeKeysForMessage(exKeys),
fingerprint: [md5(exKeys)],
extra: currentOptions.extra || {}
});
options.extra.__serialized__ = serializeException(ex);

return options;
},

/*
* Manually send a message to Sentry
*
Expand Down
98 changes: 97 additions & 1 deletion src/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
var stringify = require('../vendor/json-stringify-safe/stringify');

var _window =
typeof window !== 'undefined'
? window
Expand Down Expand Up @@ -441,6 +443,98 @@ function safeJoin(input, delimiter) {
return output.join(delimiter);
}

// 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 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;
}

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 = {
isObject: isObject,
isError: isError,
Expand Down Expand Up @@ -470,5 +564,7 @@ module.exports = {
isSameStacktrace: isSameStacktrace,
parseUrl: parseUrl,
fill: fill,
safeJoin: safeJoin
safeJoin: safeJoin,
serializeException: serializeException,
serializeKeysForMessage: serializeKeysForMessage
};
7 changes: 2 additions & 5 deletions test/integration/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,8 @@ describe('integration', function() {
},
function() {
var ravenData = iframe.contentWindow.ravenData[0];
assert.isAtLeast(ravenData.stacktrace.frames.length, 1);
assert.isAtMost(ravenData.stacktrace.frames.length, 3);

// verify trimHeadFrames hasn't slipped into final payload
assert.isUndefined(ravenData.trimHeadFrames);
assert.isAtLeast(ravenData.exception.values[0].stacktrace.frames.length, 1);
assert.isAtMost(ravenData.exception.values[0].stacktrace.frames.length, 3);
}
);
});
Expand Down
71 changes: 64 additions & 7 deletions test/raven.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3079,13 +3079,6 @@ describe('Raven (public API)', function() {
});
}

it('should send non-Errors as messages', function() {
this.sinon.stub(Raven, 'isSetup').returns(true);
this.sinon.stub(Raven, 'captureMessage');
Raven.captureException({}, {foo: 'bar'});
assert.isTrue(Raven.captureMessage.calledOnce);
});

it('should call handleStackInfo', function() {
var error = new Error('pickleRick');
this.sinon.stub(Raven, 'isSetup').returns(true);
Expand Down Expand Up @@ -3156,6 +3149,70 @@ describe('Raven (public API)', function() {
Raven.captureException(new Error('err'));
});
});

it('should serialize non-error exceptions', function(done) {
this.sinon.stub(Raven, 'isSetup').returns(true);
this.sinon.stub(Raven, '_send').callsFake(function stubbedSend(kwargs) {
kwargs.message.should.equal(
'Non-Error exception captured with keys: aKeyOne, bKeyTwo, cKeyThree, dKeyFour\u2026'
);

var serialized = kwargs.extra.__serialized__;
var fn;

// Yes, I know, it's ugly but...
// unfortunately older browsers are not capable of extracting method names
// therefore we have to use `oneOf` here
fn = serialized.eKeyFive;
delete serialized.eKeyFive;
assert.oneOf(fn, ['[Function: foo]', '[Function]']);

fn = serialized.fKeySix.levelTwo.levelThreeAnonymousFunction;
delete serialized.fKeySix.levelTwo.levelThreeAnonymousFunction;
assert.oneOf(fn, ['[Function: levelThreeAnonymousFunction]', '[Function]']);

fn = serialized.fKeySix.levelTwo.levelThreeNamedFunction;
delete serialized.fKeySix.levelTwo.levelThreeNamedFunction;
assert.oneOf(fn, ['[Function: bar]', '[Function]']);

assert.deepEqual(serialized, {
aKeyOne: 'a',
bKeyTwo: 42,
cKeyThree: {},
dKeyFour: ['d'],
fKeySix: {
levelTwo: {
levelThreeObject: '[Object]',
levelThreeArray: '[Array]',
levelThreeString: 'foo',
levelThreeNumber: 42
}
}
});

done();
});

Raven.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
}
}
});
});
});

describe('.captureBreadcrumb', function() {
Expand Down
Loading

0 comments on commit add03a5

Please sign in to comment.