Skip to content

Commit

Permalink
feat(react): Add Sentry.captureReactException
Browse files Browse the repository at this point in the history
  • Loading branch information
AbhiPrasad committed May 21, 2024
1 parent eec0687 commit 3cdc306
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 70 deletions.
56 changes: 52 additions & 4 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -30,12 +30,60 @@ Sentry.init({

// ...

ReactDOM.render(<App />, rootNode);
const container = document.getElementById(“app”);
const root = createRoot(container);
root.render(<App />);

// Can also use with React Concurrent Mode
// ReactDOM.createRoot(rootNode).render(<App />);
// also works with hydrateRoot
// const domNode = document.getElementById('root');
// const root = hydrateRoot(domNode, reactNode);
// root.render(<App />);
```

### 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(<App />);
```

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
Expand Down
74 changes: 74 additions & 0 deletions packages/react/src/error.ts
Original file line number Diff line number Diff line change
@@ -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<Error, boolean>();

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 } },
},
});
}
58 changes: 6 additions & 52 deletions packages/react/src/errorboundary.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -69,25 +65,6 @@ const INITIAL_STATE = {
eventId: null,
};

function setCause(error: Error & { cause?: Error }, cause: Error): void {
const seenErrors = new WeakMap<Error, boolean>();

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
Expand Down Expand Up @@ -118,38 +95,15 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
}
}

public componentDidCatch(error: unknown, { componentStack }: React.ErrorInfo): void {
public componentDidCatch(error: unknown, errorInfo: React.ErrorInfo): void {
const { componentStack } = errorInfo;
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.
// 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);
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
14 changes: 14 additions & 0 deletions packages/react/test/error.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof isAtLeastReact17>) => {
expect(isAtLeastReact17(input)).toBe(output);
});
});
15 changes: 1 addition & 14 deletions packages/react/test/errorboundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<typeof isAtLeastReact17>) => {
expect(isAtLeastReact17(input)).toBe(output);
});
});

0 comments on commit 3cdc306

Please sign in to comment.