diff --git a/Gruntfile.js b/Gruntfile.js index d39bcc4fe484..09a6057a8a34 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -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] } } diff --git a/package.json b/package.json index 066c7efbc5ae..697ee7b071e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plugins/react-native.js b/plugins/react-native.js index 3536080cabad..2d989dfda640 100644 --- a/plugins/react-native.js +++ b/plugins/react-native.js @@ -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'; @@ -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 */ @@ -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. */ @@ -82,7 +181,7 @@ reactNativePlugin._transport = function (options) { } } else { if (options.onError) { - options.onError(); + options.onError(new Error('Sentry error code: ' + request.status)); } } }; diff --git a/src/raven.js b/src/raven.js index a4fd43414b33..343e35f392cd 100644 --- a/src/raven.js +++ b/src/raven.js @@ -1160,8 +1160,6 @@ Raven.prototype = { _send: function(data) { - var self = this; - var globalOptions = this._globalOptions; var baseData = { @@ -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 @@ -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); } }); }, @@ -1291,7 +1299,7 @@ Raven.prototype = { opts.onSuccess(); } } else if (opts.onError) { - opts.onError(); + opts.onError(new Error('Sentry error code: ' + request.status)); } } diff --git a/test/plugins/react-native.test.js b/test/plugins/react-native.test.js index a0d760a63efc..f44906733ed6 100644 --- a/test/plugins/react-native.test.js +++ b/test/plugins/react-native.test.js @@ -1,3 +1,5 @@ +var Promise = require('bluebird'); + var _Raven = require('../../src/raven'); var reactNativePlugin = require('../../plugins/react-native'); @@ -8,6 +10,10 @@ describe('React Native plugin', function () { beforeEach(function () { Raven = new _Raven(); Raven.config('http://abc@example.com: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 () { @@ -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); + }); + }); }); }); });