From 3c8f407757911c16704129a8bf62980788a0c0cb Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Wed, 22 Mar 2017 11:31:47 +0100 Subject: [PATCH] Change options merging, Move react-native raven plugin into repo, Fix unit tests --- docs/index.rst | 13 +-- lib/Sentry.js | 75 ++++++-------- lib/raven-plugin.js | 239 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+), 53 deletions(-) create mode 100644 lib/raven-plugin.js diff --git a/docs/index.rst b/docs/index.rst index 7e9fb72495..9942d29ae9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -110,13 +110,13 @@ Setup With Cocoapods In order to use Sentry with cocoapods you have to install the packages with ``npm`` or ``yarn`` and link them locally in your ``Podfile``. -.. code-block:: bash +.. sourcecode:: bash npm install --save react react-native react-native-sentry After that change your ``Podfile`` to reference to the packages in your ``node_modules`` folder. -.. code-block:: bash +.. sourcecode:: ruby platform :ios, '8.0' use_frameworks! @@ -187,13 +187,14 @@ These are functions you can call in your javascript code: // disable stacktrace merging Sentry.config("___DSN___", { - deactivateStacktraceMerging: true, - logLevel: SentryLog.Debug, + deactivateStacktraceMerging: true, // default: false | Deactivates the stacktrace merging feature + logLevel: SentryLog.Debug, // default SentryLog.None | Possible values: .None, .Error, .Debug, .Verbose + forceRavenClient: true // default false | This will force sentry to use the raven client instead the native client // These two options will only be considered if stacktrace merging is active // Here you can add modules that should be ignored or exclude modules // that should no longer be ignored from stacktrace merging - // ignoreModulesExclude: ["I18nManager"], // Exclude is always stronger than include - // ignoreModulesInclude: ["RNSentry"], // Include modules that should be ignored too + // ignoreModulesExclude: ["I18nManager"], // default: [] | Exclude is always stronger than include + // ignoreModulesInclude: ["RNSentry"], // default: [] | Include modules that should be ignored too // --------------------------------- }).install(); diff --git a/lib/Sentry.js b/lib/Sentry.js index bdd62a521f..78dd7ea577 100644 --- a/lib/Sentry.js +++ b/lib/Sentry.js @@ -1,8 +1,7 @@ import { NativeModules } from 'react-native'; -import Raven from 'raven-js'; -require('raven-js/plugins/react-native')(Raven); +import Raven from 'raven-js';; const { RNSentry @@ -61,10 +60,10 @@ export const SentryLog = { export class Sentry { static install() { - if (RNSentry && RNSentry.nativeClientAvailable) { - Sentry._client = new NativeClient(Sentry._dsn, Sentry._options); + if (RNSentry && RNSentry.nativeClientAvailable && Sentry.options.forceRavenClient === false) { + Sentry._client = new NativeClient(Sentry._dsn, Sentry.options); } else { - Sentry._client = new RavenClient(Sentry._dsn, Sentry._options); + Sentry._client = new RavenClient(Sentry._dsn, Sentry.options); } } @@ -73,7 +72,12 @@ export class Sentry { throw new Error('Sentry: A DSN must be provided'); } Sentry._dsn = dsn; - Sentry._options = options; + Sentry.options = { + logLevel: SentryLog.None, + forceRavenClient: false, + } + Object.assign(Sentry.options, options); + Sentry._originalConsole = console || {}; return Sentry; } @@ -102,23 +106,11 @@ export class Sentry { } static log = (level, message) => { - if (Sentry._options && Sentry._options.logLevel) { - if (Sentry._options.logLevel < level) { + if (Sentry.options && Sentry.options.logLevel) { + if (Sentry.options.logLevel < level) { return; } - switch (level) { - case SentryLog.Error: - console.error(message); - break; - case SentryLog.Debug: - console.debug(message); - break; - case SentryLog.Verbose: - console.log(message); - break; - default: - return - } + Sentry._originalConsole.log(message); } } } @@ -134,24 +126,15 @@ class NativeClient { this._dsn = dsn; this._activatedMerging = false; - RNSentry.startWithDsnString(this._dsn); - - this._deactivateStacktraceMerging = false; - if (options && options.deactivateStacktraceMerging) { - this._deactivateStacktraceMerging = true; + this.options = { + ignoreModulesExclude: [], + ignoreModulesInclude: [], + deactivateStacktraceMerging: false } - if (options && options.logLevel) { - RNSentry.setLogLevel(options.logLevel); - } - this._ignoreModulesExclude = []; - if (options && options.ignoreModulesExclude) { - this._ignoreModulesExclude = options.ignoreModulesExclude; - } - this._ignoreModulesInclude = []; - if (options && options.ignoreModulesInclude) { - this._ignoreModulesInclude = options.ignoreModulesInclude; - } - if (this._deactivateStacktraceMerging === false) { + Object.assign(this.options, options); + + RNSentry.startWithDsnString(this._dsn); + if (this.options.deactivateStacktraceMerging === false) { this._activateStacktraceMerging(); } } @@ -200,9 +183,9 @@ class NativeClient { this._ignoredModules = {}; __fbBatchedBridgeConfig.remoteModuleConfig.forEach((module, moduleID) => { if (module !== null && - this._ignoreModulesExclude.indexOf(module[0]) == -1 && + this.options.ignoreModulesExclude.indexOf(module[0]) == -1 && (DEFAULT_MODULE_IGNORES.indexOf(module[0]) >= 0 || - this._ignoreModulesInclude.indexOf(module[0]) >= 0)) { + this.options.ignoreModulesInclude.indexOf(module[0]) >= 0)) { this._ignoredModules[moduleID] = true; } }); @@ -232,15 +215,13 @@ class RavenClient { if (dsn.constructor !== String) { throw new Error('SentryClient: A DSN must be provided'); } - this._dsn = dsn; - if (options === null || options === undefined) { - options = {}; + this.options = { + allowSecretKey: true, } - Object.assign(options, { - allowSecretKey: true - }); - Raven.config(dsn, options).install(); + Object.assign(this.options, options); + Raven.addPlugin(require('./raven-plugin')); + Raven.config(dsn, this.options).install(); } crash = () => { diff --git a/lib/raven-plugin.js b/lib/raven-plugin.js new file mode 100644 index 0000000000..d57789202d --- /dev/null +++ b/lib/raven-plugin.js @@ -0,0 +1,239 @@ +/*global ErrorUtils:false*/ + +/** + * react-native plugin for Raven + * + * Usage: + * var Raven = require('raven-js'); + * Raven.addPlugin(require('raven-js/plugins/react-native')); + * + * Options: + * + * 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'; +import { + NativeModules +} from 'react-native'; + +// Example React Native path format (iOS): +// /var/containers/Bundle/Application/{DEVICE_ID}/HelloWorld.app/main.jsbundle + +var PATH_STRIP_RE = /^.*\/[^\.]+(\.app|CodePush)/; +var stringify = require('json-stringify-safe'); +var FATAL_ERROR_KEY = '--rn-fatal--'; +var ASYNC_STORAGE_KEY = '--raven-js-global-error-payload--'; + +/** + * Strip device-specific IDs from React Native file:// paths + */ +function normalizeUrl(url, pathStripRe) { + return url + .replace(/^file\:\/\//, '') + .replace(pathStripRe, ''); +} + +/** + * Extract key/value pairs from an object and encode them for + * use in a query string + */ +function urlencode(obj) { + var pairs = []; + for (var key in obj) { + if ({}.hasOwnProperty.call(obj, key)) + pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key])); + } + return pairs.join('&'); +} + +/** + * Initializes React Native plugin + */ +function reactNativePlugin(Raven, options) { + options = options || {}; + + // react-native doesn't have a document, so can't use default Image + // transport - use XMLHttpRequest instead + Raven.setTransport(reactNativePlugin._transport); + + // Use data callback to strip device-specific paths from stack traces + Raven.setDataCallback(function(data) { + 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; + + Raven.setShouldSendCallback(function(data, originalCallback) { + if (!(FATAL_ERROR_KEY in data)) { + // not a fatal (will not crash runtime), continue as planned + return originalCallback ? originalCallback.call(this, data) : true; + } + + 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]; + // 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, 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. + var RCTAsyncSQLiteStorage = NativeModules.AsyncSQLiteDBStorage; + var RCTAsyncRocksDBStorage = NativeModules.AsyncRocksDBStorage; + var RCTAsyncFileStorage = NativeModules.AsyncLocalStorage; + var RCTAsyncStorage = RCTAsyncRocksDBStorage || RCTAsyncSQLiteStorage || RCTAsyncFileStorage; + if (RCTAsyncStorage.multiGet) { + 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. + */ +reactNativePlugin._transport = function (options) { + var request = new XMLHttpRequest(); + request.onreadystatechange = function (e) { + if (request.readyState !== 4) { + return; + } + + if (request.status === 200) { + if (options.onSuccess) { + options.onSuccess(); + } + } else { + if (options.onError) { + var err = new Error('Sentry error code: ' + request.status); + err.request = request; + options.onError(err); + } + } + }; + + request.open('POST', options.url + '?' + urlencode(options.auth)); + + // NOTE: React Native ignores CORS and will NOT send a preflight + // request for application/json. + // See: https://facebook.github.io/react-native/docs/network.html#xmlhttprequest + request.setRequestHeader('Content-type', 'application/json'); + + // Sentry expects an Origin header when using HTTP POST w/ public DSN. + // Just set a phony Origin value; only matters if Sentry Project is configured + // to whitelist specific origins. + request.setRequestHeader('Origin', 'react-native://'); + request.send(stringify(options.data)); +}; + +/** + * Strip device-specific IDs found in culprit and frame filenames + * when running React Native applications on a physical device. + */ +reactNativePlugin._normalizeData = function (data, pathStripRe) { + if (!pathStripRe) { + pathStripRe = PATH_STRIP_RE; + } + + if (data.culprit) { + data.culprit = normalizeUrl(data.culprit, pathStripRe); + } + + // NOTE: if data.exception exists, exception.values and exception.values[0] are + // guaranteed to exist + var stacktrace = data.stacktrace || data.exception && data.exception.values[0].stacktrace; + if (stacktrace) { + stacktrace.frames.forEach(function (frame) { + frame.filename = normalizeUrl(frame.filename, pathStripRe); + }); + } +}; + +module.exports = reactNativePlugin;