Skip to content

Commit add03a5

Browse files
authored
feat: Sensible non-Error exception serializer (#1253)
* feat: Sensible non-Error exception serializer * ref: Restructure captureException method
1 parent 5e42970 commit add03a5

File tree

6 files changed

+716
-29
lines changed

6 files changed

+716
-29
lines changed

src/raven.js

+44-16
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,23 +460,34 @@ 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;
463-
464-
if ((isNotError && isNotErrorEvent) || isErrorEventWithoutError) {
465-
return this.captureMessage(
466-
ex,
467-
objectMerge(options, {
468-
stacktrace: true, // if we fall back to captureMessage, default to attempting a new trace
469-
trimHeadFrames: options.trimHeadFrames + 1
470-
})
471-
);
472-
}
473463

474-
// Get actual Error from ErrorEvent
475-
if (isErrorEvent(ex)) ex = ex.error;
464+
if (isPlainObject(ex)) {
465+
// If it is plain Object, serialize it manually and extract options
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+
options = this._getCaptureExceptionOptionsFromPlainObject(options, ex);
469+
ex = new Error(options.message);
470+
471+
} else if (isErrorEvent(ex) && ex.error) {
472+
// If it is an ErrorEvent with `error` property, extract it to get actual Error
473+
ex = ex.error;
474+
} else if (isError(ex)){
475+
// we have a real Error object
476+
ex = ex;
477+
} else {
478+
// If none of previous checks were valid, then it means that
479+
// it's not a plain Object
480+
// it's not a valid ErrorEvent (one with an error property)
481+
// it's not an Error
482+
// So bail out and capture it as a simple message:
483+
return this.captureMessage(
484+
ex,
485+
objectMerge(options, {
486+
stacktrace: true, // if we fall back to captureMessage, default to attempting a new trace
487+
trimHeadFrames: options.trimHeadFrames + 1
488+
})
489+
);
490+
}
476491

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

512+
_getCaptureExceptionOptionsFromPlainObject: function(currentOptions, ex) {
513+
var exKeys = Object.keys(ex).sort();
514+
var options = objectMerge(currentOptions, {
515+
message:
516+
'Non-Error exception captured with keys: ' + serializeKeysForMessage(exKeys),
517+
fingerprint: [md5(exKeys)],
518+
extra: currentOptions.extra || {}
519+
});
520+
options.extra.__serialized__ = serializeException(ex);
521+
522+
return options;
523+
},
524+
497525
/*
498526
* Manually send a message to Sentry
499527
*

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

+64-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,70 @@ 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+
var serialized = kwargs.extra.__serialized__;
3161+
var fn;
3162+
3163+
// Yes, I know, it's ugly but...
3164+
// unfortunately older browsers are not capable of extracting method names
3165+
// therefore we have to use `oneOf` here
3166+
fn = serialized.eKeyFive;
3167+
delete serialized.eKeyFive;
3168+
assert.oneOf(fn, ['[Function: foo]', '[Function]']);
3169+
3170+
fn = serialized.fKeySix.levelTwo.levelThreeAnonymousFunction;
3171+
delete serialized.fKeySix.levelTwo.levelThreeAnonymousFunction;
3172+
assert.oneOf(fn, ['[Function: levelThreeAnonymousFunction]', '[Function]']);
3173+
3174+
fn = serialized.fKeySix.levelTwo.levelThreeNamedFunction;
3175+
delete serialized.fKeySix.levelTwo.levelThreeNamedFunction;
3176+
assert.oneOf(fn, ['[Function: bar]', '[Function]']);
3177+
3178+
assert.deepEqual(serialized, {
3179+
aKeyOne: 'a',
3180+
bKeyTwo: 42,
3181+
cKeyThree: {},
3182+
dKeyFour: ['d'],
3183+
fKeySix: {
3184+
levelTwo: {
3185+
levelThreeObject: '[Object]',
3186+
levelThreeArray: '[Array]',
3187+
levelThreeString: 'foo',
3188+
levelThreeNumber: 42
3189+
}
3190+
}
3191+
});
3192+
3193+
done();
3194+
});
3195+
3196+
Raven.captureException({
3197+
aKeyOne: 'a',
3198+
bKeyTwo: 42,
3199+
cKeyThree: {},
3200+
dKeyFour: ['d'],
3201+
eKeyFive: function foo() {},
3202+
fKeySix: {
3203+
levelTwo: {
3204+
levelThreeObject: {
3205+
enough: 42
3206+
},
3207+
levelThreeArray: [42],
3208+
levelThreeAnonymousFunction: function() {},
3209+
levelThreeNamedFunction: function bar() {},
3210+
levelThreeString: 'foo',
3211+
levelThreeNumber: 42
3212+
}
3213+
}
3214+
});
3215+
});
31593216
});
31603217

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

0 commit comments

Comments
 (0)