Merge pull request #42 from prymitive/sentry-client

feat(sentry): add ErrorBoundary to capture exceptions
This commit is contained in:
Łukasz Mierzwa
2018-09-19 21:45:48 +01:00
committed by GitHub
11 changed files with 339 additions and 45 deletions

View File

@@ -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 };

106
ui/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -21,7 +21,7 @@
early, before the UI app is started.
-->
<span id="settings"
data-raven-dsn="{{ .SentryDSN }}"
data-sentry-dsn="{{ .SentryDSN }}"
data-version="{{ .Version }}"
data-default-filters-base64="{{ .DefaultFilter }}">
</span>

View File

@@ -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 (
<React.Fragment>
<ErrorBoundary>
<FaviconBadge alertStore={this.alertStore} />
<NavBar
alertStore={this.alertStore}
@@ -67,7 +68,7 @@ class App extends Component {
alertStore={this.alertStore}
settingsStore={this.settingsStore}
/>
</React.Fragment>
</ErrorBoundary>
);
}
}

View File

@@ -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 };

View File

@@ -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);
});
});

74
ui/src/ErrorBoundary.js Normal file
View File

@@ -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 }) => (
<div className="jumbotron text-center bg-primary my-4">
<div className="container-fluid">
<h1 className="display-1 my-5">
<FontAwesomeIcon
className="text-danger mr-2"
icon={faExclamationCircle}
/>
<span className="text-muted">Internal error</span>
</h1>
<p className="lead text-muted">{message}</p>
<p className="text-muted">
This page will auto refresh in {secondsLeft}s
</p>
</div>
</div>
);
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 (
<InternalError
message={this.state.cachedError.toString()}
secondsLeft={this.state.reloadSeconds}
/>
);
}
return this.props.children;
}
}
export { ErrorBoundary };

View File

@@ -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(
<ErrorBoundary>
<FailingComponent />
</ErrorBoundary>
);
};
describe("<ErrorBoundary />", () => {
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(
<ErrorBoundary>
<div />
</ErrorBoundary>
);
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();
});
});

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ErrorBoundary /> matches snapshot 1`] = `
"
<div class=\\"jumbotron text-center bg-primary my-4\\">
<div class=\\"container-fluid\\">
<h1 class=\\"display-1 my-5\\">
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"exclamation-circle\\"
class=\\"svg-inline--fa fa-exclamation-circle fa-w-16 text-danger mr-2\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z\\"
>
</path>
</svg>
<span class=\\"text-muted\\">
Internal error
</span>
</h1>
<p class=\\"lead text-muted\\">
Error: Error thrown from problem child
</p>
<p class=\\"text-muted\\">
This page will auto refresh in 60s
</p>
</div>
</div>
"
`;

View File

@@ -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 <Moment/> instance