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.