It’s a Monday. You open Sentry before you open anything else, because that’s the job now. There’s a new issue at the top. A white screen, a component that threw, a stack trace that’s been minified into hieroglyphics. The error message is undefined is not an object. Helpful.
You stare at it. The real question isn’t what broke. React already told you what broke. The question is whose it is.
Usually you can answer it. Read the stack trace, recognize a component, match it to a team. Most of the time that’s a minute of work. Occasionally you guess wrong, ask in a channel, and someone points you the right way.
None of that is hard. It’s just manual. A small tax you pay every time an error comes in, on a question the app already had the answer to and simply never wrote down.
The boundary that caught everything and told you nothing
We weren’t reckless. We had error boundaries. We’d done the React thing. Wrap the app, catch the throw, show a polite fallback instead of a blank white void.
The problem is what “the React thing” looked like in practice. One boundary, sitting at the very top of the tree, catching everything. A single net under the entire trapeze act.
It did its job. The app didn’t show users a stack trace. It showed them a tasteful “something went wrong” screen instead. That part worked.
But from a triage perspective, every error in the building arrived wearing the same uniform. A crash in the course player and a crash in the billing settings and a crash in a dashboard widget all showed up in Sentry looking identical. Same boundary. Same fallback. Same shrug.
We had observability into that something went wrong. We had nothing into who should care.
In a company with more than one team, errors aren’t homeless. Every screen belongs to someone. The course player is Courses. Billing is Business. Integrations is, well, Integrations. The information existed. It lived in people’s heads and in the org chart and in the routing table. It just never made it into the error report.
So we kept answering, by hand, a question the code could have answered for us.
The fix is not “catch more errors”
When the team finally sat down to fix this, the first instinct was wrong. Mine included. We assumed it was a coverage problem. More boundaries. Smaller boundaries. Catch errors closer to where they happen.
That’s part of it. But it’s not the part that mattered.
The part that mattered was attribution. An error report is only as useful as the question it answers, and the question we cared about was ownership. We didn’t need to catch errors better. We needed every error to walk into Sentry already knowing its team.
Sentry has a primitive for exactly this. Tags. You can attach arbitrary key-value pairs to an event, then filter and alert on them later. team:courses. team:business. Suddenly the dashboard isn’t a pile of anonymous stack traces. It’s filterable by the people who can actually fix things.
The only question left was where to attach the tag. And that question turned out to be more interesting than it sounds.
Tag the route, not the component
The obvious place to put a team tag is inside the component. The course player knows it’s the course player. Just have it announce itself.
Good thing we didn’t.
Because component ownership is a lie that drifts. Teams get renamed. Responsibilities shift. A view that belonged to Courses in January can become Content’s responsibility by June as team boundaries and ownership evolve over time. If the team identity is sprinkled across a hundred components, updating it means a hundred edits and a prayer that you found them all.
The routing table doesn’t drift like that. It’s the one place in the app where “this screen exists, and here’s how you reach it” is written down on purpose. If you want a single source of truth for who owns what screen, the route definitions are already it. You’re just not using them that way yet.
So that’s where the tag goes. We wrote a small higher-order component that wraps a view in a Sentry error boundary and stamps the team onto the error before it’s ever sent:
export const withSentryBoundary = <P extends object>(
Component: React.ComponentType<P>,
{ fallback = <FallbackErrorScreen />, tags = {} }: WithSentryBoundaryOptions,
): React.FC<P> => {
const WrappedComponent: React.FC<P> = (props) => (
<ErrorBoundary
fallback={fallback}
beforeCapture={(scope: Scope): void => {
Object.entries(tags).forEach(([key, value]) => {
scope.setTag(key, value);
});
}}
>
<Component {...props} />
</ErrorBoundary>
);
WrappedComponent.displayName = `withSentryBoundary(${Component.name})`;
return WrappedComponent;
};
The important word there is beforeCapture. It’s a hook Sentry gives you that runs on the boundary’s own scope before the error event leaves the building. So by the time the crash is reported, the team tag is already on it. Not added later. Not inferred. Already there, attached at the moment of capture.
And then in the routing table, each view declares its owner exactly once, right where it’s wired up:
withSentryBoundary(Catalog, { tags: { team: Teams.COURSES } })
withSentryBoundary(Dashboard, { tags: { team: Teams.BUSINESS } })
withSentryBoundary(Automations, { tags: { team: Teams.INTEGRATIONS } })
The teams themselves live in one tiny file, so nobody invents a typo’d team name at two in the morning:
const Teams = {
BUSINESS: "business",
CORE: "core",
COURSES: "courses",
CONTENT: "content",
INTEGRATIONS: "integrations",
} as const;
That’s the whole trick. The error doesn’t know what team it belongs to. The route does. And the route is the thing least likely to lie to you.
Three nets, not one
Once the tagging worked, the coverage question came back. This time we knew why we were answering it. Different crashes deserve different blast radii.
So we ended up with three layers, and they’re layered on purpose.
At the very top, in the app’s entry point, there’s still one boundary catching everything. The last line of defense. If something escapes every other net, this one stops the user from staring at a blank document, and shows a full-page fallback instead.
<ErrorBoundary fallback={<FallbackErrorScreen isFullPage />}>
{/* the entire application */}
</ErrorBoundary>
This boundary should almost never fire. When it does, it means something got past everything else, and that’s worth knowing on its own.
The middle layer is the route level. The withSentryBoundary wrappers from earlier, one per view, each carrying its team tag. This is where most real crashes get caught, named, and contained. A view blowing up takes down its own screen and reports itself to the right team. The rest of the app keeps running.
There’s a small detail in our dashboard layout that I’m quietly fond of. The route boundary is keyed by the current path:
<ErrorBoundary fallback={<FallbackErrorScreen />} key={getErrorBoundaryKey(pathname)}>
{/* the routed view */}
</ErrorBoundary>
React remounts a component when its key changes. So when a user lands on a crashed screen, sees the fallback, and clicks away to a different view, the key changes, the boundary remounts, and the error state clears itself. The user navigates out of the wreckage instead of being trapped in it until a hard refresh. Crash recovery, for free, from a prop React already had.
The third layer is the smallest, and it’s where this stops being theory. Our dashboard is a grid of widgets. Each one fetches its own data, lazy-loads its own code, and is fully capable of throwing on its own. If a single misbehaving widget could take down the whole dashboard, the page would be one bad API response away from a white screen.
So every widget gets its own boundary, wrapped right around its Suspense:
<ErrorBoundary
fallback={<ErrorBoundaryScreen />}
beforeCapture={(scope) => scope.setTags({ team: Teams.BUSINESS })}
>
<Suspense>
<Component {...widgetProps} />
</Suspense>
</ErrorBoundary>
One widget dies, you get a tidy little “couldn’t load this widget” card in its place. The other eleven widgets don’t even notice. The dashboard stays a dashboard.
Top of the app, you want the net wide and the failure loud. Down at a single widget, you want the net tiny and the failure invisible to everything around it. Same primitive, three sizes, chosen by how much should be allowed to break.
A tag is only worth what the inbox is worth
There’s a part of this that’s easy to skip and shouldn’t be.
A team tag is only useful if team:courses actually means “Courses has a bug to fix.” The moment Sentry fills up with noise, the tags stop meaning anything. A user’s flaky wifi. A browser extension throwing inside your bundle. A 404 from an endpoint that was always going to 404. People start ignoring the channel. And an alert everyone ignores is worse than no alert, because it cost you the effort of building it and gave you nothing back.
So we spend real effort on the front door. Before an error is ever sent, it goes through a filter. Connectivity blips get dropped. The framework’s own known-noisy errors get dropped. Client-side 4xx responses get dropped too. The user asked for something they weren’t allowed to have. That’s not a crash, that’s Tuesday.
beforeSend(event, hint) {
if (isFramelessStackOverflow(event)) return null;
const status = getHttpStatus(hint?.originalException);
if (typeof status === "number" && status > 400 && status < 500) return null;
return sanitizeErrorEvent(event);
}
Returning null means the event never leaves. It’s a small function with an outsized job. It’s the reason the team tags point at real work instead of pointing at static.
The tagging tells you whose bug it is. The filtering is what makes “whose bug” a question worth answering at all.
What it actually bought us
One more piece of honesty. We didn’t build this from scratch in a vacuum. We’d been leaning on react-error-boundary, the community library, and at some point realized that @sentry/react ships its own ErrorBoundary that already knows how to talk to Sentry’s scope. The beforeCapture hook, the scope, the tagging. All native. So we migrated onto it. One fewer dependency, and a boundary that speaks the language of the tool we were already paying for.
The payoff isn’t dramatic in the way a shiny feature is dramatic. Nobody sends you a thank-you note for an error report that routes itself. But the morning ritual changed. The new issue at the top of Sentry now arrives wearing a team name. You read the tag, you know if it’s yours, and if it isn’t, you move on with your coffee still warm.
The things I’d take from it, if you’re staring at your own pile of anonymous stack traces:
- Catching the error is the easy half. Attributing it is the half you keep doing by hand. It’s rarely hard, just repetitive, and repetitive is exactly what you should hand to the code. Optimize for the question you actually ask at triage: “whose is this?”
- Put ownership where it can’t drift. The routing table already knows which screens exist. Let it be the one place that knows who owns them, too.
- Match the boundary to the blast radius. Wide and loud at the top, tiny and silent around a widget. One primitive, sized to how much is allowed to break.
- Guard the inbox like it’s the product. A tag pointed at noise is worse than no tag. Drop the connectivity blips and the 4xxs before they ever land.
A white screen will always be a bad day for somebody. We didn’t fix that. We just made sure the report knows whose bad day it is, and that it shows up before they do.
