Why Sentry User Context Goes Missing
Sentry shows "Unknown User," or the wrong user, for one of two reasons: Sentry.setUser() didn't actually execute on the code path that produced the error, or it executed somewhere the event never picks it up from. Sentry has no built-in identity detection. The user context docs are explicit that you call setUser() yourself, and if that call is skipped, delayed, or scoped to the wrong place, the event ships with a gap where the person should be.
Missing and inconsistent are two different failure modes with two different causes. Missing means the call never ran on that path: a queue worker that never touches your auth middleware, a webhook handler that fires before login, or setUser() called during server render before the session has hydrated. Inconsistent means the call did run, just not recently enough or not for the right person, so data set on one scope survives into an event where it doesn't belong. Both come from the same root fact. Sentry's SDKs are built around scopes, and scopes fork, expire, and reset in ways that aren't obvious from the outside.
Sentry hands over a user identifier only when a setUser() call happens to land inside the right scope. Everything downstream of that identifier has to come from somewhere else, which is the gap the rest of this guide is about.
Sentry doesn't know who's logged in until you tell it, every time
setUser() takes whatever you pass it — id, email, username, or none of those — and attaches it to events captured after the call. There's no session lookup, no cookie read, no framework hook that fires it automatically. That's by design, and it's also the most common source of "missing": a teammate adds a new entry point (a background sync, a scheduled export, a second frontend for internal tools) and it simply never calls setUser(), so every error from that path reads as anonymous no matter how much traffic it gets.
The subtler version of the same problem is timing. In SSR frameworks it's common to call setUser() as soon as a request handler starts, before the session cookie has been parsed. The call runs, but on an empty or partial object, and the first render's errors carry a technically-set-but-useless user field. If your issue list shows a mix of populated and empty user data on the exact same error, this ordering bug is worth checking before anything else.
Scopes fork, and the fork doesn't always carry your data forward
Sentry's SDKs organize context into three scope types: a global scope for data that should apply everywhere, an isolation scope that separates one unit of work from another, and a current scope that's "generally valid inside of a callback or an execution context." Most setUser() calls write to the isolation scope, and the docs are direct that scope forking "may happen under the hood at various points" and that there are "no guarantees about the consistency of getCurrentScope across different parts of your application."
In a Node server, that isolation scope is what makes setUser() safe to call per-request in the first place. The Node SDK API reference describes setUser() as setting the user "for the currently active request," and the SDK's web framework integrations fork a fresh isolation scope around each request handler automatically, separate from the manual Sentry.withIsolationScope() call you'd reach for to create an isolation scope elsewhere in your code. That's the right behavior for concurrent requests not bleeding into each other. It also means anything that runs outside a request's isolation scope, a cron job, a queue consumer, a webhook that fires before your auth middleware ever executes, never inherits a user at all. The instrumentation on your web tier can be perfect and those events will still show "Unknown User," because they were never inside a scope that had one.
In the browser, the failure looks like the wrong answer, not no answer
Server-side, a missing scope usually means an empty field. In the browser, it usually means a stale one. The scopes doc is direct about why: "in the browser, the isolation scope is never forked, because it is impossible to keep track of where an isolation scope would belong to." There's no per-request boundary to reset it, so call setUser() once and that data holds for the rest of the tab's life unless something explicitly overwrites or clears it.
That's fine for a single-user session. It breaks the moment a browser context outlives one identity, on a shared kiosk, when a support rep switches between customer accounts in the same admin tool, or during a slow logout flow where a new person starts clicking before the old session's user gets cleared. If nothing calls Sentry.setUser(null) on logout, the next real user's genuine errors get filed under the previous person's identity. Nobody notices because the field isn't empty; it's just wrong, and wrong-but-populated is a much harder thing to catch in a dashboard than a blank field is.
The night Voltway's login errors pointed at the wrong driver
Owen Castellano is a backend engineer at Voltway, which runs software for EV charging stations installed at fleet depots. Drivers check in on a shared tablet mounted at each charger, log into the fleet app with their own account, plug in, and log out for the next driver.
A Sentry issue fires at 11pm: TypeError: Cannot read properties of null (reading 'accountId') in the session-refresh handler, tagged to user.email: r.alvarado@voltway-fleet.com. A fleet ops lead is already asking about it.
Fleet ops lead: Depot 4's tablet is throwing an error every time someone tries to plug in. Is this the same account issue Rosalind reported an hour ago?
Owen pulls up the issue. The email on the event is Rosalind's, but her shift ended an hour ago, and the ops lead is describing a different driver hitting it right now. He checks the code and finds the gap. The fleet app calls Sentry.setUser() when a driver logs in, but nothing calls Sentry.setUser(null) when they log out, and because a browser's isolation scope never forks, Rosalind's identity didn't clear when her shift did. The session-refresh handler runs the moment a tablet reconnects, before the next driver has logged in, so every driver who plugs in and hits that crash before finishing their own login gets it filed under Rosalind, since nobody's setUser() call has run yet to overwrite hers.
Owen: Found it. The crash fires before login even starts, so it's still running under Rosalind's session from an hour ago; nothing clears the Sentry user on logout. Patching the logout path now, but the drivers who hit this in the last hour are all showing as Rosalind and there's no clean way to relabel those events after the fact.
The fix itself was small, a single Sentry.setUser(null) call added to the logout path. What stayed a problem was everything downstream of it, figuring out which of the last hour's "Rosalind" events involved other drivers meant cross-referencing shift schedules by hand, and even then, the events themselves would never be relabeled.
Where fixing the instrumentation stops being the whole fix
Getting setUser() and setUser(null) called correctly on every login and logout path is necessary, and it's genuinely tractable for one app. It stops being tractable once identity has to be right everywhere at once:
- Every surface needs its own logout hook. Voltway's kiosk tablets, mobile app, and admin dashboard each needed the fix independently; missing it on one surface reintroduces the same bug there.
- New backend surfaces inherit the gap by default. A new queue consumer or scheduled job starts with no user context until someone remembers to wire it in, and there's no alert that tells you a code path is silently anonymous.
- Sentry still only knows about users, not accounts. Even instrumented correctly, an email address on an issue doesn't tell you which company, which plan, or which support conversation that person is already part of. That join has always lived outside Sentry.
That's the point where the practical answer stops being "audit every logout path again" and starts being a system that adds account context on top of whatever identifier did arrive, instead of depending on every setUser() call being correct. That's the layer Modem works on: Modem's Sentry integration reads issues and whatever identifier did make it through, a user email, an order ID, anything short of a fully empty setUser() gap, then resolves it against your support and CRM data at the account level. It doesn't recover an identity that nothing ever captured; a queue worker or webhook path with zero identifiers in the event stays unattributed either way. What it closes is the join Sentry never makes on its own, connecting whatever identifier did arrive to the company, plan, and support history behind it. We build Modem, so weigh that against doing the instrumentation audit yourself; either way, once identity is attached, the next question is usually which customers actually hit the error, which is a separate join on top of everything above. The account-level version of that join is covered in what a customer context graph is.
The smallest version you can start this week
Grep your codebase for every call to Sentry.setUser(), then check each one has a matching Sentry.setUser(null) on the corresponding logout or session-end path. Do the same check for any shared-device flow specifically, kiosks and shared logins are where a browser's global isolation scope turns a missing call into a wrong answer instead of an empty one. That pass alone catches the failure mode that looks the most like a data quality problem but is a one-line fix.
