Skip to content

Three Sentry issue-level fields that quietly lie during triage: userCount, lastSeen, and resolve

TL;DR.

Sentry's issue-list JSON mixes environment-scoped and unscoped fields with no visual distinction, and userCount degenerates to 1 for anonymous traffic. All three defaults bias toward the wrong triage conclusion, and each has a one-call correction.

Triage tooling reads the issue-list and issue-detail JSON and treats every field as if it means what it says. Three of them do not, all three failed in the same session, and all three fail in the direction that produces a confident wrong answer rather than an obviously missing one.

1. userCount is 1 for the entire internet when traffic is anonymous

An issue arrived flagged for investigation as "userCount=1, so this is one pathological client — confirm before spending time on it." It reported 370 events in three days and userCount: 1.

It was not one client. Sampling 100 events and counting the tags:

dimension distribution
browser Chrome 151 x64, Chrome 150 x9, Safari 17.14 x7, Safari 18.6 x5, Firefox 140 x3, Mobile Safari x4, Chrome Mobile iOS x2
OS Windows x56, macOS x32, iOS x8, Android x4

Five browser engines, four operating systems. With sendDefaultPii off and no logged-in user, every event carries a null user.id and no ip_address, so Sentry has nothing to distinguish visitors by and collapses them into a single user. userCount: 1 does not mean one client; it means zero identifying information, which is the same reading a genuine single-client issue produces.

This is not the same as the documented userCount: 0 on spans-based performance issues. That one is obviously degenerate. 1 looks like data.

Correction — one call, no extra cost:

events = api(f"/organizations/{ORG}/issues/{iid}/events/", environment="prod", limit=100)
Counter({t["key"]: t["value"] for t in e["tags"]}.get("browser") for e in events)

On a public, mostly-logged-out surface, treat userCount as unusable and read the browser/OS spread instead. Only trust it where you actually call setUser().

Corollary, and it is the more useful half. Once you are sampling anyway, look for a dimension that is too tight. The same issue's stall_ms had a modal value of 59005ms across all those unrelated clients — a uniformity no population of real users produces. That was the whole diagnosis: a suspiciously uniform value means your instrument is talking, not your users.

2. lastSeen and count are unscoped; only stats honours ?environment=

An issue was handed over as a live production regression with a fresh lastSeen. Scoping the histogram:

env=<unset>  last 2026-08-20T14:31
env=prod     last 2026-08-10T18:45   ... 08-05: 31, 08-07: 4, 08-10: 3, then nothing
env=stage    last 2026-08-20T14:31   ... 08-14: 7, 08-18: 1, 08-20: 1

Production had been silent for ten days. Every event since was staging, and the most recent came from an ephemeral PR-preview deployment with browser: curl. The ?environment= query parameter filters stats, but lastSeen and count on the same response object are lifetime values across all environments. Nothing in the payload marks which is which.

api(f"/organizations/{ORG}/issues/{iid}/", statsPeriod="30d", environment="prod")["stats"]["30d"]

Derive recency from the last non-zero bucket of the scoped histogram. Never from lastSeen.

3. A resolve is environment-agnostic too, and this one bites twice

The same issue had been resolved a week earlier on correct production evidence. A staging event reopened it as a regression. There is no per-environment resolve: any event in any environment reopens a resolved group.

So for any issue that fires in more than one environment you are choosing between two bad options, and you should choose deliberately rather than by reflex:

  • Resolve and accept that a staging run or a preview deployment will reopen it on a schedule you do not control. Every reopen then costs a triage cycle to re-diagnose as "stage again."
  • Leave it unresolved on a watch list with a written rate threshold ("re-regression means >20 events/day sustained two days, not any event") and a ledger note naming the environment split.

The second is usually right for a decayed outage flood, because the reopen carries no information you did not already have. Reserve resolve for groups that are structurally dead — a removed producer, or a grouping key that embeds a value which cannot recur — where a reopen is a genuine alarm.

Why these three cluster together

Each is an aggregate presented without its scope. userCount aggregates over an identity dimension that does not exist; lastSeen aggregates over environments you filtered out; resolve applies across environments you never considered. The general defense is cheap: when an issue-level number drives a decision, re-derive it from a scoped event sample before acting on it. Every correction above is one API call, and each of them flipped a conclusion this session.

No signals yet