Skip to content

Commit 0ab8c75

Browse files
committed
feat: Sensible non-Error exception serializer
1 parent 8f8a624 commit 0ab8c75

File tree

6 files changed

+667
-21
lines changed

6 files changed

+667
-21
lines changed

src/raven.js

+30-8
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
var TraceKit = require('../vendor/TraceKit/tracekit');
44
var stringify = require('../vendor/json-stringify-safe/stringify');
5+
var md5 = require('../vendor/md5/md5');
56
var RavenConfigError = require('./configError');
67

78
var utils = require('./utils');
89
var isError = utils.isError;
910
var isObject = utils.isObject;
11+
var isPlainObject = utils.isPlainObject;
1012
var isErrorEvent = utils.isErrorEvent;
1113
var isUndefined = utils.isUndefined;
1214
var isFunction = utils.isFunction;
@@ -28,6 +30,8 @@ var parseUrl = utils.parseUrl;
2830
var fill = utils.fill;
2931
var supportsFetch = utils.supportsFetch;
3032
var supportsReferrerPolicy = utils.supportsReferrerPolicy;
33+
var serializeKeysForMessage = utils.serializeKeysForMessage;
34+
var serializeException = utils.serializeException;
3135

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

@@ -456,24 +460,42 @@ Raven.prototype = {
456460
*/
457461
captureException: function(ex, options) {
458462
options = objectMerge({trimHeadFrames: 0}, options ? options : {});
459-
// Cases for sending ex as a message, rather than an exception
460-
var isNotError = !isError(ex);
461-
var isNotErrorEvent = !isErrorEvent(ex);
462-
var isErrorEventWithoutError = isErrorEvent(ex) && !ex.error;
463463

464-
if ((isNotError && isNotErrorEvent) || isErrorEventWithoutError) {
464+
if (isPlainObject(ex)) {
465+
// If exception is plain object, serialize it
466+
// This will allow us to group events based on top-level keys
467+
// which is much better than creating new group when any key/value change
468+
469+
var keys = Object.keys(ex).sort();
470+
var hash = md5(keys);
471+
var message =
472+
'Non-Error exception captured with keys: ' + serializeKeysForMessage(keys);
473+
var serializedException = serializeException(ex);
474+
475+
options.message = message;
476+
options.fingerprint = [hash];
477+
options.extra = options.extra || {};
478+
options.extra.__serialized__ = serializedException;
479+
480+
ex = new Error(message);
481+
} else if ((!isErrorEvent(ex) && !isError(ex)) || (isErrorEvent(ex) && !ex.error)) {
482+
// If it's not a plain object, Error, ErrorEvent or ErrorEvent without `error` property
483+
// Then capture it as a simple message
465484
return this.captureMessage(
466485
ex,
467486
objectMerge(options, {
468487
stacktrace: true, // if we fall back to captureMessage, default to attempting a new trace
469488
trimHeadFrames: options.trimHeadFrames + 1
470489
})
471490
);
491+
} else if (isErrorEvent(ex)) {
492+
// If it is an ErrorEvent with `error` property, extract it to get actual Error
493+
ex = ex.error;
494+
} else {
495+
// If none of previous checks were valid, then it means that we have a real Error object
496+
ex = ex;
472497
}
473498

474-
// Get actual Error from ErrorEvent
475-
if (isErrorEvent(ex)) ex = ex.error;
476-
477499
// Store the raw exception object for potential debugging and introspection
478500
this._lastCapturedException = ex;
479501

src/utils.js

+97-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
var stringify = require('../vendor/json-stringify-safe/stringify');
2+
13
var _window =
24
typeof window !== 'undefined'
35
? window
@@ -441,6 +443,98 @@ function safeJoin(input, delimiter) {
441443
return output.join(delimiter);
442444
}
443445

446+
// Default Node.js REPL depth
447+
var MAX_SERIALIZE_EXCEPTION_DEPTH = 3;
448+
// 50kB, as 100kB is max payload size, so half sounds reasonable
449+
var MAX_SERIALIZE_EXCEPTION_SIZE = 50 * 1024;
450+
var MAX_SERIALIZE_KEYS_LENGTH = 40;
451+
452+
function utf8Length(value) {
453+
return ~-encodeURI(value).split(/%..|./).length;
454+
}
455+
456+
function jsonSize(value) {
457+
return utf8Length(JSON.stringify(value));
458+
}
459+
460+
function serializeValue(value) {
461+
var maxLength = 40;
462+
463+
if (typeof value === 'string') {
464+
return value.length <= maxLength ? value : value.substr(0, maxLength - 1) + '\u2026';
465+
} else if (
466+
typeof value === 'number' ||
467+
typeof value === 'boolean' ||
468+
typeof value === 'undefined'
469+
) {
470+
return value;
471+
}
472+
473+
var type = Object.prototype.toString.call(value);
474+
475+
// Node.js REPL notation
476+
if (type === '[object Object]') return '[Object]';
477+
if (type === '[object Array]') return '[Array]';
478+
if (type === '[object Function]')
479+
return value.name ? '[Function: ' + value.name + ']' : '[Function]';
480+
481+
return value;
482+
}
483+
484+
function serializeObject(value, depth) {
485+
if (depth === 0) return serializeValue(value);
486+
487+
if (isPlainObject(value)) {
488+
return Object.keys(value).reduce(function(acc, key) {
489+
acc[key] = serializeObject(value[key], depth - 1);
490+
return acc;
491+
}, {});
492+
} else if (Array.isArray(value)) {
493+
return value.map(function(val) {
494+
return serializeObject(val, depth - 1);
495+
});
496+
}
497+
498+
return serializeValue(value);
499+
}
500+
501+
function serializeException(ex, depth, maxSize) {
502+
if (!isPlainObject(ex)) return ex;
503+
504+
depth = typeof depth !== 'number' ? MAX_SERIALIZE_EXCEPTION_DEPTH : depth;
505+
maxSize = typeof depth !== 'number' ? MAX_SERIALIZE_EXCEPTION_SIZE : maxSize;
506+
507+
var serialized = serializeObject(ex, depth);
508+
509+
if (jsonSize(stringify(serialized)) > maxSize) {
510+
return serializeException(ex, depth - 1);
511+
}
512+
513+
return serialized;
514+
}
515+
516+
function serializeKeysForMessage(keys, maxLength) {
517+
if (typeof keys === 'number' || typeof keys === 'string') return keys.toString();
518+
if (!Array.isArray(keys)) return '';
519+
520+
keys = keys.filter(function(key) {
521+
return typeof key === 'string';
522+
});
523+
if (keys.length === 0) return '[object has no keys]';
524+
525+
maxLength = typeof maxLength !== 'number' ? MAX_SERIALIZE_KEYS_LENGTH : maxLength;
526+
if (keys[0].length >= maxLength) return keys[0];
527+
528+
for (var usedKeys = keys.length; usedKeys > 0; usedKeys--) {
529+
var serialized = keys.slice(0, usedKeys).join(', ');
530+
if (serialized.length > maxLength) continue;
531+
if (usedKeys === keys.length) return serialized;
532+
return serialized + '\u2026';
533+
}
534+
535+
return '';
536+
}
537+
444538
module.exports = {
445539
isObject: isObject,
446540
isError: isError,
@@ -470,5 +564,7 @@ module.exports = {
470564
isSameStacktrace: isSameStacktrace,
471565
parseUrl: parseUrl,
472566
fill: fill,
473-
safeJoin: safeJoin
567+
safeJoin: safeJoin,
568+
serializeException: serializeException,
569+
serializeKeysForMessage: serializeKeysForMessage
474570
};

test/integration/test.js

+2-5
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,8 @@ describe('integration', function() {
106106
},
107107
function() {
108108
var ravenData = iframe.contentWindow.ravenData[0];
109-
assert.isAtLeast(ravenData.stacktrace.frames.length, 1);
110-
assert.isAtMost(ravenData.stacktrace.frames.length, 3);
111-
112-
// verify trimHeadFrames hasn't slipped into final payload
113-
assert.isUndefined(ravenData.trimHeadFrames);
109+
assert.isAtLeast(ravenData.exception.values[0].stacktrace.frames.length, 1);
110+
assert.isAtMost(ravenData.exception.values[0].stacktrace.frames.length, 3);
114111
}
115112
);
116113
});

test/raven.test.js

+49-7
Original file line numberDiff line numberDiff line change
@@ -3079,13 +3079,6 @@ describe('Raven (public API)', function() {
30793079
});
30803080
}
30813081

3082-
it('should send non-Errors as messages', function() {
3083-
this.sinon.stub(Raven, 'isSetup').returns(true);
3084-
this.sinon.stub(Raven, 'captureMessage');
3085-
Raven.captureException({}, {foo: 'bar'});
3086-
assert.isTrue(Raven.captureMessage.calledOnce);
3087-
});
3088-
30893082
it('should call handleStackInfo', function() {
30903083
var error = new Error('pickleRick');
30913084
this.sinon.stub(Raven, 'isSetup').returns(true);
@@ -3156,6 +3149,55 @@ describe('Raven (public API)', function() {
31563149
Raven.captureException(new Error('err'));
31573150
});
31583151
});
3152+
3153+
it('should serialize non-error exceptions', function(done) {
3154+
this.sinon.stub(Raven, 'isSetup').returns(true);
3155+
this.sinon.stub(Raven, '_send').callsFake(function stubbedSend(kwargs) {
3156+
kwargs.message.should.equal(
3157+
'Non-Error exception captured with keys: aKeyOne, bKeyTwo, cKeyThree, dKeyFour\u2026'
3158+
);
3159+
3160+
assert.deepEqual(kwargs.extra.__serialized__, {
3161+
aKeyOne: 'a',
3162+
bKeyTwo: 42,
3163+
cKeyThree: {},
3164+
dKeyFour: ['d'],
3165+
eKeyFive: '[Function: foo]',
3166+
fKeySix: {
3167+
levelTwo: {
3168+
levelThreeObject: '[Object]',
3169+
levelThreeArray: '[Array]',
3170+
levelThreeAnonymousFunction: '[Function: levelThreeAnonymousFunction]',
3171+
levelThreeNamedFunction: '[Function: bar]',
3172+
levelThreeString: 'foo',
3173+
levelThreeNumber: 42
3174+
}
3175+
}
3176+
});
3177+
3178+
done();
3179+
});
3180+
3181+
Raven.captureException({
3182+
aKeyOne: 'a',
3183+
bKeyTwo: 42,
3184+
cKeyThree: {},
3185+
dKeyFour: ['d'],
3186+
eKeyFive: function foo() {},
3187+
fKeySix: {
3188+
levelTwo: {
3189+
levelThreeObject: {
3190+
enough: 42
3191+
},
3192+
levelThreeArray: [42],
3193+
levelThreeAnonymousFunction: function() {},
3194+
levelThreeNamedFunction: function bar() {},
3195+
levelThreeString: 'foo',
3196+
levelThreeNumber: 42
3197+
}
3198+
}
3199+
});
3200+
});
31593201
});
31603202

31613203
describe('.captureBreadcrumb', function() {

0 commit comments

Comments
 (0)