DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Thirteen strings and four regexes decide what our error reporter is never allowed to tell us

I wrote a while ago about how our error monitor gets loaded: dynamically, at the first idle moment, with a bounded buffer catching anything thrown before it arrives. That post was about the 300 KB.

This one is about the part of the same file that has nothing to do with performance: the configuration that decides which errors are thrown away. It is the half of an error reporting setup nobody writes about, and it is the half that decides whether anyone still reads the alerts in six months.

Two guards for development, and they do different jobs

// `next dev` never reports. Gating on NODE_ENV makes it
// hostname-independent, so a dev server reached over a LAN IP or a tunnel
// (how mobile testing happens) still stays silent.
enabled: process.env.NODE_ENV === 'production',
Enter fullscreen mode Exit fullscreen mode
// Don't send errors from localhost.
beforeSend(event) {
  if (
    typeof window !== 'undefined' &&
    (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
  ) {
    return null;
  }
  return event;
},
Enter fullscreen mode Exit fullscreen mode

Those look redundant. They are not, and the overlap is the point.

The first one is the real gate, and it is keyed on the build rather than the address. That matters because of how the practice games get tested: a lot of this product is timed, touch-driven and only honest on a real phone, so a dev server is routinely reached from another device over a LAN IP or a tunnel hostname. A hostname check would have let every one of those sessions report, and a hostname allowlist would need a new entry every time the tunnel changed.

The second one catches the case the first cannot: a production build running on your own machine. next start locally, a production bundle served to check something that only reproduces in a real build. NODE_ENV is production there, quite correctly, and without the beforeSend check every stray local error would land in the same place as real user errors.

One guard is about which code this is. The other is about where it is running. Neither implies the other.

The ignore list, and what it admits

Thirteen strings:

ignoreErrors: [
  'top.GLOBALS',
  'chrome-extension://',
  'moz-extension://',
  "Can't find variable: ZiteReader",
  'jigsaw is not defined',
  'ComboSearch is not defined',
  'fb_xd_fragment',
  'NetworkError',
  'Network request failed',
  'Failed to fetch',
  'Load failed',
  'ResizeObserver loop limit exceeded',
  'ResizeObserver loop completed with undelivered notifications',
],
Enter fullscreen mode Exit fullscreen mode

Plus four URL patterns, which do the same job from the other end:

denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i, /^moz-extension:\/\//i],
Enter fullscreen mode Exit fullscreen mode

The list has three distinct kinds of entry in it, and it is worth separating them because only one kind is uncontroversial.

Other people's software. ZiteReader, ComboSearch, jigsaw, fb_xd_fragment, the extension protocols. These are errors thrown by code we did not write, injected into our page by something the visitor installed. There is no version of this that is our bug and no action we could take. Dropping them is free.

Browser noise that is not an error. The two ResizeObserver entries. A loop notification from a layout observer is a browser telling you it gave up on a frame, not a fault, and it fires in bursts on exactly the kind of responsive layout work this app is full of. Left in, it is the single loudest thing in the project.

Network failures, which are a real trade. NetworkError, Network request failed, Failed to fetch, Load failed.

That third group is the one I would argue about with a reviewer, because it is not noise in the same sense. Those four strings are what a browser says when a request did not complete, and a request that did not complete is sometimes genuinely our fault: a bad route, a dead endpoint, a response that never came.

They are ignored anyway, and here is the honest reasoning rather than the comfortable one. On a public site, the overwhelming majority of those events are a person on a train, a tab suspended mid-request, an aggressive corporate proxy, an ad blocker eating a third-party script, a browser extension cancelling a fetch. None of that is actionable and all of it is indistinguishable, from the client, from the case that is ours.

What makes the trade survivable is that it is not our only signal. A request failing because the server broke shows up on the server, where it has a stack trace and a request id instead of a four-word string. Leaving the client version in would mean a dashboard where the one real failure is buried under a thousand suspended tabs, and a dashboard nobody reads catches nothing at all.

It is still a cost, and writing it down as a cost rather than as a best practice is the only way to notice if it ever stops being worth paying.

The sample rate that is zero on purpose

// Replay is scoped to errors only. Random-session replay is PostHog's job
// (sampled there); what this keeps is the buffered seconds leading up to an
// actual error.
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
Enter fullscreen mode Exit fullscreen mode

Two tools in this app can record a session. If both do it at a sample rate, you pay twice for the same seconds and you still have to guess which recorder caught the session you want.

So the duty is split by question, not by percentage. "What do people do on this page" is a product question, answered by the analytics tool with its own sampling. "What happened in the seconds before this specific exception" is a debugging question, answered here, at 100%, for errors only. A zero in a config file is usually a mistake; this one is a boundary.

Tracing gets the conventional treatment and nothing clever: tracesSampleRate: 0.1 in production, zero in development.

The recorder itself is not in the bundle

Session Replay is one of the heaviest parts of the browser SDK, and listing it in integrations ships the recorder to every visitor whether or not they will ever be sampled. So it is attached afterwards, from the vendor's CDN:

Sentry.lazyLoadIntegration('replayIntegration')
  .then((replayIntegration) => {
    Sentry.getClient()?.addIntegration(replayIntegration({ maskAllText: true, blockAllMedia: true }));
  })
  .catch(() => {
    // Replay unavailable this session, error and tracing reporting continue.
  });
Enter fullscreen mode Exit fullscreen mode

The empty catch is deliberate and it is the only empty catch in that file I am happy with. A blocked CDN, an offline moment or a content blocker costs this session its replay. It must not cost the session its page, and it must not cost it error reporting either, which is why this is attached after init rather than being part of it.

maskAllText and blockAllMedia are both on, which for a product where people type answers into practice tests is not a tuning decision. A replay that shows what somebody typed into a timed assessment is not a debugging artefact, it is a recording of their attempt.

The shape of it

Every line above is a decision about not collecting something: not in development, not from a production build on my own laptop, not from extensions, not from layout churn, not from failed fetches, not from sessions that did not error, not with text visible, and not at all if the recorder fails to arrive.

Capture everything and you learn nothing. The config that matters is the subtraction.

Top comments (0)