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