mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[React Error Boundary] Integrate APM error capture (#209006)
## Summary Addresses: https://github.com/elastic/observability-dev/issues/4222 The intent of this PR is to improve the kind of telemetry/metrics that are captured when there is a fatal error in rendering a React component in Kibana. ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### Identify risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] Risk of re-visiting this work if we need to add more instrumentation (APM spans, etc)
This commit is contained in:
parent
4d3cf33ffd
commit
95eea95a5f
10 changed files with 116 additions and 11 deletions
|
@ -24,6 +24,7 @@ SRCS = glob(
|
|||
BUNDLER_DEPS = [
|
||||
"@npm//react",
|
||||
"@npm//tslib",
|
||||
"@npm//@elastic/apm-rum",
|
||||
]
|
||||
|
||||
js_library(
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { mutateError } from './mutate_error';
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { REACT_FATAL_ERROR_EVENT_TYPE } from './telemetry_events';
|
||||
|
||||
/**
|
||||
* Adds ability to use APM to filter for errors caught by this error boundary.
|
||||
* The Error is mutated rather than copied, to keep the original prototype so that it can be captured in APM without side effects.
|
||||
*/
|
||||
export function mutateError(error: Error) {
|
||||
const customError: Error & { react_error_type?: string; original_name?: string } = error;
|
||||
customError.react_error_type = REACT_FATAL_ERROR_EVENT_TYPE;
|
||||
customError.original_name = error.name;
|
||||
customError.name = 'FatalReactError';
|
||||
return customError;
|
||||
}
|
|
@ -37,7 +37,7 @@ describe('<KibanaErrorBoundaryProvider>', () => {
|
|||
expect(reportEventSpy).toBeCalledWith('fatal-error-react', {
|
||||
component_name: 'BadComponent',
|
||||
component_stack: expect.any(String),
|
||||
error_message: 'Error: This is an error to show the test user!',
|
||||
error_message: 'FatalReactError: This is an error to show the test user!',
|
||||
error_stack: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
@ -66,7 +66,7 @@ describe('<KibanaErrorBoundaryProvider>', () => {
|
|||
expect(reportEventSpy1).toBeCalledWith('fatal-error-react', {
|
||||
component_name: 'BadComponent',
|
||||
component_stack: expect.any(String),
|
||||
error_message: 'Error: This is an error to show the test user!',
|
||||
error_message: 'FatalReactError: This is an error to show the test user!',
|
||||
error_stack: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
|
|
@ -42,7 +42,9 @@ export class KibanaErrorService {
|
|||
* or treated with "danger" coloring and include a detailed error message.
|
||||
*/
|
||||
private getIsFatal(error: Error) {
|
||||
const isChunkLoadError = MATCH_CHUNK_LOADERROR.test(error.name);
|
||||
const customError: Error & { react_error_type?: string; original_name?: string } = error;
|
||||
const errorName = customError.original_name ?? customError.name;
|
||||
const isChunkLoadError = MATCH_CHUNK_LOADERROR.test(errorName);
|
||||
return !isChunkLoadError; // "ChunkLoadError" is recoverable by refreshing the page
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { useErrorBoundary } from './error_boundary_services';
|
|
@ -9,19 +9,23 @@
|
|||
|
||||
import { render } from '@testing-library/react';
|
||||
import React, { FC, PropsWithChildren } from 'react';
|
||||
import { apm } from '@elastic/apm-rum';
|
||||
|
||||
import { KibanaErrorBoundary } from '../..';
|
||||
import { BadComponent, ChunkLoadErrorComponent, getServicesMock } from '../../mocks';
|
||||
import { KibanaErrorBoundaryServices } from '../../types';
|
||||
import { KibanaErrorBoundaryDepsProvider } from '../services/error_boundary_services';
|
||||
import { KibanaErrorService } from '../services/error_service';
|
||||
import { KibanaErrorBoundary } from './error_boundary';
|
||||
import { errorMessageStrings as strings } from './message_strings';
|
||||
|
||||
jest.mock('@elastic/apm-rum');
|
||||
|
||||
describe('<KibanaErrorBoundary>', () => {
|
||||
let services: KibanaErrorBoundaryServices;
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
services = getServicesMock();
|
||||
(apm.captureError as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
const Template: FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||
|
@ -92,7 +96,7 @@ describe('<KibanaErrorBoundary>', () => {
|
|||
expect(mockDeps.analytics.reportEvent.mock.calls[0][0]).toBe('fatal-error-react');
|
||||
expect(mockDeps.analytics.reportEvent.mock.calls[0][1]).toMatchObject({
|
||||
component_name: 'BadComponent',
|
||||
error_message: 'Error: This is an error to show the test user!',
|
||||
error_message: 'FatalReactError: This is an error to show the test user!',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -118,4 +122,26 @@ describe('<KibanaErrorBoundary>', () => {
|
|||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('integrates with apm to capture the error', async () => {
|
||||
const { findByTestId } = render(
|
||||
<Template>
|
||||
<BadComponent />
|
||||
</Template>
|
||||
);
|
||||
(await findByTestId('clickForErrorBtn')).click();
|
||||
|
||||
expect(apm.captureError).toHaveBeenCalledTimes(1);
|
||||
expect(apm.captureError).toHaveBeenCalledWith(
|
||||
new Error('This is an error to show the test user!')
|
||||
);
|
||||
expect(Object.keys((apm.captureError as jest.Mock).mock.calls[0][0])).toEqual([
|
||||
'react_error_type',
|
||||
'original_name',
|
||||
'name',
|
||||
]);
|
||||
expect((apm.captureError as jest.Mock).mock.calls[0][0].react_error_type).toEqual(
|
||||
'fatal-error-react'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,10 +7,12 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { apm } from '@elastic/apm-rum';
|
||||
import React from 'react';
|
||||
|
||||
import { mutateError } from '../../lib';
|
||||
import type { KibanaErrorBoundaryServices } from '../../types';
|
||||
import { useErrorBoundary } from '../services/error_boundary_services';
|
||||
import { useErrorBoundary } from '../services';
|
||||
import { FatalPrompt, RecoverablePrompt } from './message_components';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
|
@ -39,8 +41,10 @@ class ErrorBoundaryInternal extends React.Component<
|
|||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
const customError = mutateError(error);
|
||||
apm.captureError(customError);
|
||||
console.error('Error caught by Kibana React Error Boundary'); // eslint-disable-line no-console
|
||||
console.error(error); // eslint-disable-line no-console
|
||||
console.error(customError); // eslint-disable-line no-console
|
||||
|
||||
const { name, isFatal } = this.props.services.errorService.registerError(error, errorInfo);
|
||||
this.setState(() => {
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import { render } from '@testing-library/react';
|
||||
import React, { FC, PropsWithChildren } from 'react';
|
||||
import { apm } from '@elastic/apm-rum';
|
||||
|
||||
import { BadComponent, ChunkLoadErrorComponent, getServicesMock } from '../../mocks';
|
||||
import { KibanaErrorBoundaryServices } from '../../types';
|
||||
|
@ -17,11 +18,14 @@ import { KibanaErrorService } from '../services/error_service';
|
|||
import { KibanaSectionErrorBoundary } from './section_error_boundary';
|
||||
import { errorMessageStrings as strings } from './message_strings';
|
||||
|
||||
jest.mock('@elastic/apm-rum');
|
||||
|
||||
describe('<KibanaSectionErrorBoundary>', () => {
|
||||
let services: KibanaErrorBoundaryServices;
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
services = getServicesMock();
|
||||
(apm.captureError as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
const Template: FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||
|
@ -88,7 +92,7 @@ describe('<KibanaSectionErrorBoundary>', () => {
|
|||
expect(mockDeps.analytics.reportEvent.mock.calls[0][0]).toBe('fatal-error-react');
|
||||
expect(mockDeps.analytics.reportEvent.mock.calls[0][1]).toMatchObject({
|
||||
component_name: 'BadComponent',
|
||||
error_message: 'Error: This is an error to show the test user!',
|
||||
error_message: 'FatalReactError: This is an error to show the test user!',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -114,4 +118,26 @@ describe('<KibanaSectionErrorBoundary>', () => {
|
|||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('integrates with apm to capture the error', async () => {
|
||||
const { findByTestId } = render(
|
||||
<Template>
|
||||
<BadComponent />
|
||||
</Template>
|
||||
);
|
||||
(await findByTestId('clickForErrorBtn')).click();
|
||||
|
||||
expect(apm.captureError).toHaveBeenCalledTimes(1);
|
||||
expect(apm.captureError).toHaveBeenCalledWith(
|
||||
new Error('This is an error to show the test user!')
|
||||
);
|
||||
expect(Object.keys((apm.captureError as jest.Mock).mock.calls[0][0])).toEqual([
|
||||
'react_error_type',
|
||||
'original_name',
|
||||
'name',
|
||||
]);
|
||||
expect((apm.captureError as jest.Mock).mock.calls[0][0].react_error_type).toEqual(
|
||||
'fatal-error-react'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,10 +7,12 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { apm } from '@elastic/apm-rum';
|
||||
import React from 'react';
|
||||
|
||||
import { mutateError } from '../../lib';
|
||||
import type { KibanaErrorBoundaryServices } from '../../types';
|
||||
import { useErrorBoundary } from '../services/error_boundary_services';
|
||||
import { useErrorBoundary } from '../services';
|
||||
import { SectionFatalPrompt, SectionRecoverablePrompt } from './message_components';
|
||||
|
||||
interface SectionErrorBoundaryProps {
|
||||
|
@ -63,8 +65,10 @@ class SectionErrorBoundaryInternal extends React.Component<
|
|||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
console.error('Error caught by Kibana React Section Error Boundary'); // eslint-disable-line no-console
|
||||
console.error(error); // eslint-disable-line no-console
|
||||
const customError = mutateError(error);
|
||||
apm.captureError(customError);
|
||||
console.error('Error caught by Kibana React Error Boundary'); // eslint-disable-line no-console
|
||||
console.error(customError); // eslint-disable-line no-console
|
||||
|
||||
const { name, isFatal } = this.props.services.errorService.registerError(error, errorInfo);
|
||||
this.setState({ error, errorInfo, componentName: name, isFatal });
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue