Unverified Commit 5552dbfc authored by Hannah Stepanek's avatar Hannah Stepanek Committed by GitHub

Merge pull request #1320 from hypothesis/use-new-sentry-sdk

Replace legacy Sentry JS SDK with new Sentry SDK
parents 5d730afe cac16aa0
......@@ -11,6 +11,7 @@
"@babel/preset-env": "^7.1.6",
"@babel/preset-react": "^7.0.0",
"@octokit/rest": "^16.9.0",
"@sentry/browser": "^5.6.2",
"angular": "^1.7.5",
"angular-mocks": "^1.7.5",
"angular-route": "^1.7.5",
......@@ -93,7 +94,6 @@
"puppeteer": "^1.2.0",
"query-string": "^3.0.1",
"raf": "^3.1.0",
"raven-js": "^3.7.0",
"redux": "^4.0.1",
"redux-thunk": "^2.1.0",
"request": "^2.76.0",
......
......@@ -11,8 +11,8 @@ module.exports = {
jquery: ['jquery'],
angular: ['angular', 'angular-route', 'ng-tags-input', 'angular-toastr'],
katex: ['katex'],
sentry: ['@sentry/browser'],
showdown: ['showdown'],
raven: ['raven-js'],
},
// List of modules to exclude from parsing for require() statements.
......
......@@ -112,7 +112,7 @@ function bootSidebarApp(doc, config) {
...polyfills,
// Vendor code required by sidebar.bundle.js
'scripts/raven.bundle.js',
'scripts/sentry.bundle.js',
'scripts/angular.bundle.js',
'scripts/katex.bundle.js',
'scripts/showdown.bundle.js',
......
......@@ -41,7 +41,7 @@ describe('bootstrap', function() {
'styles/pdfjs-overrides.css',
// Sidebar app
'scripts/raven.bundle.js',
'scripts/sentry.bundle.js',
'scripts/angular.bundle.js',
'scripts/katex.bundle.js',
'scripts/showdown.bundle.js',
......@@ -151,7 +151,7 @@ describe('bootstrap', function() {
const expectedAssets = [
'scripts/angular.bundle.1234.js',
'scripts/katex.bundle.1234.js',
'scripts/raven.bundle.1234.js',
'scripts/sentry.bundle.1234.js',
'scripts/showdown.bundle.1234.js',
'scripts/sidebar.bundle.1234.js',
'styles/angular-csp.1234.css',
......
......@@ -6,16 +6,16 @@ const { fetchConfig } = require('./util/fetch-config');
const serviceConfig = require('./service-config');
const crossOriginRPC = require('./cross-origin-rpc.js');
let raven;
let sentry;
// Read settings rendered into sidebar app HTML by service/extension.
const appConfig = require('../shared/settings').jsonConfigsFrom(document);
if (appConfig.raven) {
// Initialize Raven. This is required at the top of this file
if (appConfig.sentry) {
// Initialize Sentry. This is required at the top of this file
// so that it happens early in the app's startup flow
raven = require('./raven');
raven.init(appConfig.raven);
sentry = require('./util/sentry');
sentry.init(appConfig.sentry);
}
// Disable Angular features that are not compatible with CSP.
......@@ -43,13 +43,6 @@ require('preact/debug');
const wrapReactComponent = require('./util/wrap-react-component');
// Setup Angular integration for Raven
if (appConfig.raven) {
raven.angularModule(angular);
} else {
angular.module('ngRaven', []);
}
if (appConfig.googleAnalytics) {
addAnalytics(appConfig.googleAnalytics);
}
......@@ -130,9 +123,6 @@ function startAngularApp(config) {
// Angular addons which do not export the Angular module
// name via module.exports
['ngTagsInput', require('ng-tags-input')][0],
// Local addons
'ngRaven',
])
// The root component for the application
......@@ -239,7 +229,6 @@ function startAngularApp(config) {
.value('VirtualThreadList', require('./virtual-thread-list'))
.value('isSidebar', isSidebar)
.value('random', require('./util/random'))
.value('raven', require('./raven'))
.value('serviceConfig', serviceConfig)
.value('settings', config)
.value('time', require('./util/time'))
......
'use strict';
/**
* This module configures Raven for reporting crashes
* to Sentry.
*
* Logging requires the Sentry DSN and Hypothesis
* version to be provided via the app's settings object.
*
* It also exports an Angular module via angularModule() which integrates
* error logging into any Angular application that it is added to
* as a dependency.
*/
const Raven = require('raven-js');
const angularPlugin = require('raven-js/plugins/angular');
/**
* Returns the input URL if it is an HTTP URL or the filename part of the URL
* otherwise.
*
* @param {string} url - The script URL associated with an exception stack
* frame.
*/
function convertLocalURLsToFilenames(url) {
if (!url) {
return url;
}
if (url.match(/https?:/)) {
return url;
}
// Strip the query string (which is used as a cache buster)
// and extract the filename from the URL
return url
.replace(/\?.*/, '')
.split('/')
.slice(-1)[0];
}
/**
* Return a transformed version of `data` with local URLs replaced
* with filenames.
*
* In environments where the client is served from a local URL,
* eg. chrome-extension://<ID>/scripts/bundle.js, the script URL
* and the sourcemap it references will not be accessible to Sentry.
*
* Therefore on the client we replace references to such URLs with just
* the filename part and then as part of the release process, upload both
* the source file and the source map to Sentry.
*
* Using just the filename allows us to upload a single set of source files
* and sourcemaps for a release though a given release of H might be served
* from multiple actual URLs (eg. different browser extensions).
*/
function translateSourceURLs(data) {
try {
const frames = data.exception.values[0].stacktrace.frames;
frames.forEach(function(frame) {
frame.filename = convertLocalURLsToFilenames(frame.filename);
});
data.culprit = frames[0].filename;
} catch (err) {
console.warn('Failed to normalize error stack trace', err, data);
}
return data;
}
function init(config) {
Raven.config(config.dsn, {
release: '__VERSION__', // replaced by versionify
dataCallback: translateSourceURLs,
}).install();
installUnhandledPromiseErrorHandler();
}
function setUserInfo(info) {
if (info) {
Raven.setUserContext(info);
} else {
Raven.setUserContext();
}
}
/**
* Initializes and returns the Angular module which provides
* a custom wrapper around Angular's $exceptionHandler service,
* logging any exceptions passed to it using Sentry.
*
* This must be invoked _after_ Raven is configured using init().
*/
function angularModule(angular) {
const prevCallback = Raven._globalOptions.dataCallback;
angularPlugin(Raven, angular);
// Hack: Ensure that both our data callback and the one provided by
// the Angular plugin are run when submitting errors.
//
// The Angular plugin replaces any previously installed
// data callback with its own which does not in turn call the
// previously registered callback that we registered when calling
// Raven.config().
//
// See https://github.com/getsentry/raven-js/issues/522
const angularCallback = Raven._globalOptions.dataCallback;
Raven.setDataCallback(function(data) {
return angularCallback(prevCallback(data));
});
return angular.module('ngRaven');
}
/**
* Report an error to Sentry.
*
* @param {Error} error - An error object describing what went wrong
* @param {string} when - A string describing the context in which
* the error occurred.
* @param {Object} [context] - A JSON-serializable object containing additional
* information which may be useful when
* investigating the error.
*/
function report(error, when, context) {
if (!(error instanceof Error)) {
// If the passed object is not an Error, raven-js
// will serialize it using toString() which produces unhelpful results
// for objects that do not provide their own toString() implementations.
//
// If the error is a plain object or non-Error subclass with a message
// property, such as errors returned by chrome.extension.lastError,
// use that instead.
if (typeof error === 'object' && error.message) {
error = error.message;
}
}
const extra = Object.assign({ when: when }, context);
Raven.captureException(error, { extra: extra });
}
/**
* Installs a handler to catch unhandled rejected promises.
*
* For this to work, the browser or the Promise polyfill must support
* the unhandled promise rejection event (Chrome >= 49). On other browsers,
* the rejections will simply go unnoticed. Therefore, app code _should_
* always provide a .catch() handler on the top-most promise chain.
*
* See https://github.com/getsentry/raven-js/issues/424
* and https://www.chromestatus.com/feature/4805872211460096
*
* It is possible that future versions of Raven JS may handle these events
* automatically, in which case this code can simply be removed.
*/
function installUnhandledPromiseErrorHandler() {
window.addEventListener('unhandledrejection', function(event) {
if (event.reason) {
report(event.reason, 'Unhandled Promise rejection');
}
});
}
module.exports = {
init: init,
angularModule: angularModule,
setUserInfo: setUserInfo,
report: report,
};
......@@ -2,6 +2,7 @@
const events = require('../events');
const retryUtil = require('../util/retry');
const sentry = require('../util/sentry');
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
......@@ -27,7 +28,6 @@ function session(
api,
auth,
flash,
raven,
settings,
serviceConfig
) {
......@@ -122,11 +122,11 @@ function session(
// Associate error reports with the current user in Sentry.
if (model.userid) {
raven.setUserInfo({
sentry.setUserInfo({
id: model.userid,
});
} else {
raven.setUserInfo(undefined);
sentry.setUserInfo(null);
}
}
......
......@@ -3,6 +3,7 @@
const angular = require('angular');
const events = require('../../events');
const sessionFactory = require('../session');
const mock = angular.mock;
......@@ -12,15 +13,17 @@ describe('sidebar.session', function() {
let fakeAnalytics;
let fakeAuth;
let fakeFlash;
let fakeRaven;
let fakeSentry;
let fakeServiceConfig;
let fakeSettings;
let fakeApi;
let sandbox;
// The instance of the `session` service.
let session;
before(function() {
angular.module('h', []).service('session', require('../session'));
angular.module('h', []).service('session', sessionFactory);
});
beforeEach(function() {
......@@ -43,7 +46,7 @@ describe('sidebar.session', function() {
login: sandbox.stub().returns(Promise.resolve()),
};
fakeFlash = { error: sandbox.spy() };
fakeRaven = {
fakeSentry = {
setUserInfo: sandbox.spy(),
};
fakeApi = {
......@@ -63,10 +66,13 @@ describe('sidebar.session', function() {
api: fakeApi,
auth: fakeAuth,
flash: fakeFlash,
raven: fakeRaven,
settings: fakeSettings,
serviceConfig: fakeServiceConfig,
});
sessionFactory.$imports.$mock({
'../util/sentry': fakeSentry,
});
});
beforeEach(
......@@ -77,6 +83,7 @@ describe('sidebar.session', function() {
);
afterEach(function() {
sessionFactory.$imports.$restore();
sandbox.restore();
});
......@@ -198,7 +205,7 @@ describe('sidebar.session', function() {
session.update({
userid: 'anne',
});
assert.calledWith(fakeRaven.setUserInfo, {
assert.calledWith(fakeSentry.setUserInfo, {
id: 'anne',
});
});
......
'use strict';
const raven = require('../raven');
function fakeExceptionData(scriptURL) {
return {
exception: {
values: [
{
stacktrace: {
frames: [
{
filename: scriptURL,
},
],
},
},
],
},
culprit: scriptURL,
};
}
describe('raven', function() {
// A stub for the callback that the Angular plugin installs with
// Raven.setDataCallback()
let fakeAngularTransformer;
let fakeAngularPlugin;
let fakeRavenJS;
beforeEach(function() {
fakeRavenJS = {
config: sinon.stub().returns({
install: sinon.stub(),
}),
captureException: sinon.stub(),
setDataCallback: function(callback) {
this._globalOptions.dataCallback = callback;
},
_globalOptions: {
dataCallback: undefined,
},
};
fakeAngularTransformer = sinon.stub();
fakeAngularPlugin = sinon.spy(function(Raven) {
Raven.setDataCallback(fakeAngularTransformer);
});
raven.$imports.$mock({
'raven-js': fakeRavenJS,
'raven-js/plugins/angular': fakeAngularPlugin,
});
});
afterEach(() => {
raven.$imports.$restore();
});
describe('.install()', function() {
it('installs a handler for uncaught promises', function() {
raven.init({
dsn: 'dsn',
release: 'release',
});
const event = document.createEvent('Event');
event.initEvent(
'unhandledrejection',
true /* bubbles */,
true /* cancelable */
);
event.reason = new Error('Some error');
window.dispatchEvent(event);
assert.calledWith(
fakeRavenJS.captureException,
event.reason,
sinon.match.any
);
});
});
describe('pre-submission data transformation', function() {
let dataCallback;
beforeEach(function() {
raven.init({ dsn: 'dsn', release: 'release' });
const configOpts = fakeRavenJS.config.args[0][1];
dataCallback = configOpts && configOpts.dataCallback;
});
it('installs a transformer', function() {
assert.ok(dataCallback);
});
it('replaces non-HTTP URLs with filenames', function() {
const scriptURL = 'chrome-extension://1234/public/bundle.js';
const transformed = dataCallback(fakeExceptionData(scriptURL));
assert.equal(transformed.culprit, 'bundle.js');
const transformedStack = transformed.exception.values[0].stacktrace;
assert.equal(transformedStack.frames[0].filename, 'bundle.js');
});
it('does not modify HTTP URLs', function() {
const scriptURL = 'https://hypothes.is/assets/scripts/bundle.js';
const transformed = dataCallback(fakeExceptionData(scriptURL));
assert.equal(transformed.culprit, scriptURL);
const transformedStack = transformed.exception.values[0].stacktrace;
assert.equal(transformedStack.frames[0].filename, scriptURL);
});
});
describe('.report()', function() {
it('extracts the message property from Error-like objects', function() {
raven.report({ message: 'An error' }, 'context');
assert.calledWith(fakeRavenJS.captureException, 'An error', {
extra: {
when: 'context',
},
});
});
it('passes extra details through', function() {
const error = new Error('an error');
raven.report(error, 'some operation', { url: 'foobar.com' });
assert.calledWith(fakeRavenJS.captureException, error, {
extra: {
when: 'some operation',
url: 'foobar.com',
},
});
});
});
describe('.angularModule()', function() {
let angularStub;
beforeEach(function() {
angularStub = {
module: sinon.stub(),
};
});
it('installs the Angular plugin', function() {
raven.init('dsn');
raven.angularModule(angularStub);
assert.calledWith(fakeAngularPlugin, fakeRavenJS, angularStub);
});
it('installs the data transformers', function() {
raven.init('dsn');
const originalTransformer = sinon.stub();
fakeRavenJS._globalOptions.dataCallback = originalTransformer;
raven.angularModule(angularStub);
fakeRavenJS._globalOptions.dataCallback(
fakeExceptionData('app.bundle.js')
);
// Check that both our data transformer and the one provided by
// the Angular plugin were invoked
assert.called(originalTransformer);
assert.called(fakeAngularTransformer);
});
});
});
'use strict';
const Sentry = require('@sentry/browser');
const warnOnce = require('../../shared/warn-once');
/**
* @typedef SentryConfig
* @prop {string} dsn
* @prop {string} environment
*/
let eventsSent = 0;
const maxEventsToSendPerSession = 5;
/**
* Initialize the Sentry integration.
*
* This will activate Sentry and enable capturing of uncaught errors and
* unhandled promise rejections.
*
* @param {SentryConfig} config
*/
function init(config) {
Sentry.init({
dsn: config.dsn,
environment: config.environment,
release: '__VERSION__', // replaced by versionify
beforeSend: event => {
if (eventsSent >= maxEventsToSendPerSession) {
// Cap the number of events that any client instance will send, to
// reduce the impact on our Sentry event quotas.
//
// Sentry implements its own server-side rate limiting in addition.
// See https://docs.sentry.io/accounts/quotas/.
warnOnce(
'Client-side Sentry quota reached. No further Sentry events will be sent'
);
return null;
}
++eventsSent;
return event;
},
});
}
/**
* Record the user ID of the logged-in user.
*
* See https://docs.sentry.io/platforms/javascript/#capturing-the-user
*
* @param {import('@sentry/browser').User|null} user
*/
function setUserInfo(user) {
Sentry.setUser(user);
}
/**
* Reset metrics used for client-side event filtering.
*/
function reset() {
eventsSent = 0;
}
module.exports = {
init,
setUserInfo,
// Test helpers.
reset,
};
'use strict';
const sentry = require('../sentry');
describe('sidebar/util/sentry', () => {
let fakeSentry;
let fakeWarnOnce;
beforeEach(() => {
fakeSentry = {
init: sinon.stub(),
setUser: sinon.stub(),
};
fakeWarnOnce = sinon.stub();
sentry.$imports.$mock({
'@sentry/browser': fakeSentry,
'../../shared/warn-once': fakeWarnOnce,
});
});
afterEach(() => {
sentry.$imports.$restore();
});
describe('init', () => {
it('configures Sentry', () => {
sentry.init({
dsn: 'test-dsn',
environment: 'dev',
});
assert.calledWith(
fakeSentry.init,
sinon.match({
dsn: 'test-dsn',
environment: 'dev',
release: '1.0.0-dummy-version',
})
);
});
it('limits the number of events sent to Sentry per session', () => {
sentry.init({ dsn: 'test-dsn' });
assert.called(fakeSentry.init);
// The first `maxEvents` events should be sent to Sentry.
const maxEvents = 5;
const beforeSend = fakeSentry.init.getCall(0).args[0].beforeSend;
for (let i = 0; i < maxEvents; i++) {
const val = {};
// These events should not be modified.
assert.equal(beforeSend(val), val);
}
assert.notCalled(fakeWarnOnce);
// Subsequent events should not be sent and a warning should be logged.
assert.equal(beforeSend({}), null);
assert.equal(beforeSend({}), null); // Verify this works a second time.
assert.called(fakeWarnOnce);
});
});
describe('setUserInfo', () => {
it('sets the Sentry user', () => {
sentry.setUserInfo({ id: 'acct:jimsmith@hypothes.is' });
// `setUserInfo` is currently a trivial wrapper.
assert.calledWith(fakeSentry.setUser, {
id: 'acct:jimsmith@hypothes.is',
});
});
});
});
......@@ -765,6 +765,58 @@
universal-user-agent "^3.0.0"
url-template "^2.0.8"
"@sentry/browser@^5.6.2":
version "5.6.2"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.6.2.tgz#f39e95c3aff2d4b4fd5d0d4aa7192af73f20d24e"
integrity sha512-Nm/W/5ra6+OQCWQkdd86vHjcYUjHCVqCzQyPasd6HE7SNlWe5euGVfFfDuUFsiDrMAG5uKfGYw5u/AqoweiQkQ==
dependencies:
"@sentry/core" "5.6.2"
"@sentry/types" "5.6.1"
"@sentry/utils" "5.6.1"
tslib "^1.9.3"
"@sentry/core@5.6.2":
version "5.6.2"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.6.2.tgz#8c5477654a83ebe41a72e86a79215deb5025e418"
integrity sha512-grbjvNmyxP5WSPR6UobN2q+Nss7Hvz+BClBT8QTr7VTEG5q89TwNddn6Ej3bGkaUVbct/GpVlI3XflWYDsnU6Q==
dependencies:
"@sentry/hub" "5.6.1"
"@sentry/minimal" "5.6.1"
"@sentry/types" "5.6.1"
"@sentry/utils" "5.6.1"
tslib "^1.9.3"
"@sentry/hub@5.6.1":
version "5.6.1"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.6.1.tgz#9f355c0abcc92327fbd10b9b939608aa4967bece"
integrity sha512-m+OhkIV5yTAL3R1+XfCwzUQka0UF/xG4py8sEfPXyYIcoOJ2ZTX+1kQJLy8QQJ4RzOBwZA+DzRKP0cgzPJ3+oQ==
dependencies:
"@sentry/types" "5.6.1"
"@sentry/utils" "5.6.1"
tslib "^1.9.3"
"@sentry/minimal@5.6.1":
version "5.6.1"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.6.1.tgz#09d92b26de0b24555cd50c3c33ba4c3e566009a1"
integrity sha512-ercCKuBWHog6aS6SsJRuKhJwNdJ2oRQVWT2UAx1zqvsbHT9mSa8ZRjdPHYOtqY3DoXKk/pLUFW/fkmAnpdMqRw==
dependencies:
"@sentry/hub" "5.6.1"
"@sentry/types" "5.6.1"
tslib "^1.9.3"
"@sentry/types@5.6.1":
version "5.6.1"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.6.1.tgz#5915e1ee4b7a678da3ac260c356b1cb91139a299"
integrity sha512-Kub8TETefHpdhvtnDj3kKfhCj0u/xn3Zi2zIC7PB11NJHvvPXENx97tciz4roJGp7cLRCJsFqCg4tHXniqDSnQ==
"@sentry/utils@5.6.1":
version "5.6.1"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.6.1.tgz#69d9e151e50415bc91f2428e3bcca8beb9bc2815"
integrity sha512-rfgha+UsHW816GqlSRPlniKqAZylOmQWML2JsujoUP03nPu80zdN43DK9Poy/d9OxBxv0gd5K2n+bFdM2kqLQQ==
dependencies:
"@sentry/types" "5.6.1"
tslib "^1.9.3"
"@sinonjs/commons@^1", "@sinonjs/commons@^1.0.2", "@sinonjs/commons@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.4.0.tgz#7b3ec2d96af481d7a0321252e7b1c94724ec5a78"
......@@ -7091,11 +7143,6 @@ range-parser@^1.2.0, range-parser@~1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raven-js@^3.7.0:
version "3.27.2"
resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.27.2.tgz#6c33df952026cd73820aa999122b7b7737a66775"
integrity sha512-mFWQcXnhRFEQe5HeFroPaEghlnqy7F5E2J3Fsab189ondqUzcjwSVi7el7F36cr6PvQYXoZ1P2F5CSF2/azeMQ==
raw-body@2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
......@@ -8493,7 +8540,7 @@ trim-right@^1.0.1:
dependencies:
glob "^7.1.2"
tslib@^1.9.0:
tslib@^1.9.0, tslib@^1.9.3:
version "1.10.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment