When upgrading sentry dependency I wanted to ensure that it worked.
So I write my classic code to throw some sentry errors:
<button
type="button"
onClick={() => {
throw new Error("RTR just testing sentry config");
}}
>
Click me daddy
</button>;
I deploy.
I click on the button.
I wait
I wait..
I wait…
Nothing happened! No sentry is thrown :(
When I test on another project. It works!
Let’s look at what I discovered about the sentry sdk and the two problems I encountered in this project.
I spent some time to look at the documentation to know how sentry handles uncaught exceptions. And find that it’s automatically handled by the GlobalHandlers integration:
Attaches global handlers to capture uncaught exceptions and unhandled rejections. (default)
We can make sure that the integration is installed by passing debug: true
to your init
sentry function.
Thanks to this log we can be sure that sentry is well configured in the application and that the integration is present:
You may have guessed it, sentry adds a window.onerror
handler that we can print in the devtool:
Now we can go to the function definition by clicking on the function, and put a breakpoint:
Script error
is catched, not my RTR just testing sentry config
error!
Script errors are ignored by default by sentry, so no sentry thrown.
After searching this problem on the internet, I found quickly this answer .
Why do I have this problem?To answer this question, I need to tell you more about the infrastructure of this project. It’s pretty straightforward:
sequenceDiagram actor User Note right of User: Go on romaintrotard.com User->>Server: Get page activate Server Note right of Server: romaintrotard.com Server-->>User: index.html deactivate Server User->>CDN: Get Javascript activate CDN Note right of CDN: assets.romaintrotard.com CDN-->>User: script.js deactivate CDN
As you can see there is two domains names in this scenario:
- romaintrotard.com that delivers the
index.html
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Sentry problem example</title>
<script
defer="defer"
src="https://assets.romaintrotard.com/index-CP1JccLo.js"
></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
- assets.romaintrotard.com that delivers assets (here the Javascript file)
I think you see this coming. You can encounter CORS error! But it’s not the case in my project because the CDN add the following header:
Access-Control-Allow-Origin: *
This header is an authorization set by the server to deliver the script to any domain.
But it’s not sufficient!
In my example, the browser will consider the index.js
as a script it cannot trust and
will throw a default "Script error."
to protect the user from malicious code.
We need to tell to the browser, we want to get the script in an anonymous way: crossorigin="anonymous"
. Thanks to this, we can have access
to real errors, and there is no exchange of user credentials via cookie if the destination is not the same domain.
<script
defer="defer"
crossorigin="anonymous"
src="https://assets.romaintrotard.com/index-CP1JccLo.js"
></script>
Another way is to use the type="module"
. And we can even delete the defer
attribute.
<script
type="module"
src="https://assets.romaintrotard.com/index-CP1JccLo.js"
></script>
TADA!!! We throw nice errors to sentry :)
How to configure html-webpack-plugin
new HtmlWebpackPlugin({
templateParameters: (
compilation,
assets,
assetTags,
options,
) => ({
compilation,
webpackConfig: compilation.options,
htmlWebpackPlugin: {
tags: {
...assetTags,
headTags: assetTags.headTags.map((tag) => {
if (tag.tagName === "script") {
return {
...tag,
attributes: {
...tag.attributes,
crossorigin: "anonymous",
},
};
}
return tag;
}),
},
files: assets,
options,
},
}),
});
By testing, on another project, how the event listener error was handled by Sentry I saw that the error was not handled by
the window.onerror
.
I realised that I had another problem in the project configuration.
Actually, the error should be catch thanks to a try/catch
.
First we need to dive deeper in the prototype chain of an HTMLButtonElement
:
classDiagram HTMLElement <|-- HTMLButtonElement Element <|-- HTMLElement Node <|-- Element EventTarget <|-- Node class EventTarget{ addEventListener removeEventListener dispatchEvent }
EventTarget
is the class having the addEventListener
function, so we can override the function to try/catch the event listener:
function wrap(callback) {
function newCallback() {
try {
callback.apply(this, arguments);
} catch (error) {
// Here sentry will throw the error to the sentry cloud
throw error;
}
}
return newCallback;
}
const originalAddEventListener =
window.EventTarget.prototype.addEventListener;
window.EventTarget.prototype.addEventListener = function (
eventType,
callback,
options,
) {
return originalAddEventListener.apply(this, [
eventType,
wrap(callback),
options,
]);
};
That’s exactly what does Sentry.
But why doesn’t it work with my project?
To understand the problem, you need to know how React manages event listeners. You can read my article React event listeners demystified to know more about it.
With React 18, event listeners are created when creating the root element with createRoot
.
The javascript code for this project was:
// React add event listeners to the root element for
// each events
const rootElement = createRoot(document.getElementById('root'));
// Sentry overrides the addEventListener function
// on EventTarget
// It's too late for React :(
initSentry();
rootElement.render(<App />);
So the solution is a one liner, switch createRoot
and initSentry
lines:
// Sentry overrides the addEventListener function
// on EventTarget
initSentry();
// React add event listeners to the root element for
// each events, the `addEventListener` is the overridden one :)
const rootElement = createRoot(document.getElementById('root'));
rootElement.render(<App />);
You can find me on Twitter if you want to comment this post or just contact me. Feel free to buy me a coffee if you like the content and encourage me.