From 22a48301e8f5f2c6e22d31eaa94fb6d8c6949a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Wed, 19 Sep 2018 21:08:22 +0100 Subject: [PATCH] feat(sentry): add ErrorBoundary to capture exceptions * switch to new Sentry client lib * Add ErrorBoundary as a wrapper around App to capture errors and display exception page with auto refresh * rename all instances of Raven to Sentry --- ui/__mocks__/@sentry/browser.js | 10 ++ ui/package-lock.json | 106 +++++++++++++++++- ui/package.json | 2 +- ui/public/index.html | 2 +- ui/src/App.js | 5 +- ui/src/AppBoot.js | 23 ++-- ui/src/AppBoot.test.js | 54 +++++---- ui/src/ErrorBoundary.js | 74 ++++++++++++ ui/src/ErrorBoundary.test.js | 70 ++++++++++++ .../__snapshots__/ErrorBoundary.test.js.snap | 34 ++++++ ui/src/index.js | 4 +- 11 files changed, 339 insertions(+), 45 deletions(-) create mode 100644 ui/__mocks__/@sentry/browser.js create mode 100644 ui/src/ErrorBoundary.js create mode 100644 ui/src/ErrorBoundary.test.js create mode 100644 ui/src/__snapshots__/ErrorBoundary.test.js.snap diff --git a/ui/__mocks__/@sentry/browser.js b/ui/__mocks__/@sentry/browser.js new file mode 100644 index 000000000..e367dc45d --- /dev/null +++ b/ui/__mocks__/@sentry/browser.js @@ -0,0 +1,10 @@ +const init = jest.fn(); +const MockScope = { + setExtra: jest.fn() +}; +const configureScope = jest.fn().mockImplementation(fn => { + fn(MockScope); +}); +const captureException = jest.fn(); + +export { init, configureScope, captureException, MockScope }; diff --git a/ui/package-lock.json b/ui/package-lock.json index 92093c51a..b01352b48 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -133,6 +133,87 @@ "prop-types": "15.6.2" } }, + "@sentry/browser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-4.0.2.tgz", + "integrity": "sha512-i5yHv5OSOtO9LfR7Ju26X27yIw2B4h8nI92uM1HA0NEMgKvayA2fTj+gC3e/1+/E84bAu/8ZWEPt/j3zPEoYXQ==", + "requires": { + "@sentry/core": "4.0.1", + "@sentry/types": "4.0.1", + "@sentry/utils": "4.0.1", + "md5": "2.2.1" + } + }, + "@sentry/core": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-4.0.1.tgz", + "integrity": "sha512-ZCaLe+o3Ic1V0yUEQC7b9XvtJJsQ/N/UwWtDjIxn2ShszZNlYkZXxZp29r6GtZ1u318j3t6shcO9dw5eHN9kQg==", + "requires": { + "@sentry/hub": "4.0.1", + "@sentry/minimal": "4.0.1", + "@sentry/types": "4.0.0", + "@sentry/utils": "4.0.1" + }, + "dependencies": { + "@sentry/types": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-4.0.0.tgz", + "integrity": "sha512-UaGfdu9DTcdQjyZ7nSQXAX4Nz1EzwDY6lFMfWxvb3hq7qDvr3bg0LOSFyjELbS6RcAkXwjB8BLpicABAXao3ag==" + } + } + }, + "@sentry/hub": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-4.0.1.tgz", + "integrity": "sha512-XLpVrB8MJcquZpVstv7pXq1qjWH80mX7hln2CxWfeQTT5OmUMPGz0NbcaUyPA/QisvMYNo9eeVe2+lTnmSn5+Q==", + "requires": { + "@sentry/types": "4.0.0", + "@sentry/utils": "4.0.1" + }, + "dependencies": { + "@sentry/types": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-4.0.0.tgz", + "integrity": "sha512-UaGfdu9DTcdQjyZ7nSQXAX4Nz1EzwDY6lFMfWxvb3hq7qDvr3bg0LOSFyjELbS6RcAkXwjB8BLpicABAXao3ag==" + } + } + }, + "@sentry/minimal": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-4.0.1.tgz", + "integrity": "sha512-KKrgVATezmf/Wt1c0yH8csRwvV4s6TFGY1R3lgBwql3pKl3YYfL7RMqBke5elG9/3zWl6W4G9cleKZGAPLSvTA==", + "requires": { + "@sentry/hub": "4.0.1", + "@sentry/types": "4.0.0" + }, + "dependencies": { + "@sentry/types": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-4.0.0.tgz", + "integrity": "sha512-UaGfdu9DTcdQjyZ7nSQXAX4Nz1EzwDY6lFMfWxvb3hq7qDvr3bg0LOSFyjELbS6RcAkXwjB8BLpicABAXao3ag==" + } + } + }, + "@sentry/types": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-4.0.1.tgz", + "integrity": "sha512-1Cq2gk/wZuBHB//HO830nykysHEsvZpjFcoIBHyqsJ7GjjcMxRAnO8ix0aw3hRfOsiPgD3mp8QomY9DqRHbjjA==" + }, + "@sentry/utils": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-4.0.1.tgz", + "integrity": "sha512-SYbE1oe94TPnzcMlGZyCgIfo8e0NpkDo8sX1w43yHcE4HUbJ0NpK8z2FF4h52hShaog4ceLIXXSiYIKoKaJa2A==", + "requires": { + "@sentry/types": "4.0.0" + }, + "dependencies": { + "@sentry/types": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-4.0.0.tgz", + "integrity": "sha512-UaGfdu9DTcdQjyZ7nSQXAX4Nz1EzwDY6lFMfWxvb3hq7qDvr3bg0LOSFyjELbS6RcAkXwjB8BLpicABAXao3ag==" + } + } + }, "@types/node": { "version": "10.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.9.0.tgz", @@ -2079,6 +2160,11 @@ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=" }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" + }, "cheerio": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.2.tgz", @@ -2637,6 +2723,11 @@ "which": "1.3.1" } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" + }, "cryptiles": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", @@ -7568,6 +7659,16 @@ "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.1.tgz", "integrity": "sha1-izqsWIuKZuSXXjzepn97sylgH6w=" }, + "md5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz", + "integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "1.1.6" + } + }, "md5.js": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz", @@ -10194,11 +10295,6 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" }, - "raven-js": { - "version": "3.27.0", - "resolved": "https://registry.npmjs.org/raven-js/-/raven-js-3.27.0.tgz", - "integrity": "sha512-vChdOL+yzecfnGA+B5EhEZkJ3kY3KlMzxEhShKh6Vdtooyl0yZfYNFQfYzgMf2v4pyQa+OTZ5esTxxgOOZDHqw==" - }, "raw-body": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", diff --git a/ui/package.json b/ui/package.json index cbe81e4e0..a6a99fa64 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,6 +9,7 @@ "@fortawesome/free-regular-svg-icons": "5.3.1", "@fortawesome/free-solid-svg-icons": "5.3.1", "@fortawesome/react-fontawesome": "0.1.3", + "@sentry/browser": "4.0.2", "body-scroll-lock": "2.5.7", "bootstrap": "4.1.3", "bootswatch": "4.1.3", @@ -25,7 +26,6 @@ "object-hash": "1.3.0", "prop-types": "15.6.2", "qs": "6.5.2", - "raven-js": "3.27.0", "react": "16.5.2", "react-autosuggest": "9.4.1", "react-datepicker": "1.6.0", diff --git a/ui/public/index.html b/ui/public/index.html index 0003dc837..d05750f71 100644 --- a/ui/public/index.html +++ b/ui/public/index.html @@ -21,7 +21,7 @@ early, before the UI app is started. --> diff --git a/ui/src/App.js b/ui/src/App.js index 43c416eea..da48d96f1 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -10,6 +10,7 @@ import { NavBar } from "Components/NavBar"; import { Grid } from "Components/Grid"; import { Fetcher } from "Components/Fetcher"; import { FaviconBadge } from "Components/FaviconBadge"; +import { ErrorBoundary } from "./ErrorBoundary"; import "./App.css"; @@ -49,7 +50,7 @@ class App extends Component { render() { return ( - + - + ); } } diff --git a/ui/src/AppBoot.js b/ui/src/AppBoot.js index 1a2551afa..69d31e0b2 100644 --- a/ui/src/AppBoot.js +++ b/ui/src/AppBoot.js @@ -1,14 +1,14 @@ // helpers used to bootstrap App instance and environment for it -import Raven from "raven-js"; +import * as Sentry from "@sentry/browser"; const SettingsElement = () => document.getElementById("settings"); -const SetupRaven = settingsElement => { +const SetupSentry = settingsElement => { if ( settingsElement !== null && - settingsElement.dataset.ravenDsn && - settingsElement.dataset.ravenDsn !== "{{ .SentryDSN }}" + settingsElement.dataset.sentryDsn && + settingsElement.dataset.sentryDsn !== "{{ .SentryDSN }}" ) { let version = "unknown"; if ( @@ -18,17 +18,14 @@ const SetupRaven = settingsElement => { version = settingsElement.dataset.version; } - const ravenClient = new Raven.Client(); try { - ravenClient - .config(settingsElement.dataset.ravenDsn, { - release: version - }) - .install(); + return Sentry.init({ + dsn: settingsElement.dataset.sentryDsn, + release: version + }); } catch (err) { - console.error("Raven config failed: " + err); + console.error("Sentry config failed: " + err); } - return ravenClient; } return null; }; @@ -55,4 +52,4 @@ const ParseDefaultFilters = settingsElement => { return defaultFilters; }; -export { SettingsElement, SetupRaven, ParseDefaultFilters }; +export { SettingsElement, SetupSentry, ParseDefaultFilters }; diff --git a/ui/src/AppBoot.test.js b/ui/src/AppBoot.test.js index d69c20602..ee6e584ae 100644 --- a/ui/src/AppBoot.test.js +++ b/ui/src/AppBoot.test.js @@ -1,23 +1,29 @@ -import { SettingsElement, SetupRaven, ParseDefaultFilters } from "./AppBoot"; +import * as Sentry from "@sentry/browser"; -const MockSettings = (version, ravenDsn, defaultFilters) => { +import { SettingsElement, SetupSentry, ParseDefaultFilters } from "./AppBoot"; + +beforeEach(() => { + Sentry.init.mockReset(); +}); + +const MockSettings = (version, SentryDsn, defaultFilters) => { return jest.spyOn(document, "getElementById").mockImplementation(() => { const filtersBase64 = btoa(JSON.stringify(defaultFilters)); const settings = document.createElement("span"); settings.id = "settings"; settings.dataset = { version: version, - ravenDsn: ravenDsn, + SentryDsn: SentryDsn, defaultFiltersBase64: filtersBase64 }; return settings; }); }; -const RavenClient = (ravenDsn, version) => { +const SentryClient = (SentryDsn, version) => { const settings = document.createElement("span"); - settings.dataset = { ravenDsn: ravenDsn, version: version }; - return SetupRaven(settings); + settings.dataset = { sentryDsn: SentryDsn, version: version }; + SetupSentry(settings); }; const FiltersSetting = filters => { @@ -38,34 +44,40 @@ describe("SettingsElement()", () => { expect(spy).toHaveBeenCalledTimes(1); expect(settings.id).toBe("settings"); expect(settings.dataset.version).toBe("ver1"); - expect(settings.dataset.ravenDsn).toBe("fakeDSN"); + expect(settings.dataset.SentryDsn).toBe("fakeDSN"); }); }); -describe("SetupRaven()", () => { - it("does nothing when raven DSN is missing", () => { - const client = RavenClient(""); - expect(client).toBeNull(); +describe("SetupSentry()", () => { + it("does nothing when Sentry DSN is missing", () => { + SentryClient(""); + expect(Sentry.init).not.toHaveBeenCalled(); }); - it("configures raven when DSN is present", () => { - const client = RavenClient("https://key@example.com/mock"); - expect(client.isSetup()).toBeTruthy(); - expect(client._dsn).toBe("https://key@example.com/mock"); + it("configures Sentry when DSN is present", () => { + SentryClient("https://key@example.com/mock"); + expect(Sentry.init).toHaveBeenCalledWith({ + dsn: "https://key@example.com/mock", + release: "unknown" // default version + }); }); it("passes release option when version attr is present", () => { - const client = RavenClient("https://key@example.com/mock", "ver1"); - expect(client.isSetup()).toBeTruthy(); - expect(client._globalOptions.release).toBe("ver1"); + SentryClient("https://key@example.com/mock", "ver1"); + expect(Sentry.init).toHaveBeenCalledWith({ + dsn: "https://key@example.com/mock", + release: "ver1" + }); }); - it("logs an error when invalid DSN is passed to raven", () => { + it("logs an error when invalid DSN is passed to Sentry", () => { + Sentry.init = jest.fn().mockImplementation(() => { + throw new Error("Fake error"); + }); const consoleSpy = jest .spyOn(console, "error") .mockImplementation(() => {}); - const client = RavenClient("invalidDSN"); - expect(client.isSetup()).toBeFalsy(); + SentryClient("invalidDSN"); expect(consoleSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/ui/src/ErrorBoundary.js b/ui/src/ErrorBoundary.js new file mode 100644 index 000000000..0f805a25f --- /dev/null +++ b/ui/src/ErrorBoundary.js @@ -0,0 +1,74 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import * as Sentry from "@sentry/browser"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle"; + +const InternalError = ({ message, secondsLeft }) => ( +
+
+

