[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:
Tim Sullivan 2025-02-12 15:02:48 -07:00 committed by GitHub
parent 4d3cf33ffd
commit 95eea95a5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 116 additions and 11 deletions

View file

@ -24,6 +24,7 @@ SRCS = glob(
BUNDLER_DEPS = [
"@npm//react",
"@npm//tslib",
"@npm//@elastic/apm-rum",
]
js_library(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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