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);
});
}
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]
);
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.
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:
- Setting up a global
captureException
, which works even with query-levelonError
handlers defined - 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]
);
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(),
],
});
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! 😊
Top comments (1)
Great article, you got my follow, keep writing!