Skip to content

Commit

Permalink
ref(react): Rely on error.cause to link ErrorBoundary errors (#4005)
Browse files Browse the repository at this point in the history
  • Loading branch information
AbhiPrasad authored Oct 6, 2021
1 parent bdcf133 commit 00fba50
Show file tree
Hide file tree
Showing 2 changed files with 37 additions and 97 deletions.
66 changes: 16 additions & 50 deletions packages/react/src/errorboundary.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
import {
captureEvent,
captureException,
eventFromException,
ReportDialogOptions,
Scope,
showReportDialog,
withScope,
} from '@sentry/browser';
import { Event } from '@sentry/types';
import { captureException, ReportDialogOptions, Scope, showReportDialog, withScope } from '@sentry/browser';
import { logger, parseSemver } from '@sentry/utils';
import hoistNonReactStatics from 'hoist-non-react-statics';
import * as React from 'react';
Expand Down Expand Up @@ -53,7 +44,7 @@ export type ErrorBoundaryProps = {
};

type ErrorBoundaryState = {
componentStack: string | null;
componentStack: React.ErrorInfo['componentStack'] | null;
error: Error | null;
eventId: string | null;
};
Expand All @@ -64,43 +55,6 @@ const INITIAL_STATE = {
eventId: null,
};

/**
* Logs react error boundary errors to Sentry. If on React version >= 17, creates stack trace
* from componentStack param, otherwise relies on error param for stacktrace.
*
* @param error An error captured by React Error Boundary
* @param componentStack The component stacktrace
*/
function captureReactErrorBoundaryError(error: Error, componentStack: string): string {
const errorBoundaryError = new Error(error.message);
errorBoundaryError.name = `React ErrorBoundary ${errorBoundaryError.name}`;
errorBoundaryError.stack = componentStack;

let errorBoundaryEvent: Event = {};
void eventFromException({}, errorBoundaryError).then(e => {
errorBoundaryEvent = e;
});

if (
errorBoundaryEvent.exception &&
Array.isArray(errorBoundaryEvent.exception.values) &&
reactVersion.major &&
reactVersion.major >= 17
) {
let originalEvent: Event = {};
void eventFromException({}, error).then(e => {
originalEvent = e;
});
if (originalEvent.exception && Array.isArray(originalEvent.exception.values)) {
originalEvent.exception.values = [...errorBoundaryEvent.exception.values, ...originalEvent.exception.values];
}

return captureEvent(originalEvent);
}

return captureException(error, { contexts: { react: { componentStack } } });
}

/**
* A ErrorBoundary component that logs errors to Sentry. Requires React >= 16.
* NOTE: If you are a Sentry user, and you are seeing this stack frame, it means the
Expand All @@ -110,14 +64,26 @@ function captureReactErrorBoundaryError(error: Error, componentStack: string): s
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
public state: ErrorBoundaryState = INITIAL_STATE;

public componentDidCatch(error: Error, { componentStack }: React.ErrorInfo): void {
public componentDidCatch(error: Error & { cause?: Error }, { componentStack }: React.ErrorInfo): void {
const { beforeCapture, onError, showDialog, dialogOptions } = this.props;

withScope(scope => {
// If on React version >= 17, create stack trace from componentStack param and links
// to to the original error using `error.cause` otherwise relies on error param for stacktrace.
// Linking errors requires the `LinkedErrors` integration be enabled.
if (reactVersion.major && reactVersion.major >= 17) {
const errorBoundaryError = new Error(error.message);
errorBoundaryError.name = `React ErrorBoundary ${errorBoundaryError.name}`;
errorBoundaryError.stack = componentStack;

// Using the `LinkedErrors` integration to link the errors together.
error.cause = errorBoundaryError;
}

if (beforeCapture) {
beforeCapture(scope, error, componentStack);
}
const eventId = captureReactErrorBoundaryError(error, componentStack);
const eventId = captureException(error, { contexts: { react: { componentStack } } });
if (onError) {
onError(error, componentStack, eventId);
}
Expand Down
68 changes: 21 additions & 47 deletions packages/react/test/errorboundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ import { useState } from 'react';

import { ErrorBoundary, ErrorBoundaryProps, UNKNOWN_COMPONENT, withErrorBoundary } from '../src/errorboundary';

const mockCaptureEvent = jest.fn();
const mockCaptureException = jest.fn();
const mockShowReportDialog = jest.fn();
const EVENT_ID = 'test-id-123';

jest.mock('@sentry/browser', () => {
const actual = jest.requireActual('@sentry/browser');
return {
...actual,
captureEvent: (event: Event) => {
mockCaptureEvent(event);
captureException: (...args: unknown[]) => {
mockCaptureException(...args);
return EVENT_ID;
},
showReportDialog: (options: any) => {
Expand Down Expand Up @@ -74,7 +74,7 @@ describe('ErrorBoundary', () => {
jest.spyOn(console, 'error').mockImplementation();

afterEach(() => {
mockCaptureEvent.mockClear();
mockCaptureException.mockClear();
mockShowReportDialog.mockClear();
});

Expand Down Expand Up @@ -220,60 +220,34 @@ describe('ErrorBoundary', () => {
);

expect(mockOnError).toHaveBeenCalledTimes(0);
expect(mockCaptureEvent).toHaveBeenCalledTimes(0);
expect(mockCaptureException).toHaveBeenCalledTimes(0);

const btn = screen.getByTestId('errorBtn');
fireEvent.click(btn);

expect(mockOnError).toHaveBeenCalledTimes(1);
expect(mockOnError).toHaveBeenCalledWith(expect.any(Error), expect.any(String), expect.any(String));

expect(mockCaptureEvent).toHaveBeenCalledTimes(1);

// We do a detailed assert on the stacktrace as a regression test against future
// react changes (that way we can update the docs if frames change in a major way).
const event = mockCaptureEvent.mock.calls[0][0];
expect(event.exception.values).toHaveLength(2);
expect(event.level).toBe(Severity.Error);

expect(event.exception.values[0].type).toEqual('React ErrorBoundary Error');
expect(event.exception.values[0].stacktrace.frames).toEqual([
{
colno: expect.any(Number),
filename: expect.stringContaining('errorboundary.test.tsx'),
function: 'TestApp',
in_app: true,
lineno: expect.any(Number),
},
{
colno: expect.any(Number),
filename: expect.stringContaining('errorboundary.tsx'),
function: 'ErrorBoundary',
in_app: true,
lineno: expect.any(Number),
},
{
colno: expect.any(Number),
filename: expect.stringContaining('errorboundary.test.tsx'),
function: 'Bam',
in_app: true,
lineno: expect.any(Number),
},
{
colno: expect.any(Number),
filename: expect.stringContaining('errorboundary.test.tsx'),
function: 'Boo',
in_app: true,
lineno: expect.any(Number),
},
]);
expect(mockCaptureException).toHaveBeenCalledTimes(1);
expect(mockCaptureException).toHaveBeenLastCalledWith(expect.any(Error), {
contexts: { react: { componentStack: expect.any(String) } },
});

expect(mockOnError.mock.calls[0][0]).toEqual(mockCaptureException.mock.calls[0][0]);

// Check if error.cause -> react component stack
const error = mockCaptureException.mock.calls[0][0];
const cause = error.cause;
expect(cause.stack).toEqual(mockCaptureException.mock.calls[0][1].contexts.react.componentStack);
expect(cause.name).toContain('React ErrorBoundary');
expect(cause.message).toEqual(error.message);
});

it('calls `beforeCapture()` when an error occurs', () => {
const mockBeforeCapture = jest.fn();

const testBeforeCapture = (...args: any[]) => {
expect(mockCaptureEvent).toHaveBeenCalledTimes(0);
expect(mockCaptureException).toHaveBeenCalledTimes(0);
mockBeforeCapture(...args);
};

Expand All @@ -284,14 +258,14 @@ describe('ErrorBoundary', () => {
);

expect(mockBeforeCapture).toHaveBeenCalledTimes(0);
expect(mockCaptureEvent).toHaveBeenCalledTimes(0);
expect(mockCaptureException).toHaveBeenCalledTimes(0);

const btn = screen.getByTestId('errorBtn');
fireEvent.click(btn);

expect(mockBeforeCapture).toHaveBeenCalledTimes(1);
expect(mockBeforeCapture).toHaveBeenLastCalledWith(expect.any(Scope), expect.any(Error), expect.any(String));
expect(mockCaptureEvent).toHaveBeenCalledTimes(1);
expect(mockCaptureException).toHaveBeenCalledTimes(1);
});

it('shows a Sentry Report Dialog with correct options', () => {
Expand Down

0 comments on commit 00fba50

Please sign in to comment.