Skip to content

[react-native] capture and report fatals on next launch #626

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 2, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,12 @@ module.exports = function(grunt) {
},
test: {
src: 'test/**/*.test.js',
dest: 'build/raven.test.js',
options: {
dest: 'build/raven.test.js',
options: {
browserifyOptions: {
debug: true // source maps
},
ignore: ['react-native'],
plugin: [proxyquire.plugin]
}
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"json-stringify-safe": "^5.0.1"
},
"devDependencies": {
"bluebird": "^3.4.1",
"browserify-versionify": "^1.0.6",
"bundle-collapser": "^1.2.1",
"chai": "2.3.0",
Expand Down
107 changes: 103 additions & 4 deletions plugins/react-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
* pathStrip: A RegExp that matches the portions of a file URI that should be
* removed from stacks prior to submission.
*
* onInitialize: A callback that fires once the plugin has fully initialized
* and checked for any previously thrown fatals. If there was a fatal, its
* data payload will be passed as the first argument of the callback.
*
*/
'use strict';

Expand All @@ -20,6 +24,9 @@

var PATH_STRIP_RE = /^.*\/[^\.]+\.app/;

var FATAL_ERROR_KEY = '--rn-fatal--';
var ASYNC_STORAGE_KEY = '--raven-js-global-error-payload--';

/**
* Strip device-specific IDs from React Native file:// paths
*/
Expand Down Expand Up @@ -57,15 +64,107 @@ function reactNativePlugin(Raven, options) {
reactNativePlugin._normalizeData(data, options.pathStrip)
});

// Check for a previously persisted payload, and report it.
reactNativePlugin._restorePayload()
.then(function(payload) {
options.onInitialize && options.onInitialize(payload);
if (!payload) return;
Raven._sendProcessedPayload(payload, function(error) {
if (error) return; // Try again next launch.
reactNativePlugin._clearPayload();
});
})
['catch'](function() {});

// Make sure that if multiple fatals occur, we only persist the first one.
//
// The first error is probably the most important/interesting error, and we
// want to crash ASAP, rather than potentially queueing up multiple errors.
var handlingFatal = false;

var defaultHandler = ErrorUtils.getGlobalHandler && ErrorUtils.getGlobalHandler() || ErrorUtils._globalHandler;

ErrorUtils.setGlobalHandler(function() {
Raven.setShouldSendCallback(function(data, originalCallback) {
if (!(FATAL_ERROR_KEY in data)) {
return originalCallback.call(this, data);
}

var origError = data[FATAL_ERROR_KEY];
delete data[FATAL_ERROR_KEY];

reactNativePlugin._persistPayload(data)
.then(function() {
defaultHandler(origError, true);
handlingFatal = false; // In case it isn't configured to crash.
return null;
})
['catch'](function() {});

return false; // Do not continue.
});

ErrorUtils.setGlobalHandler(function(error, isFatal) {
var captureOptions = {
timestamp: new Date() / 1000
};
var error = arguments[0];
defaultHandler.apply(this, arguments)
Raven.captureException(error);
// We want to handle fatals, but only in production mode.
var shouldHandleFatal = isFatal && !global.__DEV__;
if (shouldHandleFatal) {
if (handlingFatal) {
console.log('Encountered multiple fatals in a row. The latest:', error);
return;
}
handlingFatal = true;
// We need to preserve the original error so that it can be rethrown
// after it is persisted (see our shouldSendCallback above).
captureOptions[FATAL_ERROR_KEY] = error;
}
Raven.captureException(error, captureOptions);
// Handle non-fatals regularly.
if (!shouldHandleFatal) {
defaultHandler(error);
}
});
}

/**
* Saves the payload for a globally-thrown error, so that we can report it on
* next launch.
*
* Returns a promise that guarantees never to reject.
*/
reactNativePlugin._persistPayload = function(payload) {
var AsyncStorage = require('react-native').AsyncStorage;
return AsyncStorage.setItem(ASYNC_STORAGE_KEY, JSON.stringify(payload))
['catch'](function() { return null; });
}

/**
* Checks for any previously persisted errors (e.g. from last crash)
*
* Returns a promise that guarantees never to reject.
*/
reactNativePlugin._restorePayload = function() {
var AsyncStorage = require('react-native').AsyncStorage;
var promise = AsyncStorage.getItem(ASYNC_STORAGE_KEY)
.then(function(payload) { return JSON.parse(payload); })
['catch'](function() { return null; });
// Make sure that we fetch ASAP.
AsyncStorage.flushGetRequests();

return promise;
};

/**
* Clears any persisted payloads.
*/
reactNativePlugin._clearPayload = function() {
var AsyncStorage = require('react-native').AsyncStorage;
return AsyncStorage.removeItem(ASYNC_STORAGE_KEY)
['catch'](function() { return null; });
}

/**
* Custom HTTP transport for use with React Native applications.
*/
Expand All @@ -82,7 +181,7 @@ reactNativePlugin._transport = function (options) {
}
} else {
if (options.onError) {
options.onError();
options.onError(new Error('Sentry error code: ' + request.status));
}
}
};
Expand Down
16 changes: 12 additions & 4 deletions src/raven.js
Original file line number Diff line number Diff line change
Expand Up @@ -1160,8 +1160,6 @@ Raven.prototype = {


_send: function(data) {
var self = this;

var globalOptions = this._globalOptions;

var baseData = {
Expand Down Expand Up @@ -1222,6 +1220,13 @@ Raven.prototype = {
return;
}

this._sendProcessedPayload(data);
},

_sendProcessedPayload: function(data, callback) {
var self = this;
var globalOptions = this._globalOptions;

// Send along an event_id if not explicitly passed.
// This event_id can be used to reference the error within Sentry itself.
// Set lastEventId after we know the error should actually be sent
Expand Down Expand Up @@ -1264,12 +1269,15 @@ Raven.prototype = {
data: data,
src: url
});
callback && callback();
},
onError: function failure() {
onError: function failure(error) {
self._triggerEvent('failure', {
data: data,
src: url
});
error = error || new Error('Raven send failed (no additional details provided)');
callback && callback(error);
}
});
},
Expand All @@ -1291,7 +1299,7 @@ Raven.prototype = {
opts.onSuccess();
}
} else if (opts.onError) {
opts.onError();
opts.onError(new Error('Sentry error code: ' + request.status));
}
}

