SALEOR-2064 Add generic error tracker with Sentry adapter (#956)

* Add generic error tracker with Sentry extension

* Add Sentry webpack plugin

* Update variable names and README

* Update deploy-staging template

* Update changelog
This commit is contained in:
Jakub Majorek 2021-01-22 15:05:26 +01:00 committed by GitHub
parent 10131163c1
commit 383055a2de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 451 additions and 23 deletions

View file

@ -11,6 +11,12 @@ jobs:
API_URI: https://master.staging.saleor.cloud/graphql/
APP_MOUNT_URI: /dashboard/
STATIC_URL: /dashboard/static/
SENTRY_ORG: sentry
SENTRY_PROJECT: dashboard
SENTRY_URL_PREFIX: "~/static"
ENVIRONMENT: master-staging
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
steps:
- uses: actions/checkout@v2
- name: Package
@ -41,4 +47,3 @@ jobs:
aws s3 sync build/dashboard s3://${{ secrets.AWS_STAGING_DEPLOYMENT_BUCKET }}/saleor-master-staging/static/
aws s3 cp build/dashboard/index.html s3://${{ secrets.AWS_STAGING_DEPLOYMENT_BUCKET }}/saleor-master-staging/
aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_STAGING_CF_DIST_ID }} --paths "/dashboard*"

View file

@ -17,6 +17,7 @@ All notable, unreleased changes to this project will be documented in this file.
- Add reference attributes - #917 by @orzechdev
- Add product reference attributes - #948 by @orzechdev
- Drop descriptionJson and contentJson fields - #950 by @jwm0
- Add error tracking with Sentry adapter - #956 by @jwm0
# 2.11.1

View file

