From 7741886dcc05e7a9bae16c4d6c6115eb1b8417da Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 21 May 2024 11:36:35 -0400 Subject: [PATCH] feat(react): Add Sentry.captureReactException --- packages/react/README.md | 56 ++++++++++++++-- packages/react/src/error.ts | 74 ++++++++++++++++++++++ packages/react/src/errorboundary.tsx | 58 ++--------------- packages/react/src/index.ts | 1 + packages/react/test/error.test.ts | 14 ++++ packages/react/test/errorboundary.test.tsx | 15 +---- 6 files changed, 148 insertions(+), 70 deletions(-) create mode 100644 packages/react/src/error.ts create mode 100644 packages/react/test/error.test.ts diff --git a/packages/react/README.md b/packages/react/README.md index 49c09247c9ea..1315d9760142 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -20,7 +20,7 @@ To use this SDK, call `Sentry.init(options)` before you mount your React compone ```javascript import React from 'react'; -import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; import * as Sentry from '@sentry/react'; Sentry.init({ @@ -30,12 +30,60 @@ Sentry.init({ // ... -ReactDOM.render(, rootNode); +const container = document.getElementById(“app”); +const root = createRoot(container); +root.render(); -// Can also use with React Concurrent Mode -// ReactDOM.createRoot(rootNode).render(); +// also works with hydrateRoot +// const domNode = document.getElementById('root'); +// const root = hydrateRoot(domNode, reactNode); +// root.render(); ``` +### React 19 + +Starting with React 19, the `createRoot` and `hydrateRoot` methods expose error hooks that can be used to capture errors automatically. Use the `Sentry.captureReactException` method to capture errors in the error hooks you are interested in. + +```js +const container = document.getElementById(“app”); +const root = createRoot(container, { + // Callback called when an error is thrown and not caught by an Error Boundary. + onUncaughtError: (error, errorInfo) => { + Sentry.captureReactException(error, errorInfo); + + console.error( + 'Uncaught error', + error, + errorInfo.componentStack + ); + }, + // Callback called when React catches an error in an Error Boundary. + onCaughtError: (error, errorInfo) => { + Sentry.captureReactException(error, errorInfo); + + console.error( + 'Caught error', + error, + errorInfo.componentStack + ); + }, + // Callback called when React automatically recovers from errors. + onRecoverableError: (error, errorInfo) => { + Sentry.captureReactException(error, errorInfo); + + console.error( + 'Recoverable error', + error, + error.cause, + errorInfo.componentStack, + ); + } +}); +root.render(); +``` + +If you want more finely grained control over error handling, we recommend only adding the `onUncaughtError` and `onRecoverableError` hooks and using an `ErrorBoundary` component instead of the `onCaughtError` hook. + ### ErrorBoundary `@sentry/react` exports an ErrorBoundary component that will automatically send Javascript errors from inside a diff --git a/packages/react/src/error.ts b/packages/react/src/error.ts new file mode 100644 index 000000000000..347793400d58 --- /dev/null +++ b/packages/react/src/error.ts @@ -0,0 +1,74 @@ +import { captureException } from '@sentry/browser'; +import type { EventHint } from '@sentry/types'; +import { isError } from '@sentry/utils'; +import { version } from 'react'; +import type { ErrorInfo } from 'react'; + +/** + * See if React major version is 17+ by parsing version string. + */ +export function isAtLeastReact17(reactVersion: string): boolean { + const reactMajor = reactVersion.match(/^([^.]+)/); + return reactMajor !== null && parseInt(reactMajor[0]) >= 17; +} + +/** + * Recurse through `error.cause` chain to set cause on an error. + */ +export function setCause(error: Error & { cause?: Error }, cause: Error): void { + const seenErrors = new WeakMap(); + + function recurse(error: Error & { cause?: Error }, cause: Error): void { + // If we've already seen the error, there is a recursive loop somewhere in the error's + // cause chain. Let's just bail out then to prevent a stack overflow. + if (seenErrors.has(error)) { + return; + } + if (error.cause) { + seenErrors.set(error, true); + return recurse(error.cause, cause); + } + error.cause = cause; + } + + recurse(error, cause); +} + +/** + * Captures an error that was thrown by a React ErrorBoundary or React root. + * + * @param error The error to capture. + * @param errorInfo The errorInfo provided by React. + * @param hint Optional additional data to attach to the Sentry event. + * @returns the id of the captured Sentry event. + */ +export function captureReactException( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: any, + { componentStack }: ErrorInfo, + hint?: EventHint, +): string { + // 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. + // See: https://reactjs.org/blog/2020/08/10/react-v17-rc.html#native-component-stacks + // + // Although `componentDidCatch` is typed to accept an `Error` object, it can also be invoked + // with non-error objects. This is why we need to check if the error is an error-like object. + // See: https://github.com/getsentry/sentry-javascript/issues/6167 + if (isAtLeastReact17(version) && isError(error)) { + const errorBoundaryError = new Error(error.message); + errorBoundaryError.name = `React ErrorBoundary ${error.name}`; + errorBoundaryError.stack = componentStack; + + // Using the `LinkedErrors` integration to link the errors together. + setCause(error, errorBoundaryError); + } + + return captureException(error, { + ...hint, + captureContext: { + contexts: { react: { componentStack } }, + }, + }); +} diff --git a/packages/react/src/errorboundary.tsx b/packages/react/src/errorboundary.tsx index 3ce1d0442b81..5cb706639b27 100644 --- a/packages/react/src/errorboundary.tsx +++ b/packages/react/src/errorboundary.tsx @@ -1,16 +1,12 @@ import type { ReportDialogOptions } from '@sentry/browser'; -import { captureException, getClient, showReportDialog, withScope } from '@sentry/browser'; +import { getClient, showReportDialog, withScope } from '@sentry/browser'; import type { Scope } from '@sentry/types'; -import { isError, logger } from '@sentry/utils'; +import { logger } from '@sentry/utils'; import hoistNonReactStatics from 'hoist-non-react-statics'; import * as React from 'react'; import { DEBUG_BUILD } from './debug-build'; - -export function isAtLeastReact17(version: string): boolean { - const major = version.match(/^([^.]+)/); - return major !== null && parseInt(major[0]) >= 17; -} +import { captureReactException } from './error'; export const UNKNOWN_COMPONENT = 'unknown'; @@ -69,25 +65,6 @@ const INITIAL_STATE = { eventId: null, }; -function setCause(error: Error & { cause?: Error }, cause: Error): void { - const seenErrors = new WeakMap(); - - function recurse(error: Error & { cause?: Error }, cause: Error): void { - // If we've already seen the error, there is a recursive loop somewhere in the error's - // cause chain. Let's just bail out then to prevent a stack overflow. - if (seenErrors.has(error)) { - return; - } - if (error.cause) { - seenErrors.set(error, true); - return recurse(error.cause, cause); - } - error.cause = cause; - } - - recurse(error, cause); -} - /** * A ErrorBoundary component that logs errors to Sentry. * NOTE: If you are a Sentry user, and you are seeing this stack frame, it means the @@ -118,38 +95,15 @@ class ErrorBoundary extends React.Component { - // 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. - // See: https://reactjs.org/blog/2020/08/10/react-v17-rc.html#native-component-stacks - // - // Although `componentDidCatch` is typed to accept an `Error` object, it can also be invoked - // with non-error objects. This is why we need to check if the error is an error-like object. - // See: https://github.com/getsentry/sentry-javascript/issues/6167 - if (isAtLeastReact17(React.version) && isError(error)) { - const errorBoundaryError = new Error(error.message); - errorBoundaryError.name = `React ErrorBoundary ${error.name}`; - errorBoundaryError.stack = componentStack; - - // Using the `LinkedErrors` integration to link the errors together. - setCause(error, errorBoundaryError); - } - if (beforeCapture) { beforeCapture(scope, error, componentStack); } - const eventId = captureException(error, { - captureContext: { - contexts: { react: { componentStack } }, - }, - // If users provide a fallback component we can assume they are handling the error. - // Therefore, we set the mechanism depending on the presence of the fallback prop. - mechanism: { handled: !!this.props.fallback }, - }); + const eventId = captureReactException(error, errorInfo, { mechanism: { handled: !!this.props.fallback }}) if (onError) { onError(error, componentStack, eventId); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ef6627cf0c5e..b3f20deed03d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,6 +1,7 @@ export * from '@sentry/browser'; export { init } from './sdk'; +export { captureReactException } from './error'; export { Profiler, withProfiler, useProfiler } from './profiler'; export type { ErrorBoundaryProps, FallbackRender } from './errorboundary'; export { ErrorBoundary, withErrorBoundary } from './errorboundary'; diff --git a/packages/react/test/error.test.ts b/packages/react/test/error.test.ts new file mode 100644 index 000000000000..780c6f9657fb --- /dev/null +++ b/packages/react/test/error.test.ts @@ -0,0 +1,14 @@ +import { isAtLeastReact17 } from '../src/error'; + +describe('isAtLeastReact17', () => { + test.each([ + ['React 16', '16.0.4', false], + ['React 17', '17.0.0', true], + ['React 17 with no patch', '17.4', true], + ['React 17 with no patch and no minor', '17', true], + ['React 18', '18.1.0', true], + ['React 19', '19.0.0', true], + ])('%s', (_: string, input: string, output: ReturnType) => { + expect(isAtLeastReact17(input)).toBe(output); + }); +}); diff --git a/packages/react/test/errorboundary.test.tsx b/packages/react/test/errorboundary.test.tsx index 10c5130f88d7..d032fe73d6d3 100644 --- a/packages/react/test/errorboundary.test.tsx +++ b/packages/react/test/errorboundary.test.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { useState } from 'react'; import type { ErrorBoundaryProps } from '../src/errorboundary'; -import { ErrorBoundary, UNKNOWN_COMPONENT, isAtLeastReact17, withErrorBoundary } from '../src/errorboundary'; +import { ErrorBoundary, UNKNOWN_COMPONENT, withErrorBoundary } from '../src/errorboundary'; const mockCaptureException = jest.fn(); const mockShowReportDialog = jest.fn(); @@ -581,16 +581,3 @@ describe('ErrorBoundary', () => { }); }); }); - -describe('isAtLeastReact17', () => { - test.each([ - ['React 16', '16.0.4', false], - ['React 17', '17.0.0', true], - ['React 17 with no patch', '17.4', true], - ['React 17 with no patch and no minor', '17', true], - ['React 18', '18.1.0', true], - ['React 19', '19.0.0', true], - ])('%s', (_: string, input: string, output: ReturnType) => { - expect(isAtLeastReact17(input)).toBe(output); - }); -});