diff --git a/web/vtadmin/package-lock.json b/web/vtadmin/package-lock.json
index 61dd23b7fe1..f9a1043d375 100644
--- a/web/vtadmin/package-lock.json
+++ b/web/vtadmin/package-lock.json
@@ -1137,6 +1137,58 @@
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="
},
+ "@bugsnag/browser": {
+ "version": "7.10.1",
+ "resolved": "https://registry.npmjs.org/@bugsnag/browser/-/browser-7.10.1.tgz",
+ "integrity": "sha512-Yxm/DheT/NHX2PhadBDuafuHBhP547Iav6Y9jf+skBBSi1n0ZYkGhtVxh8ZWLgqz5W8MsJ0HFiLBqcg/mulSvQ==",
+ "requires": {
+ "@bugsnag/core": "^7.10.0"
+ }
+ },
+ "@bugsnag/core": {
+ "version": "7.10.0",
+ "resolved": "https://registry.npmjs.org/@bugsnag/core/-/core-7.10.0.tgz",
+ "integrity": "sha512-sDa2nDxwsxHQx2/2/tsBWjYqH0TewCR8N/r5at6B+irwVkI0uts7Qc2JyqDTfiEiBXKVEXFK+fHTz1x9b8tsiA==",
+ "requires": {
+ "@bugsnag/cuid": "^3.0.0",
+ "@bugsnag/safe-json-stringify": "^6.0.0",
+ "error-stack-parser": "^2.0.3",
+ "iserror": "0.0.2",
+ "stack-generator": "^2.0.3"
+ }
+ },
+ "@bugsnag/cuid": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@bugsnag/cuid/-/cuid-3.0.0.tgz",
+ "integrity": "sha512-LOt8aaBI+KvOQGneBtpuCz3YqzyEAehd1f3nC5yr9TIYW1+IzYKa2xWS4EiMz5pPOnRPHkyyS5t/wmSmN51Gjg=="
+ },
+ "@bugsnag/js": {
+ "version": "7.10.1",
+ "resolved": "https://registry.npmjs.org/@bugsnag/js/-/js-7.10.1.tgz",
+ "integrity": "sha512-1/MK/Bw2ViFx1hMG2TOX8MOq/LzT2VRd0VswknF4LYsZSgzohkRzz/hi6P2TSlLeapRs+bkDC6u2RCq4zYvyiA==",
+ "requires": {
+ "@bugsnag/browser": "^7.10.1",
+ "@bugsnag/node": "^7.10.1"
+ }
+ },
+ "@bugsnag/node": {
+ "version": "7.10.1",
+ "resolved": "https://registry.npmjs.org/@bugsnag/node/-/node-7.10.1.tgz",
+ "integrity": "sha512-kpasrz/im5ljptt2JOqrjbOu4b0i5sAZOYU4L0psWXlD31/wXytk7im11QlNALdI8gZZBxIFsVo8ks6dR6mHzg==",
+ "requires": {
+ "@bugsnag/core": "^7.10.0",
+ "byline": "^5.0.0",
+ "error-stack-parser": "^2.0.2",
+ "iserror": "^0.0.2",
+ "pump": "^3.0.0",
+ "stack-generator": "^2.0.3"
+ }
+ },
+ "@bugsnag/safe-json-stringify": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@bugsnag/safe-json-stringify/-/safe-json-stringify-6.0.0.tgz",
+ "integrity": "sha512-htzFO1Zc57S8kgdRK9mLcPVTW1BY2ijfH7Dk2CeZmspTWKdKqSo1iwmqrq2WtRjFlo8aRZYgLX0wFrDXF/9DLA=="
+ },
"@cnakazawa/watch": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz",
@@ -4047,6 +4099,11 @@
"resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz",
"integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug="
},
+ "byline": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz",
+ "integrity": "sha1-dBxSFkaOrcRXsDQQEYrXfejB3bE="
+ },
"bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
@@ -8517,6 +8574,11 @@
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
},
+ "iserror": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/iserror/-/iserror-0.0.2.tgz",
+ "integrity": "sha1-vVNFH+L2aLnyQCwZZnh6qix8C/U="
+ },
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -15754,6 +15816,14 @@
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
"integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w=="
},
+ "stack-generator": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.5.tgz",
+ "integrity": "sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q==",
+ "requires": {
+ "stackframe": "^1.1.1"
+ }
+ },
"stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.3.tgz",
diff --git a/web/vtadmin/package.json b/web/vtadmin/package.json
index 3679c2f3fbd..255f5ca8a93 100644
--- a/web/vtadmin/package.json
+++ b/web/vtadmin/package.json
@@ -7,6 +7,7 @@
"npm": ">=6.14.9"
},
"dependencies": {
+ "@bugsnag/js": "^7.10.1",
"@testing-library/user-event": "^12.6.0",
"@types/classnames": "^2.2.11",
"@types/jest": "^26.0.19",
diff --git a/web/vtadmin/src/api/http.test.ts b/web/vtadmin/src/api/http.test.ts
index af46c15b26c..b5ca9fba402 100644
--- a/web/vtadmin/src/api/http.test.ts
+++ b/web/vtadmin/src/api/http.test.ts
@@ -17,8 +17,10 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import * as api from './http';
-import { vtadmin as pb } from '../proto/vtadmin';
-import { HTTP_RESPONSE_NOT_OK_ERROR, MALFORMED_HTTP_RESPONSE_ERROR } from './http';
+import { HTTP_RESPONSE_NOT_OK_ERROR, MALFORMED_HTTP_RESPONSE_ERROR } from '../errors/errorTypes';
+import * as errorHandler from '../errors/errorHandler';
+
+jest.mock('../errors/errorHandler');
// This test suite uses Mock Service Workers (https://github.com/mswjs/msw)
// to mock HTTP responses from vtadmin-api.
@@ -56,7 +58,9 @@ const TEST_PROCESS_ENV = {
};
beforeAll(() => {
- process.env = { ...TEST_PROCESS_ENV };
+ // TypeScript can get a little cranky with the automatic
+ // string/boolean type conversions, hence this cast.
+ process.env = { ...TEST_PROCESS_ENV } as NodeJS.ProcessEnv;
// Enable API mocking before tests.
server.listen();
@@ -64,7 +68,7 @@ beforeAll(() => {
afterEach(() => {
// Reset the process.env to clear out any changes made in the tests.
- process.env = { ...TEST_PROCESS_ENV };
+ process.env = { ...TEST_PROCESS_ENV } as NodeJS.ProcessEnv;
jest.restoreAllMocks();
@@ -92,34 +96,52 @@ describe('api/http', () => {
it('throws an error if response.ok is false', async () => {
const endpoint = `/api/tablets`;
- const response = { ok: false };
- mockServerJson(endpoint, response);
+ const response = {
+ ok: false,
+ error: {
+ code: 'oh_no',
+ message: 'something went wrong',
+ },
+ };
- expect.assertions(3);
+ // See https://mswjs.io/docs/recipes/mocking-error-responses
+ server.use(rest.get(endpoint, (req, res, ctx) => res(ctx.status(500), ctx.json(response))));
+
+ expect.assertions(5);
try {
await api.fetchTablets();
} catch (e) {
/* eslint-disable jest/no-conditional-expect */
expect(e.name).toEqual(HTTP_RESPONSE_NOT_OK_ERROR);
- expect(e.message).toEqual(endpoint);
+ expect(e.message).toEqual('[status 500] /api/tablets: oh_no something went wrong');
expect(e.response).toEqual(response);
+
+ expect(errorHandler.notify).toHaveBeenCalledTimes(1);
+ expect(errorHandler.notify).toHaveBeenCalledWith(e);
/* eslint-enable jest/no-conditional-expect */
}
});
it('throws an error on malformed JSON', async () => {
const endpoint = `/api/tablets`;
- server.use(rest.get(endpoint, (req, res, ctx) => res(ctx.body('this is fine'))));
+ server.use(
+ rest.get(endpoint, (req, res, ctx) =>
+ res(ctx.status(504), ctx.body('
504 Gateway Time-out'))
+ )
+ );
- expect.assertions(2);
+ expect.assertions(4);
try {
await api.vtfetch(endpoint);
} catch (e) {
/* eslint-disable jest/no-conditional-expect */
- expect(e.name).toEqual('SyntaxError');
- expect(e.message.startsWith('Unexpected token')).toBeTruthy();
+ expect(e.name).toEqual(MALFORMED_HTTP_RESPONSE_ERROR);
+ expect(e.message).toEqual('[status 504] /api/tablets: Unexpected token < in JSON at position 0');
+
+ expect(errorHandler.notify).toHaveBeenCalledTimes(1);
+ expect(errorHandler.notify).toHaveBeenCalledWith(e);
/* eslint-enable jest/no-conditional-expect */
}
});
@@ -151,7 +173,9 @@ describe('api/http', () => {
await api.vtfetch(endpoint);
expect(global.fetch).toHaveBeenCalledTimes(1);
- expect(global.fetch).toHaveBeenCalledWith(endpoint, { credentials: 'include' });
+ expect(global.fetch).toHaveBeenCalledWith(endpoint, {
+ credentials: 'include',
+ });
jest.restoreAllMocks();
});
@@ -165,7 +189,9 @@ describe('api/http', () => {
await api.vtfetch(endpoint);
expect(global.fetch).toHaveBeenCalledTimes(1);
- expect(global.fetch).toHaveBeenCalledWith(endpoint, { credentials: undefined });
+ expect(global.fetch).toHaveBeenCalledWith(endpoint, {
+ credentials: undefined,
+ });
jest.restoreAllMocks();
});
@@ -187,6 +213,9 @@ describe('api/http', () => {
'Invalid fetch credentials property: nope. Must be undefined or one of omit, same-origin, include'
);
expect(global.fetch).toHaveBeenCalledTimes(0);
+
+ expect(errorHandler.notify).toHaveBeenCalledTimes(1);
+ expect(errorHandler.notify).toHaveBeenCalledWith(e);
/* eslint-enable jest/no-conditional-expect */
}
@@ -200,7 +229,7 @@ describe('api/http', () => {
const endpoint = '/api/foos';
mockServerJson(endpoint, { ok: true, result: { foos: null } });
- expect.assertions(1);
+ expect.assertions(3);
try {
await api.vtfetchEntities({
@@ -211,6 +240,9 @@ describe('api/http', () => {
} catch (e) {
/* eslint-disable jest/no-conditional-expect */
expect(e.message).toMatch('expected entities to be an array, got null');
+
+ expect(errorHandler.notify).toHaveBeenCalledTimes(1);
+ expect(errorHandler.notify).toHaveBeenCalledWith(e);
/* eslint-enable jest/no-conditional-expect */
}
});
diff --git a/web/vtadmin/src/api/http.ts b/web/vtadmin/src/api/http.ts
index 8c2f0c71986..61c8c62c0e4 100644
--- a/web/vtadmin/src/api/http.ts
+++ b/web/vtadmin/src/api/http.ts
@@ -15,68 +15,66 @@
*/
import { vtadmin as pb } from '../proto/vtadmin';
+import * as errorHandler from '../errors/errorHandler';
+import { HttpFetchError, HttpResponseNotOkError, MalformedHttpResponseError } from '../errors/errorTypes';
+import { HttpOkResponse } from './responseTypes';
-interface HttpOkResponse {
- ok: true;
- result: any;
-}
-
-interface HttpErrorResponse {
- ok: false;
-}
-
-export const MALFORMED_HTTP_RESPONSE_ERROR = 'MalformedHttpResponseError';
-
-// MalformedHttpResponseError is thrown when the JSON response envelope
-// is an unexpected shape.
-class MalformedHttpResponseError extends Error {
- responseJson: object;
-
- constructor(message: string, responseJson: object) {
- super(message);
- this.name = MALFORMED_HTTP_RESPONSE_ERROR;
- this.responseJson = responseJson;
- }
-}
-
-export const HTTP_RESPONSE_NOT_OK_ERROR = 'HttpResponseNotOkError';
-
-// HttpResponseNotOkError is throw when the `ok` is false in
-// the JSON response envelope.
-class HttpResponseNotOkError extends Error {
- response: HttpErrorResponse | null;
-
- constructor(endpoint: string, response: HttpErrorResponse) {
- super(endpoint);
- this.name = HTTP_RESPONSE_NOT_OK_ERROR;
- this.response = response;
- }
-}
-
-// vtfetch makes HTTP requests against the given vtadmin-api endpoint
-// and returns the parsed response.
-//
-// HttpResponse envelope types are not defined in vtadmin.proto (nor should they be)
-// thus we have to validate the shape of the API response with more care.
-//
-// Note that this only validates the HttpResponse envelope; it does not
-// do any type checking or validation on the result.
+/**
+ * vtfetch makes HTTP requests against the given vtadmin-api endpoint
+ * and returns the parsed response.
+ *
+ * HttpResponse envelope types are not defined in vtadmin.proto (nor should they be)
+ * thus we have to validate the shape of the API response with more care.
+ *
+ * Note that this only validates the HttpResponse envelope; it does not
+ * do any type checking or validation on the result.
+ */
export const vtfetch = async (endpoint: string): Promise => {
- const { REACT_APP_VTADMIN_API_ADDRESS } = process.env;
-
- const url = `${REACT_APP_VTADMIN_API_ADDRESS}${endpoint}`;
- const opts = vtfetchOpts();
-
- const response = await global.fetch(url, opts);
-
- const json = await response.json();
- if (!('ok' in json)) throw new MalformedHttpResponseError('invalid http envelope', json);
-
- // Throw "not ok" responses so that react-query correctly interprets them as errors.
- // See https://react-query.tanstack.com/guides/query-functions#handling-and-throwing-errors
- if (!json.ok) throw new HttpResponseNotOkError(endpoint, json);
-
- return json as HttpOkResponse;
+ try {
+ const { REACT_APP_VTADMIN_API_ADDRESS } = process.env;
+
+ const url = `${REACT_APP_VTADMIN_API_ADDRESS}${endpoint}`;
+ const opts = vtfetchOpts();
+
+ let response = null;
+ try {
+ response = await global.fetch(url, opts);
+ } catch (error) {
+ // Capture fetch() promise rejections and rethrow as HttpFetchError.
+ // fetch() promises will reject with a TypeError when a network error is
+ // encountered or CORS is misconfigured, in which case the request never
+ // makes it to the server.
+ // See https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#checking_that_the_fetch_was_successful
+ throw new HttpFetchError(url);
+ }
+
+ let json = null;
+ try {
+ json = await response.json();
+ } catch (error) {
+ throw new MalformedHttpResponseError(error.message, endpoint, json, response);
+ }
+
+ if (!('ok' in json)) {
+ throw new MalformedHttpResponseError('invalid HTTP envelope', endpoint, json, response);
+ }
+
+ if (!json.ok) {
+ throw new HttpResponseNotOkError(endpoint, json, response);
+ }
+
+ return json as HttpOkResponse;
+ } catch (error) {
+ // Most commonly, react-query is the downstream consumer of
+ // errors thrown in vtfetch. Because react-query "handles" errors
+ // by propagating them to components (as it should!), any errors thrown
+ // from vtfetch are _not_ automatically logged as "unhandled errors".
+ // Instead, we catch errors and manually notify our error handling serivce(s),
+ // and then rethrow the error for react-query to propagate the usual way.
+ // See https://react-query.tanstack.com/guides/query-functions#handling-and-throwing-errors
+ errorHandler.notify(error);
+ throw error;
+ }
};
export const vtfetchOpts = (): RequestInit => {
@@ -106,7 +104,12 @@ export const vtfetchEntities = async (opts: {
const entities = opts.extract(res);
if (!Array.isArray(entities)) {
- throw Error(`expected entities to be an array, got ${entities}`);
+ // Since react-query is the downstream consumer of vtfetch + vtfetchEntities,
+ // errors thrown in either function will be "handled" and will not automatically
+ // propagate as "unhandled" errors, meaning we have to log them manually.
+ const error = Error(`expected entities to be an array, got ${entities}`);
+ errorHandler.notify(error);
+ throw error;
}
return entities.map(opts.transform);
diff --git a/web/vtadmin/src/api/responseTypes.ts b/web/vtadmin/src/api/responseTypes.ts
new file mode 100644
index 00000000000..32be2d2bb50
--- /dev/null
+++ b/web/vtadmin/src/api/responseTypes.ts
@@ -0,0 +1,28 @@
+/**
+ * Copyright 2021 The Vitess Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface HttpOkResponse {
+ ok: true;
+ result: any;
+}
+
+export interface HttpErrorResponse {
+ error?: {
+ message?: string;
+ code?: string;
+ };
+ ok: false;
+}
diff --git a/web/vtadmin/src/errors/bugsnag.ts b/web/vtadmin/src/errors/bugsnag.ts
new file mode 100644
index 00000000000..4a985dee3f1
--- /dev/null
+++ b/web/vtadmin/src/errors/bugsnag.ts
@@ -0,0 +1,49 @@
+/**
+ * Copyright 2021 The Vitess Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import BugsnagJS from '@bugsnag/js';
+import { ErrorHandler } from './errorTypes';
+
+const { REACT_APP_BUGSNAG_API_KEY } = process.env;
+
+/**
+ * If using Bugsnag, Bugsnag.start() will automatically capture and report
+ * unhandled exceptions and unhandled promise rejections, as well as
+ * initialize it for capturing handled errors.
+ */
+export const initialize = () => {
+ if (typeof REACT_APP_BUGSNAG_API_KEY === 'string') {
+ BugsnagJS.start(REACT_APP_BUGSNAG_API_KEY);
+ }
+};
+
+export const isEnabled = () => typeof REACT_APP_BUGSNAG_API_KEY === 'string';
+
+export const notify = (error: Error, env: object, metadata?: object) => {
+ // See https://docs.bugsnag.com/platforms/javascript/reporting-handled-errors/
+ BugsnagJS.notify(error, (event) => {
+ event.addMetadata('env', env);
+
+ if (!!metadata) {
+ event.addMetadata('metadata', metadata);
+ }
+ });
+};
+
+export const Bugsnag: ErrorHandler = {
+ initialize,
+ isEnabled,
+ notify,
+};
diff --git a/web/vtadmin/src/errors/errorHandler.test.ts b/web/vtadmin/src/errors/errorHandler.test.ts
new file mode 100644
index 00000000000..929daae20f4
--- /dev/null
+++ b/web/vtadmin/src/errors/errorHandler.test.ts
@@ -0,0 +1,126 @@
+/**
+ * Copyright 2021 The Vitess Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ErrorHandler, HttpResponseNotOkError } from './errorTypes';
+import * as errorHandler from './errorHandler';
+import * as errorHandlers from './errorHandlers';
+
+// Since vtadmin uses process.env variables quite a bit, we need to
+// do a bit of a dance to clear them out between test runs.
+const ORIGINAL_PROCESS_ENV = process.env;
+const TEST_PROCESS_ENV = {
+ ...process.env,
+ REACT_APP_VTADMIN_API_ADDRESS: '',
+};
+
+beforeAll(() => {
+ // TypeScript can get a little cranky with the automatic
+ // string/boolean type conversions, hence this cast.
+ process.env = { ...TEST_PROCESS_ENV } as NodeJS.ProcessEnv;
+});
+
+afterEach(() => {
+ // Reset the process.env to clear out any changes made in the tests.
+ process.env = { ...TEST_PROCESS_ENV } as NodeJS.ProcessEnv;
+
+ jest.restoreAllMocks();
+});
+
+afterAll(() => {
+ process.env = { ...ORIGINAL_PROCESS_ENV };
+});
+
+describe('errorHandler', () => {
+ let mockErrorHandler: ErrorHandler;
+ let mockEnv: NodeJS.ProcessEnv;
+
+ beforeEach(() => {
+ mockErrorHandler = {
+ initialize: jest.fn(),
+ isEnabled: () => true,
+ notify: jest.fn(),
+ };
+
+ jest.spyOn(errorHandlers, 'getHandlers').mockReturnValue([mockErrorHandler]);
+
+ mockEnv = {
+ REACT_APP_VTADMIN_API_ADDRESS: 'http://example.com',
+ } as NodeJS.ProcessEnv;
+ process.env = mockEnv;
+ });
+
+ describe('initialize', () => {
+ it('initializes enabled handlers', () => {
+ errorHandler.initialize();
+ expect(mockErrorHandler.initialize).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('notify', () => {
+ it('notifies enabled ErrorHandlers', () => {
+ const err = new Error('testing');
+ errorHandler.notify(err);
+
+ expect(mockErrorHandler.notify).toHaveBeenCalledTimes(1);
+ expect(mockErrorHandler.notify).toHaveBeenCalledWith(err, mockEnv, {
+ errorMetadata: {},
+ });
+ });
+
+ it("appends metadata from the Error's instance properties", () => {
+ const response = new Response('', { status: 500 });
+ const err = new HttpResponseNotOkError('/api/test', { ok: false }, response);
+ errorHandler.notify(err, { goodbye: 'moon' });
+
+ expect(mockErrorHandler.notify).toHaveBeenCalledTimes(1);
+ expect(mockErrorHandler.notify).toHaveBeenCalledWith(err, mockEnv, {
+ errorMetadata: {
+ fetchResponse: {
+ ok: false,
+ status: 500,
+ statusText: '',
+ type: 'default',
+ url: '',
+ },
+ name: 'HttpResponseNotOkError',
+ response: { ok: false },
+ },
+ goodbye: 'moon',
+ });
+ });
+
+ it('only includes santizied environment variables', () => {
+ process.env = {
+ REACT_APP_VTADMIN_API_ADDRESS: 'http://not-secret.example.com',
+ REACT_APP_BUGSNAG_API_KEY: 'secret',
+ } as NodeJS.ProcessEnv;
+
+ const err = new Error('testing');
+ errorHandler.notify(err);
+
+ expect(mockErrorHandler.notify).toHaveBeenCalledTimes(1);
+ expect(mockErrorHandler.notify).toHaveBeenCalledWith(
+ err,
+ {
+ REACT_APP_VTADMIN_API_ADDRESS: 'http://not-secret.example.com',
+ },
+ {
+ errorMetadata: {},
+ }
+ );
+ });
+ });
+});
diff --git a/web/vtadmin/src/errors/errorHandler.ts b/web/vtadmin/src/errors/errorHandler.ts
new file mode 100644
index 00000000000..abc4faa71d1
--- /dev/null
+++ b/web/vtadmin/src/errors/errorHandler.ts
@@ -0,0 +1,72 @@
+/**
+ * Copyright 2021 The Vitess Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { pick } from 'lodash';
+import { getHandlers } from './errorHandlers';
+
+/**
+ * Initializes error handling for both unhandled and handled exceptions.
+ * This should be called as early as possible.
+ */
+export const initialize = () => {
+ getHandlers().forEach((h) => h.initialize());
+};
+
+/**
+ * Manually notify error handlers of an error. Also known as
+ * a "handled error".
+ *
+ * @param error - The Error that was thrown/captured.
+ * @param metadata - Additional key/value metadata to log. Note that
+ * additional metadata from `error` will be added to the metadata
+ * object under the key "errorMetadata" before it is passed along
+ * to the active ErrorHandler clients(s).
+ */
+export const notify = (error: Error, metadata?: object) => {
+ const env = sanitizeEnv();
+ const errorMetadata = Object.getOwnPropertyNames(error).reduce((acc, propertyName) => {
+ // Only annotate the `metadata` object with properties beyond the standard instance
+ // properties. (Bugsnag, for example, does not log additional `Error` properties:
+ // they have to be logged as additional metadata.)
+ if (propertyName !== 'stack' && propertyName !== 'message') {
+ acc[propertyName] = (error as any)[propertyName];
+ }
+
+ return acc;
+ }, {} as { [k: string]: any });
+
+ getHandlers().forEach((h) =>
+ h.notify(error, env, {
+ errorMetadata,
+ ...metadata,
+ })
+ );
+};
+
+/**
+ * sanitizeEnv serializes process.env into an object that's sent to
+ * configured error handlers, for extra debugging context.
+ * Implemented as an allow list, rather than as a block list, to avoid
+ * leaking sensitive environment variables, like API keys.
+ */
+const sanitizeEnv = () =>
+ pick(process.env, [
+ 'REACT_APP_BUILD_BRANCH',
+ 'REACT_APP_BUILD_SHA',
+ 'REACT_APP_ENABLE_EXPERIMENTAL_TABLET_DEBUG_VARS',
+ 'REACT_APP_FETCH_CREDENTIALS',
+ 'REACT_APP_VTADMIN_API_ADDRESS',
+ ]);
diff --git a/web/vtadmin/src/errors/errorHandlers.ts b/web/vtadmin/src/errors/errorHandlers.ts
new file mode 100644
index 00000000000..9bf74259a92
--- /dev/null
+++ b/web/vtadmin/src/errors/errorHandlers.ts
@@ -0,0 +1,24 @@
+/**
+ * Copyright 2021 The Vitess Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as Bugsnag from './bugsnag';
+import { ErrorHandler } from './errorTypes';
+
+/**
+ * getHandlers returns a list of enabled error handlers.
+ * This is implemented in its own file to make testing easier.
+ */
+export const getHandlers = (): ErrorHandler[] => [Bugsnag].filter((h) => h.isEnabled());
diff --git a/web/vtadmin/src/errors/errorTypes.ts b/web/vtadmin/src/errors/errorTypes.ts
new file mode 100644
index 00000000000..dd207cde235
--- /dev/null
+++ b/web/vtadmin/src/errors/errorTypes.ts
@@ -0,0 +1,131 @@
+/**
+ * Copyright 2021 The Vitess Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { HttpErrorResponse } from '../api/responseTypes';
+
+/**
+ * ErrorHandler defines a common interface for bindings to
+ * client-side monitoring services.
+ *
+ * Similar to Vitess itself [1], vtadmin-web can integrate with a variety
+ * of client-side monitoring tools. At the time of this writing, only
+ * Bugsnag integration is added. This interface, however, allows flexibility
+ * to implement other monitoring serivces, like Sentry, Datadog, etc.
+ *
+ * [1] https://vitess.io/docs/user-guides/configuration-basic/monitoring/
+ */
+export interface ErrorHandler {
+ /**
+ * Handler to initialize the monitoring client. Called at the very
+ * beginning of the app life cycle. If a particular client supports
+ * capturing unhandled exceptions (as most do) that initialization
+ * logic should happen here.
+ */
+ initialize: () => void;
+
+ /**
+ * Handler to determine whether the monitoring client is enabled.
+ */
+ isEnabled: () => boolean;
+
+ /**
+ * Handler to manually notify the monitoring system of a problem.
+ *
+ * @param error - The Error that was thrown
+ * @param env - Sanitized process.env environment variables
+ * @param metadata - Additional, arbitrary metadata.
+ */
+ notify: (error: Error, env: object, metadata?: object) => void;
+}
+
+interface SerializedFetchResponse {
+ ok: boolean;
+ status: number;
+ statusText: string;
+ type: string;
+ url: string;
+}
+
+/**
+ * serializeFetchResponse serializes a Response object into
+ * a simplified JSON object. This is particularly useful when
+ * logging and/or sending errors to monitoring clients, as
+ * a full Response object JSON.stringifies to "{}"."
+ */
+export const serializeFetchResponse = (fetchResponse: Response) => ({
+ ok: fetchResponse.ok,
+ status: fetchResponse.status,
+ statusText: fetchResponse.statusText,
+ type: fetchResponse.type,
+ url: fetchResponse.url,
+});
+
+export const MALFORMED_HTTP_RESPONSE_ERROR = 'MalformedHttpResponseError';
+
+/**
+ * MalformedHttpResponseError is thrown when the JSON response envelope
+ * is an unexpected shape.
+ */
+export class MalformedHttpResponseError extends Error {
+ fetchResponse: SerializedFetchResponse;
+ responseJson: object;
+
+ constructor(message: string, endpoint: string, responseJson: object, fetchResponse: Response) {
+ const key = `[status ${fetchResponse.status}] ${endpoint}: ${message}`;
+ super(key);
+
+ this.name = MALFORMED_HTTP_RESPONSE_ERROR;
+ this.responseJson = responseJson;
+ this.fetchResponse = serializeFetchResponse(fetchResponse);
+ }
+}
+
+export const HTTP_RESPONSE_NOT_OK_ERROR = 'HttpResponseNotOkError';
+
+/**
+ * HttpResponseNotOkError is throw when the `ok` is false in
+ * the JSON response envelope.
+ *
+ * See https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#checking_that_the_fetch_was_successful
+ */
+export class HttpResponseNotOkError extends Error {
+ fetchResponse: SerializedFetchResponse;
+ response: HttpErrorResponse | null;
+
+ constructor(endpoint: string, response: HttpErrorResponse, fetchResponse: Response) {
+ const key = `[status ${fetchResponse.status}] ${endpoint}: ${response.error?.code} ${response.error?.message}`;
+ super(key);
+
+ this.name = HTTP_RESPONSE_NOT_OK_ERROR;
+ this.response = response;
+ this.fetchResponse = serializeFetchResponse(fetchResponse);
+ }
+}
+
+export const HTTP_FETCH_ERROR = 'HttpFetchError';
+
+/**
+ * HttpFetchError is thrown when fetch() promises reject with a TypeError when a network error is
+ * encountered or CORS is misconfigured.
+ *
+ * See https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#checking_that_the_fetch_was_successful
+ */
+export class HttpFetchError extends Error {
+ constructor(endpoint: string) {
+ super(endpoint);
+ this.name = HTTP_FETCH_ERROR;
+ }
+}
diff --git a/web/vtadmin/src/index.tsx b/web/vtadmin/src/index.tsx
index 9b7d9eae8d8..352ee9faa60 100644
--- a/web/vtadmin/src/index.tsx
+++ b/web/vtadmin/src/index.tsx
@@ -21,6 +21,9 @@ import './index.css';
import './components/charts/charts.scss';
import { App } from './components/App';
+import * as errorHandler from './errors/errorHandler';
+
+errorHandler.initialize();
const queryClient = new QueryClient();
diff --git a/web/vtadmin/src/react-app-env.d.ts b/web/vtadmin/src/react-app-env.d.ts
index 4b74c3ea542..179e1d3e0eb 100644
--- a/web/vtadmin/src/react-app-env.d.ts
+++ b/web/vtadmin/src/react-app-env.d.ts
@@ -4,13 +4,26 @@ declare namespace NodeJS {
NODE_ENV: 'development' | 'production' | 'test';
PUBLIC_URL: string;
+ /* REQUIRED */
+
// Required. The full address of vtadmin-api's HTTP interface.
// Example: "http://127.0.0.1:12345"
REACT_APP_VTADMIN_API_ADDRESS: string;
+ /* OPTIONAL */
+
+ // Optional. An API key for https://bugsnag.com. If defined,
+ // the @bugsnag/js client will be initialized. Your Bugsnag API key
+ // can be found in your Bugsnag Project Settings.
+ REACT_APP_BUGSNAG_API_KEY?: string;
+
+ // Optional. Build variables.
+ REACT_APP_BUILD_BRANCH?: string;
+ REACT_APP_BUILD_SHA?: string;
+
// Optional, but recommended. When true, enables front-end components that query
// vtadmin-api's /api/experimental/tablet/{tablet}/debug/vars endpoint.
- REACT_APP_ENABLE_EXPERIMENTAL_TABLET_DEBUG_VARS: boolean;
+ REACT_APP_ENABLE_EXPERIMENTAL_TABLET_DEBUG_VARS?: boolean | string;
// Optional. Configures the `credentials` property for fetch requests.
// made against vtadmin-api. If unspecified, uses fetch defaults.