@ -61,7 +61,7 @@ $ npm i
### Configuration
There are two environment variables available for configuration:
There following environment variables are available for configuration:
- `API_URI` (required) - URI of a running instance of Saleor GraphQL API.
If you are running Saleor locally with the default settings, set `API_URI` to: `http://localhost:8000/graphql/`.
@ -88,4 +88,43 @@ To build the application bundle run:
$ npm run build
```
### Error Tracking
Saleor Dashboard is using a generic error tracking wrapper function that takes care of the most popular use cases:
- initializing the tracker
- capturing exceptions and (optionally) displaying the event id
- setting basic user data (this is opt-in and disabled by default)
By default it ships with a Sentry adapter but any kind of error tracking software can be used by creating a custom adapter (using Sentry and TS types as an example).
Example:
```javascript
// src/services/errorTracking/index.ts
import { CustomAdapter } from "./adapters/";
const errorTracker = ErrorTrackerFactory(CustomAdapter(config));
```
##### Usage with Sentry adapter:
Sentry is used as the default tracker so no changes in code are necessary and the configuration is done via environment variables.
The following environment variables are available:
```
# Required
SENTRY_DSN=
# Optional
# https://docs.sentry.io/product/cli/configuration/
SENTRY_AUTH_TOKEN=
SENTRY_ORG=
SENTRY_PROJECT=
SENTRY_URL_PREFIX=
ENVIRONMENT=
```
#### Crafted with ❤️ by [Mirumee Software](https://mirumee.com)

120
package-lock.json generated
View file

@ -3153,6 +3153,120 @@
"any-observable": "^0.3.0"
}
},
"@sentry/browser": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.0.0.tgz",
"integrity": "sha512-R4+MHb5FyVZCz3EVnaquvT1mwOM2MWP4gBqjYEADY5m0XWoHiJf0skFkWt8iEKJanzGbhl4PMb9gHuJj6YfVLw==",
"requires": {
"@sentry/core": "6.0.0",
"@sentry/types": "6.0.0",
"@sentry/utils": "6.0.0",
"tslib": "^1.9.3"
}
},
"@sentry/cli": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-1.61.0.tgz",
"integrity": "sha512-pHEhqP1bB4sdO7N5ow/IkRBrPbKT9HZRinq4PhTVIvmG+NW4VVuVZ6k4tlbp+JXmzMcUc/iXynVkTL7zJIlTQw==",
"dev": true,
"requires": {
"https-proxy-agent": "^5.0.0",
"mkdirp": "^0.5.5",
"node-fetch": "^2.6.0",
"progress": "^2.0.3",
"proxy-from-env": "^1.1.0"
},
"dependencies": {
"https-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
"integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
"dev": true,
"requires": {
"agent-base": "6",
"debug": "4"
}
}
}
},
"@sentry/core": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.0.0.tgz",
"integrity": "sha512-afAiOachs/WfGWc9LsJBFnJMhqQVENyzfSMnf7sLRvxPAw8n7IrXY0R09MKmG0SlAnTKN2pWoQFzFF+J3NuHBA==",
"requires": {
"@sentry/hub": "6.0.0",
"@sentry/minimal": "6.0.0",
"@sentry/types": "6.0.0",
"@sentry/utils": "6.0.0",
"tslib": "^1.9.3"
}
},
"@sentry/hub": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.0.0.tgz",
"integrity": "sha512-s8IsW6LvEH7ACnniQcxxb/9uEyjmoQ/TAoryTJN2qyPzzrHTw8NCyMuJvK+8ivUvRViz5AvtuOFf8AJlh9lzeA==",
"requires": {
"@sentry/types": "6.0.0",
"@sentry/utils": "6.0.0",
"tslib": "^1.9.3"
}
},
"@sentry/minimal": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.0.0.tgz",
"integrity": "sha512-daYdEzTr+ERMwViu6RpWHOfk0oZrSNqdx+7bejTqmFHqO4pt+9ZrMiw3vinL+MWQcKXwD95uXBz6O/ryrVdPtg==",
"requires": {
"@sentry/hub": "6.0.0",
"@sentry/types": "6.0.0",
"tslib": "^1.9.3"
}
},
"@sentry/react": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-6.0.0.tgz",
"integrity": "sha512-GYX110NSodd8wGUbnyxemndTijM+U7dI/WjFSPOyJdLB2hzzPjJ9kUqtuobT/JlGzbWE2278WysAuySne6bUGw==",
"requires": {
"@sentry/browser": "6.0.0",
"@sentry/minimal": "6.0.0",
"@sentry/types": "6.0.0",
"@sentry/utils": "6.0.0",
"hoist-non-react-statics": "^3.3.2",
"tslib": "^1.9.3"
},
"dependencies": {
"hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"requires": {
"react-is": "^16.7.0"
}
}
}
},
"@sentry/types": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.0.0.tgz",
"integrity": "sha512-yueRSRGPCahuju/UMdtOt8LIIncbpwLINQd9Q8E4OXtoPpMHR6Oun8sMKCPd+Wq3piI5yRDzKkGCl+sH7mHVrA=="
},
"@sentry/utils": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.0.0.tgz",
"integrity": "sha512-dMMWOT69bQ4CF1R33dOnXIOyiHRWsUAON3nFVljV1JNNTDA69YwaF9f5FIT0DKpO4qhgTlElsm8WgHI9prAVEQ==",
"requires": {
"@sentry/types": "6.0.0",
"tslib": "^1.9.3"
}
},
"@sentry/webpack-plugin": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-1.14.0.tgz",
"integrity": "sha512-1cS99mnHqASYtMlHi2J107p6x3lfC5NmLOgA0iI6avaaFes8RTQMlW8YT2CyrvhtQod1bViPZOlh3NOVC8vnOA==",
"dev": true,
"requires": {
"@sentry/cli": "^1.58.0"
}
},
"@sindresorhus/fnv1a": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/fnv1a/-/fnv1a-1.2.0.tgz",
@ -18614,6 +18728,12 @@
"ipaddr.js": "1.9.0"
}
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"prr": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",

View file

@ -26,6 +26,7 @@
"@material-ui/icons": "^4.5.1",
"@material-ui/styles": "^4.5.2",
"@saleor/macaw-ui": "^0.1.1-9",
"@sentry/react": "^6.0.0",
"apollo": "^2.21.2",
"apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8",
@ -92,6 +93,7 @@
"@pollyjs/adapter-node-http": "^5.0.0",
"@pollyjs/core": "^5.0.0",
"@pollyjs/persister-fs": "^5.0.0",
"@sentry/webpack-plugin": "^1.14.0",
"@storybook/addon-storyshots": "^5.2.8",
"@storybook/react": "^5.1.9",
"@testing-library/react-hooks": "^1.1.0",
@ -226,6 +228,7 @@
"test:e2e:dev": "start-server-and-test start http://localhost:9000 cy:open",
"test": "jest src/",
"transpile-messages": "node scripts/transpile-tx.js",
"lint": "npx eslint \"src/**/*.@(tsx|ts|jsx|js)\" --fix ; npx prettier --check \"src/**/*.@(tsx|ts|jsx|js)\" --write"
"lint": "npx eslint \"src/**/*.@(tsx|ts|jsx|js)\" --fix ; npx prettier --check \"src/**/*.@(tsx|ts|jsx|js)\" --write",
"postbuild": "rimraf ./build/**/*.js.map"
}
}

View file

@ -4,6 +4,7 @@ import { User } from "@saleor/fragments/types/User";
import useNotifier from "@saleor/hooks/useNotifier";
import { commonMessages } from "@saleor/intl";
import { getMutationStatus } from "@saleor/misc";
import errorTracker from "@saleor/services/errorTracking";
import {
isSupported as isCredentialsManagementAPISupported,
login as loginWithCredentialsManagementAPI,
@ -52,7 +53,15 @@ export function useAuthProvider(
}, []);
useEffect(() => {
if (userContext && !userContext.isStaff) {
if (userContext) {
const { id, email, firstName, lastName } = userContext;
errorTracker.setUserData({
email,
id,
username: `${firstName} ${lastName}`
});
if (!userContext.isStaff) {
logout();
notify({
status: "error",
@ -60,6 +69,7 @@ export function useAuthProvider(
title: intl.formatMessage(commonMessages.insufficientPermissions)
});
}
}
}, [userContext]);
const logout = () => {

View file

@ -239,8 +239,11 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
</div>
<main className={classes.view}>
{appState.error
? appState.error === "unhandled" && (
<ErrorPage onBack={handleErrorBack} />
? appState.error.type === "unhandled" && (
<ErrorPage
id={appState.error.id}
onBack={handleErrorBack}
/>
)
: children}
</main>

View file

@ -7,6 +7,7 @@ import SVG from "react-inlinesvg";
import { FormattedMessage } from "react-intl";
export interface ErrorPageProps {
id?: string | null;
onBack: () => void;
}
@ -31,6 +32,9 @@ const useStyles = makeStyles(
margin: "0 auto",
width: 830
},
errorId: {
marginTop: theme.spacing(3)
},
innerContainer: {
[theme.breakpoints.down("sm")]: {
order: 1,
@ -58,7 +62,7 @@ const useStyles = makeStyles(
);
const ErrorPage: React.FC<ErrorPageProps> = props => {
const { onBack } = props;
const { onBack, id } = props;
const classes = useStyles(props);
@ -79,6 +83,11 @@ const ErrorPage: React.FC<ErrorPageProps> = props => {
<Typography>
<FormattedMessage defaultMessage="Don't worry, everything is gonna be fine" />
</Typography>
{!!id && (
<Typography variant="subtitle2" className={classes.errorId}>
Error ID: {id}
</Typography>
)}
</div>
<div>
<Button

View file

@ -4,16 +4,24 @@ export type AppStateReducerActionType = "displayError" | "displayLoader";
export interface AppStateReducerAction {
payload: Partial<{
error: AppError;
error: AppError["type"];
errorId: AppError["id"];
value: boolean;
}>;
type: AppStateReducerActionType;
}
function displayError(prevState: IAppState, error: AppError): IAppState {
function displayError(
prevState: IAppState,
errorType: AppError["type"],
errorId?: AppError["id"]
): IAppState {
return {
...prevState,
error,
error: {
id: errorId,
type: errorType
},
loading: false
};
}
@ -31,7 +39,11 @@ function reduceAppState(
): IAppState {
switch (action.type) {
case "displayError":
return displayError(prevState, action.payload.error);
return displayError(
prevState,
action.payload.error,
action.payload.errorId
);
case "displayLoader":
return displayLoader(prevState, action.payload.value);
default:

View file

@ -1,4 +1,7 @@
export type AppError = "unhandled";
export interface AppError {
type: "unhandled";
id: string | null | undefined;
}
interface IAppState {
error: AppError | null;

View file

@ -54,6 +54,7 @@ import PermissionGroupSection from "./permissionGroups";
import PluginsSection from "./plugins";
import ProductSection from "./products";
import ProductTypesSection from "./productTypes";
import errorTracker from "./services/errorTracking";
import ShippingSection from "./shipping";
import SiteSettingsSection from "./siteSettings";
import StaffSection from "./staff";
@ -67,6 +68,8 @@ if (process.env.GTM_ID) {
TagManager.initialize({ gtmId: GTM_ID });
}
errorTracker.init();
// DON'T TOUCH THIS
// These are separate clients and do not share configs between themselves
// so we need to explicitly set them
@ -159,14 +162,17 @@ const Routes: React.FC = () => {
{homePageLoaded ? (
<AppLayout>
<ErrorBoundary
onError={() =>
onError={e => {
const errorId = errorTracker.captureException(e);
dispatchAppState({
payload: {
errorId,
error: "unhandled"
},
type: "displayError"
})
}
});
}}
>
<Switch>
<SectionRoute exact path="/" component={HomePage} />

View file

@ -0,0 +1,33 @@
import * as Sentry from "@sentry/react";
import { TrackerMethods } from "../types";
interface Config {
dsn: string;
environment?: string;
}
export const SentryAdapter = (config: Config): TrackerMethods => {
const init: TrackerMethods["init"] = () => {
if (config?.dsn) {
Sentry.init({
dsn: config.dsn,
environment: config.environment
});
return true;
}
return false;
};
const setUserData: TrackerMethods["setUserData"] = userData =>
Sentry.setUser(userData);
const captureException: TrackerMethods["captureException"] = (e: Error) =>
Sentry.captureException(e);
return {
captureException,
init,
setUserData
};
};

View file

@ -0,0 +1 @@
export { SentryAdapter } from "./Sentry";

View file

@ -0,0 +1,11 @@
import { SentryAdapter } from "./adapters";
import { ErrorTrackerFactory } from "./trackerFactory";
const errorTracker = ErrorTrackerFactory(
SentryAdapter({
dsn: process.env.SENTRY_DSN,
environment: process.env.ENVIRONMENT
})
);
export default errorTracker;

View file

@ -0,0 +1,83 @@
import { ErrorTrackerFactory } from "./trackerFactory";
import { TrackerMethods, TrackerPermission } from "./types";
const testErrorId = "testId";
const initMockFn = jest.fn();
const captureExceptionMockFn = jest.fn(_ => testErrorId);
const setUserDataMockFn = jest.fn();
const TestAdapter = (): TrackerMethods => {
const init: TrackerMethods["init"] = () => {
initMockFn();
return true;
};
const setUserData: TrackerMethods["setUserData"] = userData =>
setUserDataMockFn(userData);
const captureException: TrackerMethods["captureException"] = (e: Error) =>
captureExceptionMockFn(e);
return {
captureException,
init,
setUserData
};
};
describe("Error Tracking", () => {
it("Initiates the tracker", () => {
const errorTracking = ErrorTrackerFactory(TestAdapter());
const enabled = errorTracking.init();
expect(enabled).toBe(true);
expect(initMockFn).toHaveBeenCalled();
});
it("Does not fire events when is not initiated", () => {
const errorTracking = ErrorTrackerFactory(TestAdapter());
const sampleError = new Error("test");
const id = errorTracking.captureException(sampleError);
expect(id).toBe(undefined);
expect(captureExceptionMockFn).toHaveBeenCalledTimes(0);
});
it("Sends a captured exception", () => {
const errorTracking = ErrorTrackerFactory(TestAdapter());
errorTracking.init();
const sampleError = new Error("test");
const id = errorTracking.captureException(sampleError);
expect(id).toBe(testErrorId);
expect(captureExceptionMockFn).toHaveBeenCalledWith(sampleError);
});
it("Does not save user data without permission", () => {
const errorTracking = ErrorTrackerFactory(TestAdapter());
errorTracking.init();
const userData = {
email: "john@example.com",
id: "id",
username: "John Doe"
};
errorTracking.setUserData(userData);
expect(setUserDataMockFn).toHaveBeenCalledTimes(0);
});
it("Does save user data with proper permission", () => {
const errorTracking = ErrorTrackerFactory(TestAdapter(), [
TrackerPermission.USER_DATA
]);
errorTracking.init();
const userData = {
email: "john@example.com",
id: "id",
username: "John Doe"
};
errorTracking.setUserData(userData);
expect(setUserDataMockFn).toHaveBeenCalledWith(userData);
});
});

View file

@ -0,0 +1,52 @@
import { TrackerMethods, TrackerPermission, UserData } from "./types";
type ErrorTrackerFactory = (
ExtensionFactory: TrackerMethods,
permissions?: TrackerPermission[]
) => TrackerMethods;
export const ErrorTrackerFactory: ErrorTrackerFactory = (
extension,
permissions = []
) => {
let ENABLED = false;
const safelyInvoke = <T extends () => any>(
fn: T,
permission?: TrackerPermission
): ReturnType<T> => {
const hasPermission =
permission !== undefined ? permissions.includes(permission) : true;
if (ENABLED && hasPermission) {
try {
return fn();
} catch (e) {
throw new Error(`Tracking Extension Error: ${e}`);
}
}
};
const init: TrackerMethods["init"] = () => {
if (!ENABLED) {
ENABLED = extension.init();
}
return ENABLED;
};
const setUserData: TrackerMethods["setUserData"] = (userData: UserData) =>
safelyInvoke(
() => extension.setUserData(userData),
TrackerPermission.USER_DATA
);
const captureException: TrackerMethods["captureException"] = (e: Error) =>
safelyInvoke(() => extension.captureException(e));
return {
captureException,
init,
setUserData
};
};

View file

@ -0,0 +1,15 @@
export type UserData = {
id: string;
email: string;
username: string;
} | null;
export interface TrackerMethods {
init: () => boolean;
setUserData: (userData: UserData) => void;
captureException: (e: Error) => string | null | undefined;
}
export enum TrackerPermission {
USER_DATA
}

View file

@ -4,6 +4,7 @@ const CheckerPlugin = require("fork-ts-checker-webpack-plugin");
const webpack = require("webpack");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const SentryWebpackPlugin = require("@sentry/webpack-plugin");
require("dotenv").config();
@ -26,7 +27,9 @@ const environmentPlugin = new webpack.EnvironmentPlugin({
API_URI: "",
APP_MOUNT_URI: "/",
DEMO_MODE: false,
GTM_ID: ""
ENVIRONMENT: "",
GTM_ID: "",
SENTRY_DSN: ""
});
const dashboardBuildPath = "build/dashboard/";
@ -60,6 +63,20 @@ module.exports = (env, argv) => {
fileLoaderPath = "file-loader?name=[name].[ext]";
}
// Create release if sentry config is set
let sentryPlugin;
if (
process.env.SENTRY_ORG &&
process.env.SENTRY_PROJECT &&
process.env.SENTRY_DSN &&
process.env.SENTRY_AUTH_TOKEN
) {
sentryPlugin = new SentryWebpackPlugin({
include: "./build/dashboard/",
urlPrefix: process.env.SENTRY_URL_PREFIX
});
}
return {
devServer: {
compress: true,
@ -68,7 +85,7 @@ module.exports = (env, argv) => {
hot: true,
port: 9000
},
devtool: "sourceMap",
devtool: "source-map",
entry: {
dashboard: "./src/index.tsx"
},
@ -100,7 +117,12 @@ module.exports = (env, argv) => {
splitChunks: false
},
output,
plugins: [checkerPlugin, environmentPlugin, htmlWebpackPlugin],
plugins: [
checkerPlugin,
environmentPlugin,
htmlWebpackPlugin,
sentryPlugin
].filter(Boolean),
resolve: {
extensions: [".js", ".jsx", ".ts", ".tsx"],
plugins: [pathsPlugin]