Error boundary telemetry stacktraces 2 (#175219)

## Summary

Closes https://github.com/elastic/kibana-team/issues/733

This change adds two new fields to [Error Boundary event-based
telemetry](https://github.com/elastic/kibana/pull/169895) logging:
`error_stack` and `component_stack`.

- `error_stack` is a stacktrace of the error message that was caught, if
any, otherwise an empty string.
- `component_stack` is the list React components in the React component
tree where error propagated until it was caught by the Error Boundary.


### Checklist

Delete any items that are not applicable to this PR.

- [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


### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Vadim Kibana 2024-01-26 12:45:52 +01:00 committed by GitHub
parent 48f8653279
commit 7490c32373
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 92 additions and 13 deletions

View file

@ -24,6 +24,13 @@ export const reactFatalErrorSchema = {
optional: false as const,
},
},
component_stack: {
type: 'text' as const,
_meta: {
description: 'Stack trace React component tree',
optional: false as const,
},
},
error_message: {
type: 'keyword' as const,
_meta: {
@ -31,4 +38,11 @@ export const reactFatalErrorSchema = {
optional: false as const,
},
},
error_stack: {
type: 'text' as const,
_meta: {
description: 'Stack trace from the error object',
optional: false as const,
},
},
};

View file

@ -34,7 +34,9 @@ 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_stack: expect.any(String),
});
});
@ -61,7 +63,9 @@ describe('<KibanaErrorBoundaryProvider>', () => {
expect(reportEventSpy2).not.toBeCalled();
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_stack: expect.any(String),
});
});
});

View file

@ -20,7 +20,7 @@ describe('KibanaErrorBoundary Error Service', () => {
it('decorates fatal error object', () => {
const testFatal = new Error('This is an unrecognized and fatal error');
const serviceError = service.registerError(testFatal, {});
const serviceError = service.registerError(testFatal, { componentStack: '' });
expect(serviceError.isFatal).toBe(true);
});
@ -28,7 +28,7 @@ describe('KibanaErrorBoundary Error Service', () => {
it('decorates recoverable error object', () => {
const testRecoverable = new Error('Could not load chunk blah blah');
testRecoverable.name = 'ChunkLoadError';
const serviceError = service.registerError(testRecoverable, {});
const serviceError = service.registerError(testRecoverable, { componentStack: '' });
expect(serviceError.isFatal).toBe(false);
});
@ -88,9 +88,36 @@ describe('KibanaErrorBoundary Error Service', () => {
service.registerError(testFatal, errorInfo);
expect(mockDeps.analytics.reportEvent).toHaveBeenCalledTimes(1);
expect(mockDeps.analytics.reportEvent).toHaveBeenCalledWith('fatal-error-react', {
expect(mockDeps.analytics.reportEvent.mock.calls[0][0]).toBe('fatal-error-react');
expect(mockDeps.analytics.reportEvent.mock.calls[0][1]).toMatchObject({
component_name: 'OutrageousMaker',
error_message: 'Error: This is an outrageous and fatal error',
});
});
it('captures component stack trace and error stack trace for telemetry', () => {
jest.resetAllMocks();
const testFatal = new Error('This is an outrageous and fatal error');
const errorInfo = {
componentStack: `
at OutrageousMaker (http://localhost:9001/main.iframe.bundle.js:11616:73)
`,
};
service.registerError(testFatal, errorInfo);
expect(mockDeps.analytics.reportEvent).toHaveBeenCalledTimes(1);
expect(mockDeps.analytics.reportEvent.mock.calls[0][0]).toBe('fatal-error-react');
expect(
String(mockDeps.analytics.reportEvent.mock.calls[0][1].component_stack).includes(
'at OutrageousMaker'
)
).toBe(true);
expect(
String(mockDeps.analytics.reportEvent.mock.calls[0][1].error_stack).startsWith(
'Error: This is an outrageous and fatal error'
)
).toBe(true);
});
});

View file

@ -15,7 +15,7 @@ const MATCH_CHUNK_LOADERROR = /ChunkLoadError/;
interface ErrorServiceError {
error: Error;
errorInfo?: Partial<React.ErrorInfo> | null;
errorInfo?: React.ErrorInfo;
name: string | null;
isFatal: boolean;
}
@ -48,7 +48,7 @@ export class KibanaErrorService {
/**
* Derive the name of the component that threw the error
*/
private getErrorComponentName(errorInfo: Partial<React.ErrorInfo> | null) {
private getErrorComponentName(errorInfo?: React.ErrorInfo) {
let errorComponentName: string | null = null;
const stackLines = errorInfo?.componentStack?.split('\n');
const errorIndicator = /^ at (\S+).*/;
@ -75,18 +75,28 @@ export class KibanaErrorService {
/**
* Creates a decorated error object
*/
public registerError(
error: Error,
errorInfo: Partial<React.ErrorInfo> | null
): ErrorServiceError {
public registerError(error: Error, errorInfo?: React.ErrorInfo): ErrorServiceError {
const isFatal = this.getIsFatal(error);
const name = this.getErrorComponentName(errorInfo);
try {
if (isFatal) {
this.analytics?.reportEvent(REACT_FATAL_ERROR_EVENT_TYPE, {
if (isFatal && this.analytics) {
let componentStack = '';
let errorStack = '';
if (errorInfo && errorInfo.componentStack) {
componentStack = errorInfo.componentStack;
}
if (error instanceof Error && typeof error.stack === 'string') {
errorStack = error.stack;
}
this.analytics.reportEvent(REACT_FATAL_ERROR_EVENT_TYPE, {
component_name: name,
component_stack: componentStack,
error_message: error.toString(),
error_stack: errorStack,
});
}
} catch (e) {

View file

@ -87,9 +87,33 @@ describe('<KibanaErrorBoundary>', () => {
);
(await findByTestId('clickForErrorBtn')).click();
expect(mockDeps.analytics.reportEvent).toHaveBeenCalledWith('fatal-error-react', {
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!',
});
});
it('captures component and error stack traces in telemetry', async () => {
const mockDeps = {
analytics: { reportEvent: jest.fn() },
};
services.errorService = new KibanaErrorService(mockDeps);
const { findByTestId } = render(
<Template>
<BadComponent />
</Template>
);
(await findByTestId('clickForErrorBtn')).click();
expect(
mockDeps.analytics.reportEvent.mock.calls[0][1].component_stack.includes('at BadComponent')
).toBe(true);
expect(
mockDeps.analytics.reportEvent.mock.calls[0][1].error_stack.startsWith(
'Error: This is an error to show the test user!'
)
).toBe(true);
});
});

View file

@ -41,7 +41,7 @@ class ErrorBoundaryInternal extends React.Component<
};
}
componentDidCatch(error: Error, errorInfo: Partial<React.ErrorInfo>) {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
const { name, isFatal } = this.props.services.errorService.registerError(error, errorInfo);
this.setState(() => {
return { error, errorInfo, componentName: name, isFatal };