From 00fba50e6971fcf36af413070bdfa423bcf8cc19 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 6 Oct 2021 08:14:45 -0400 Subject: [PATCH] ref(react): Rely on error.cause to link ErrorBoundary errors (#4005) --- packages/react/src/errorboundary.tsx | 66 +++++---------------- packages/react/test/errorboundary.test.tsx | 68 +++++++--------------- 2 files changed, 37 insertions(+), 97 deletions(-) diff --git a/packages/react/src/errorboundary.tsx b/packages/react/src/errorboundary.tsx index 162bb543b99b..4b12fd7c6b0f 100644 --- a/packages/react/src/errorboundary.tsx +++ b/packages/react/src/errorboundary.tsx @@ -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'; @@ -53,7 +44,7 @@ export type ErrorBoundaryProps = { }; type ErrorBoundaryState = { - componentStack: string | null; + componentStack: React.ErrorInfo['componentStack'] | null; error: Error | null; eventId: string | null; }; @@ -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 @@ -110,14 +64,26 @@ function captureReactErrorBoundaryError(error: Error, componentStack: string): s class ErrorBoundary extends React.Component { 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); } diff --git a/packages/react/test/errorboundary.test.tsx b/packages/react/test/errorboundary.test.tsx index b0b8dd4c0b3f..ac6318be053c 100644 --- a/packages/react/test/errorboundary.test.tsx +++ b/packages/react/test/errorboundary.test.tsx @@ -6,7 +6,7 @@ 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'; @@ -14,8 +14,8 @@ 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) => { @@ -74,7 +74,7 @@ describe('ErrorBoundary', () => { jest.spyOn(console, 'error').mockImplementation(); afterEach(() => { - mockCaptureEvent.mockClear(); + mockCaptureException.mockClear(); mockShowReportDialog.mockClear(); }); @@ -220,7 +220,7 @@ describe('ErrorBoundary', () => { ); expect(mockOnError).toHaveBeenCalledTimes(0); - expect(mockCaptureEvent).toHaveBeenCalledTimes(0); + expect(mockCaptureException).toHaveBeenCalledTimes(0); const btn = screen.getByTestId('errorBtn'); fireEvent.click(btn); @@ -228,52 +228,26 @@ describe('ErrorBoundary', () => { 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); }; @@ -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', () => {