Expand Down
100 changes: 93 additions & 7 deletions test/plugins/react-native.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
var Promise = require('bluebird');

var _Raven = require('../../src/raven');
var reactNativePlugin = require('../../plugins/react-native');

Expand All @@ -8,6 +10,10 @@ describe('React Native plugin', function () {
beforeEach(function () {
Raven = new _Raven();
Raven.config('http://[email protected]:80/2');

reactNativePlugin._persistPayload = self.sinon.stub().returns(Promise.resolve());
reactNativePlugin._restorePayload = self.sinon.stub().returns(Promise.resolve());
reactNativePlugin._clearPayload = self.sinon.stub().returns(Promise.resolve());
});

describe('_normalizeData()', function () {
Expand Down Expand Up @@ -135,16 +141,96 @@ describe('React Native plugin', function () {
}
});

it('should call the default React Native handler and Raven.captureException', function () {
it('checks for persisted errors when starting', function () {
var onInit = self.sinon.stub();
reactNativePlugin(Raven, {onInitialize: onInit});
assert.isTrue(reactNativePlugin._restorePayload.calledOnce);

return Promise.resolve().then(function () {
assert.isTrue(onInit.calledOnce);
});
});

it('reports persisted errors', function () {
var payload = {abc: 123};
self.sinon.stub(Raven, '_sendProcessedPayload');
reactNativePlugin._restorePayload = self.sinon.stub().returns(Promise.resolve(payload));
var onInit = self.sinon.stub();
reactNativePlugin(Raven, {onInitialize: onInit});

return Promise.resolve().then(function () {
assert.isTrue(onInit.calledOnce);
assert.equal(onInit.getCall(0).args[0], payload);
assert.isTrue(Raven._sendProcessedPayload.calledOnce);
assert.equal(Raven._sendProcessedPayload.getCall(0).args[0], payload);
});
});

it('clears persisted errors after they are reported', function () {
var payload = {abc: 123};
var callback;
self.sinon.stub(Raven, '_sendProcessedPayload', function(p, cb) { callback = cb; });
reactNativePlugin._restorePayload = self.sinon.stub().returns(Promise.resolve(payload));

reactNativePlugin(Raven);

return Promise.resolve().then(function () {
assert.isFalse(reactNativePlugin._clearPayload.called);
callback();
assert.isTrue(reactNativePlugin._clearPayload.called);
});
});

it('does not clear persisted errors if there is an error reporting', function () {
var payload = {abc: 123};
var callback;
self.sinon.stub(Raven, '_sendProcessedPayload', function(p, cb) { callback = cb; });
reactNativePlugin._restorePayload = self.sinon.stub().returns(Promise.resolve(payload));

reactNativePlugin(Raven);
var err = new Error();
this.sinon.stub(Raven, 'captureException');

this.globalErrorHandler(err);
return Promise.resolve().then(function () {
assert.isFalse(reactNativePlugin._clearPayload.called);
callback(new Error('nope'));
assert.isFalse(reactNativePlugin._clearPayload.called);
});
});

describe('in development mode', function () {
beforeEach(function () {
global.__DEV__ = true;
});

it('should call the default React Native handler and Raven.captureException', function () {
reactNativePlugin(Raven);
var err = new Error();
this.sinon.stub(Raven, 'captureException');

assert.isTrue(this.defaultErrorHandler.calledOnce);
assert.isTrue(Raven.captureException.calledOnce);
assert.equal(Raven.captureException.getCall(0).args[0], err);
this.globalErrorHandler(err, true);

assert.isTrue(this.defaultErrorHandler.calledOnce);
assert.isTrue(Raven.captureException.calledOnce);
assert.equal(Raven.captureException.getCall(0).args[0], err);
});
});

describe('in production mode', function () {
beforeEach(function () {
global.__DEV__ = false;
});

it('should call the default React Native handler after persisting the error', function () {
reactNativePlugin(Raven);
var err = new Error();
this.globalErrorHandler(err, true);

assert.isTrue(reactNativePlugin._persistPayload.calledOnce);

var defaultErrorHandler = this.defaultErrorHandler;
return Promise.resolve().then(function () {
assert.isTrue(defaultErrorHandler.calledOnce);
});
});
});
});
});