+ + Internal error +

+

{message}

+

+ This page will auto refresh in {secondsLeft}s +

+
+
+); +InternalError.propTypes = { + message: PropTypes.node.isRequired, + secondsLeft: PropTypes.number.isRequired +}; + +class ErrorBoundary extends Component { + static propTypes = { + children: PropTypes.any + }; + + constructor(props) { + super(props); + this.state = { cachedError: null, reloadSeconds: 60 }; + } + + reloadApp = () => { + if (this.state.reloadSeconds <= 1) { + window.location.reload(); + } else { + this.setState({ reloadSeconds: this.state.reloadSeconds - 1 }); + } + }; + + componentDidCatch(error, errorInfo) { + this.setState({ cachedError: error }); + Sentry.configureScope(scope => { + Object.keys(errorInfo).forEach(key => { + scope.setExtra(key, errorInfo[key]); + }); + }); + Sentry.captureException(error); + // reload after 60s, this is to fix wall monitors automatically + setInterval(this.reloadApp, 1000); + } + + render() { + if (this.state.cachedError !== null) { + return ( + + ); + } + return this.props.children; + } +} + +export { ErrorBoundary }; diff --git a/ui/src/ErrorBoundary.test.js b/ui/src/ErrorBoundary.test.js new file mode 100644 index 000000000..52740a31d --- /dev/null +++ b/ui/src/ErrorBoundary.test.js @@ -0,0 +1,70 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import toDiffableHtml from "diffable-html"; + +import * as Sentry from "@sentry/browser"; + +import { ErrorBoundary } from "./ErrorBoundary"; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.clearAllTimers(); + jest.clearAllMocks(); +}); + +const FailingComponent = () => { + throw new Error("Error thrown from problem child"); +}; + +const MountedFailingComponent = () => { + return mount( + + + + ); +}; + +describe("", () => { + it("matches snapshot", () => { + const tree = MountedFailingComponent(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); + + it("componentDidCatch should catch an error from FailingComponent", () => { + jest.spyOn(ErrorBoundary.prototype, "componentDidCatch"); + MountedFailingComponent(); + expect(ErrorBoundary.prototype.componentDidCatch).toHaveBeenCalled(); + }); + + it("componentDidCatch should report to sentry", () => { + MountedFailingComponent(); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + + it("componentDidCatch passes scope to sentry", () => { + const tree = mount( + +
+ + ); + const instance = tree.instance(); + instance.componentDidCatch("foo", { foo: "bar" }); + expect(Sentry.MockScope.setExtra).toHaveBeenCalledWith("foo", "bar"); + }); + + it("calls window.location.reload after 60s", () => { + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + const reloadSpy = jest.spyOn(global.window.location, "reload"); + MountedFailingComponent(); + jest.runTimersToTime(1000 * 61); + expect(reloadSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalled(); + }); +}); diff --git a/ui/src/__snapshots__/ErrorBoundary.test.js.snap b/ui/src/__snapshots__/ErrorBoundary.test.js.snap new file mode 100644 index 000000000..f8901340f --- /dev/null +++ b/ui/src/__snapshots__/ErrorBoundary.test.js.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches snapshot 1`] = ` +" +
+
+

+ + + + + + Internal error + +

+

+ Error: Error thrown from problem child +

+

+ This page will auto refresh in 60s +

+
+
+" +`; diff --git a/ui/src/index.js b/ui/src/index.js index 4ba254936..bd3e66e59 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -3,12 +3,12 @@ import ReactDOM from "react-dom"; import Moment from "react-moment"; -import { SettingsElement, SetupRaven, ParseDefaultFilters } from "./AppBoot"; +import { SettingsElement, SetupSentry, ParseDefaultFilters } from "./AppBoot"; import { App } from "./App"; const settingsElement = SettingsElement(); -SetupRaven(settingsElement); +SetupSentry(settingsElement); // global timer for updating timestamps to human readable offsets // this needs to be run before any instance