From 943e3f8c6ebd5c9d22e7be2d63dbdfcba2b7a0eb Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 20 Jul 2020 12:04:03 -0400 Subject: [PATCH] feat(react): Allow for scope to be accessed before error (#2753) Co-authored-by: Daniel Griesser --- CHANGELOG.md | 1 + packages/react/src/errorboundary.tsx | 34 +++++++++------ packages/react/test/errorboundary.test.tsx | 48 ++++++++++++++++++---- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90cca849d346..a9e91e017575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - [tracing] feat: `Add @sentry/tracing` (#2719) - [gatsby] fix: Make APM optional in gatsby package (#2752) - [tracing] ref: Use idleTimout if no activities occur in idle transaction (#2752) +- [react] feat: Add `beforeCapture` option to ErrorBoundary (#2753) ## 5.19.2 diff --git a/packages/react/src/errorboundary.tsx b/packages/react/src/errorboundary.tsx index ec05ad73dd43..6f78c41f56e7 100644 --- a/packages/react/src/errorboundary.tsx +++ b/packages/react/src/errorboundary.tsx @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import { captureException, ReportDialogOptions, Scope, showReportDialog, withScope } from '@sentry/browser'; import * as hoistNonReactStatic from 'hoist-non-react-statics'; import * as React from 'react'; @@ -18,7 +18,7 @@ export type ErrorBoundaryProps = { * Options to be passed into the Sentry report dialog. * No-op if {@link showDialog} is false. */ - dialogOptions?: Sentry.ReportDialogOptions; + dialogOptions?: ReportDialogOptions; // tslint:disable no-null-undefined-union /** * A fallback component that gets rendered when the error boundary encounters an error. @@ -38,6 +38,8 @@ export type ErrorBoundaryProps = { onReset?(error: Error | null, componentStack: string | null, eventId: string | null): void; /** Called on componentWillUnmount() */ onUnmount?(error: Error | null, componentStack: string | null, eventId: string | null): void; + /** Called before the error is captured by Sentry, allows for you to add tags or context using the scope */ + beforeCapture?(scope: Scope, error: Error | null, componentStack: string | null): void; }; type ErrorBoundaryState = { @@ -60,18 +62,24 @@ class ErrorBoundary extends React.Component { + if (beforeCapture) { + beforeCapture(scope, error, componentStack); + } + const eventId = captureException(error, { contexts: { react: { componentStack } } }); + if (onError) { + onError(error, componentStack, eventId); + } + if (showDialog) { + showReportDialog({ ...dialogOptions, eventId }); + } - // componentDidCatch is used over getDerivedStateFromError - // so that componentStack is accessible through state. - this.setState({ error, componentStack, eventId }); + // componentDidCatch is used over getDerivedStateFromError + // so that componentStack is accessible through state. + this.setState({ error, componentStack, eventId }); + }); } public componentDidMount(): void { diff --git a/packages/react/test/errorboundary.test.tsx b/packages/react/test/errorboundary.test.tsx index e522841636e8..fe427d0e53ac 100644 --- a/packages/react/test/errorboundary.test.tsx +++ b/packages/react/test/errorboundary.test.tsx @@ -1,3 +1,4 @@ +import { Scope } from '@sentry/browser'; import { fireEvent, render, screen } from '@testing-library/react'; import * as React from 'react'; @@ -7,15 +8,19 @@ const mockCaptureException = jest.fn(); const mockShowReportDialog = jest.fn(); const EVENT_ID = 'test-id-123'; -jest.mock('@sentry/browser', () => ({ - captureException: (err: any, ctx: any) => { - mockCaptureException(err, ctx); - return EVENT_ID; - }, - showReportDialog: (options: any) => { - mockShowReportDialog(options); - }, -})); +jest.mock('@sentry/browser', () => { + const actual = jest.requireActual('@sentry/browser'); + return { + ...actual, + captureException: (err: any, ctx: any) => { + mockCaptureException(err, ctx); + return EVENT_ID; + }, + showReportDialog: (options: any) => { + mockShowReportDialog(options); + }, + }; +}); const TestApp: React.FC = ({ children, ...props }) => { const [isError, setError] = React.useState(false); @@ -197,6 +202,31 @@ describe('ErrorBoundary', () => { }); }); + it('calls `beforeCapture()` when an error occurs', () => { + const mockBeforeCapture = jest.fn(); + + const testBeforeCapture = (...args: any[]) => { + expect(mockCaptureException).toHaveBeenCalledTimes(0); + mockBeforeCapture(...args); + }; + + render( + You have hit an error

} beforeCapture={testBeforeCapture}> +

children

+
, + ); + + expect(mockBeforeCapture).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(mockCaptureException).toHaveBeenCalledTimes(1); + }); + it('shows a Sentry Report Dialog with correct options', () => { const options = { title: 'custom title' }; render(