Go to main content
July 10, 2024
Cover image

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 :(

Sadge

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:

Integration registration logs

You may have guessed it, sentry adds a window.onerror handler that we can print in the devtool:

window error log in devtool

Now we can go to the function definition by clicking on the function, and put a breakpoint:

window error log in devtool

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.

But how is it possible?

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.