Meaningful Sentry issues with react-query + axios

thraizz

Aron Schüler

Posted on February 2, 2023

Meaningful Sentry issues with react-query + axios

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. 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, axios and Sentry. 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:

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);
        });
    }
Enter fullscreen mode Exit fullscreen mode

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:

  const queryClient = useMemo(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            onError: (err) => {
              captureException(err);
              showAlert();
            },
          },
          mutations: {
            onError: (err) => {
              captureException(err);
              showAlert();
            },
          },
        },
      }),
    [showAlert]
  );
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

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:

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]
);

Enter fullscreen mode Exit fullscreen mode

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:

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(),
  ],
});
Enter fullscreen mode Exit fullscreen mode

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! 😊

💖 💪 🙅 🚩
thraizz
Aron Schüler

Posted on February 2, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related