Meaningful Sentry issues with react-query + axios
Tags: javascript, react, sentry, axios
Published: 2023-02-02 10:39:26

One of our products just went live, wohoo! But we are using external backend servers, which are not monitored by us, the frontend development team. To gain more insight into application behaviour, we are monitoring the web app through [Sentry](https://sentry.io). This works well and already provided a lot of informations on legacy issues, such as missing `ResizeObserver`, clients that use browsers without `window.matchMedia` and so on.

But one issue really stood out. We were not able to gather _anything useful_ for errors produced by axios. And as we are not able to debug this backend-side, this was a big no-no.

So I went to optimize the way how our errors are handled with [react-query](https://tanstack.com/query/v4/docs/react/overview), [axios](https://github.com/axios/axios) and [Sentry](https://sentry.io). Let's have a look!

## The initial problem

Like any sane web app, we catch exceptions for requests on a query level using `onError` and display errors in a toast, so the user knows something went wrong and what they can do about it. Then we pass the exception into Sentry.
Often, we enrich the exception further with informations that might be useful for debugging later, for example, a selected product or what payment provider the user tried to use.

An example would look like:

```tsx
const { isLoading, error, data } = useQuery({
    queryKey: ['repoData'],
    queryFn: () => myQueryFn(),
    onError: (err) => {
		showAlert({
		    text: t("Error.repoData.failed"),
		    type: AlertTypes.error,
	    });
	    withScope((scope) => {
		    scope.setTag("section", "repoData");
		    captureException(err);
		});
	}
```

But what if we don't want to do this with every component, what if we have a lot of queries and mutations that should just display a general error and capture the exception afterwards?

Well, that's definitely possible in the `defaultOptions` while setting up your [`QueryClient`](https://tanstack.com/query/v4/docs/react/reference/QueryClient):

```tsx
  const queryClient = useMemo(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            onError: (err) => {
              captureException(err);
              showAlert();
            },
          },
          mutations: {
            onError: (err) => {
              captureException(err);
              showAlert();
            },
          },
        },
      }),
    [showAlert]
  );
```

Cool, right? Yeah, well, not really. There are three big issues with this.

### Missing information 
Both usages of `captureException` above just brings us the stack trace and the `error.response.message` in Sentry, so all information we would have on a response with a `500` status would be:

```
AxiosError: Request failed with status code 500
  at AxiosError(../node_modules/axios/lib/core/AxiosError.js:22:23)
  at settle(../node_modules/axios/lib/core/settle.js:19:16)
  at onloadend(../node_modules/axios/lib/adapters/xhr.js:103:7)
```

To be fair, in the breadcrumbs you can see what the requests were before that.
But this is not much info either, you can just say what URL the user tried to reach, not what exception occurred or what the request and response looked like.

### Missing grouping of issues
Handling exceptions this way will cause the same queries and exceptions to raise new issues. This is because Sentry groups issues by their stack trace and the point in your app where these exceptions were found. This is called "Fingerprinting" in Sentry. You can read more about this here: [Fingerprinting Rules in Sentry](https://docs.sentry.io/product/data-management-settings/event-grouping/fingerprint-rules/). 

### Totally not DRY when using query-level `onError`
If you want to handle certain errors on a query level as well, you will have many places with custom logic that looks like the first example. Using `defaultOptions`, you cannot configure your queries to share a common `onError` behaviour and then implement e.g. a custom alert themselves. It is just the _default option_ if no `onError` callback was defined.

## The solution
Now, we are really searching for two things:
1. Setting up a global `captureException`, which works even with query-level `onError` handlers defined
2. Enriching issues with more informations about the exception

### Step 1: Global `captureException` handlers
Instead of capturing exceptions on a query-based level, I discovered the ability to configure error handling on a more global level. This is done by configuring the `MutationCache` and `QueryCache` used by your `QueryClient`. You can set `onError` handlers for these, which will then trigger always, even when query-level `onError` handlers are present.
As you have access to the `muation` or `query` object there, you can even extract more informations and send them with the exception!

Let's have a look at the new `queryClient` I came up with:

```tsx
const queryClient = useMemo(
  () =>
    new QueryClient({
      mutationCache: new MutationCache({
        onError: (err, _variables, _context, mutation) => {
          withScope((scope) => {
            scope.setContext("mutation", {
              mutationId: mutation.mutationId,
              variables: mutation.state.variables,
            });
            if (mutation.options.mutationKey) {
              scope.setFingerprint(
                // Duplicate to prevent modification
                Array.from(mutation.options.mutationKey) as string[]
              );
            }
            captureException(err);
          });
        },
      }),
      queryCache: new QueryCache({
        onError: (err, query) => {
          withScope((scope) => {
            scope.setContext("query", { queryHash: query.queryHash });
            scope.setFingerprint([query.queryHash.replaceAll(/[0-9]/g, "0")]);
            captureException(err);
          });
        },
      }),
      defaultOptions: {
        queries: {
          onError: (err) => {
            showAlert(err);
          },
        },
        mutations: {
          onError: (err) => {
            showAlert(err);
          },
        },
      },
    }),
  [showAlert]
);

```
We capture exceptions for **every query and mutation** now and we use `scope.setFingerprint` to define, **what fingerprint the scope has** and therefore what the exception will be grouped by. For now, I use the `mutationKey` or the `queryHash` for this. Before using the `queryHash` I strip away all numbers, so that I don't get one issue per query parameter combination.

### Step 2: Automatically enriching issues with informations
Sentry provides a really helpful **pluggable Integration**, `ExtraErrorData`. This integration automatically extracts data from the given `error` object in an event and even checks if this `error` object has a `.toJSON()` method, which it will call and display all information in the issue page on Sentry.

To set this integration up, you have to install the `@sentry/integrations` package and add your wanted integration into your `Sentry.init` call, like so:

```javascript
import * as Sentry from "@sentry/browser";
import { ExtraErrorData as ExtraErrorDataIntegration } from "@sentry/integrations";

Sentry.init({
  dsn: "https://examplePublicKey@o0.ingest.sentry.io/0",
  integrations: [
    new ExtraErrorDataIntegration(),
  ],
});
```

This will then display all additional data from your axios error in sentry. Nice!

## Done!
Instead of just getting a stack trace and the error message, we now get grouped issues per query/mutation, all data from the axios error and global handling of all errors when using react-query.

I hope this was helpful to you! If not, be sure to leave a comment below, I am happy to investigate your issue as well! 😊