01 Issues, not events #
A single broken code path can throw thousands of times an hour. A list of those events is unreadable, so ZenPlus groups them into issues: one row per distinct failure, with an occurrence count, a first-and-last-seen, the affected services and versions, and a triage state.
Grouping happens at ingest, from a stable fingerprint, so an issue keeps its identity across restarts and releases — which is what makes “resolved” mean something.
02 How grouping works #
The fingerprint is a hash of the exception type plus a normalised stack trace (or the message, when no stack is available). Normalisation strips the parts that change between otherwise identical failures:
| Normalised away | Example | Why |
|---|---|---|
| Memory addresses | 0x7f3a... → 0xADDR | Differs on every run |
| UUIDs | any UUID → UUID | Request and entity ids are not part of the bug |
| Line/column suffixes | app.py:214 → app.py:N | Shifts with unrelated edits |
| Bare integers | retry 3 → retry N | Counters and ids vary per occurrence |
The exception type is never collapsed: a TimeoutError and a
ConnectionError from the same line stay separate issues, because they usually have different
causes and different fixes.
A span marked ERROR with no exception event still becomes an issue — typed Error and grouped by service and operation. You get the count and the affected route, but no type, message or stack. Emitting proper exception events is what turns “something failed here” into “this failed, and here is the line”.
03 The inbox #
The list defaults to Unresolved. That is deliberate: an inbox that keeps showing issues you already closed never visibly shrinks, so triaging it feels pointless. Every status chip carries a live count, including zero, and All shows the full set.
- Unresolved
- The default working set — new and untriaged.
- Resolved
- Fixed. If the fingerprint fires again it reappears in the list with its new occurrences, so a regression is visible rather than silently re-closed.
- Resolved in version
- Fixed in a specific release, for when the fix is shipped but not everywhere deployed.
- Ignored
- Known, accepted, and deliberately out of the way — a third-party client disconnecting, a health probe that always 404s.
Rows can be resolved or ignored inline from the check and eye icons, without opening the issue. The service dropdown scopes the inbox to one service; the time range applies to occurrence counts, not to triage state.
04 Issue detail #
Opening an issue gives you what you need to reproduce and fix it:
- Occurrence trend — hourly counts, which answers “is this getting worse, and did it start when we deployed?”
- Sample stack trace from the most recent occurrence.
- Representative trace — open the full waterfall for a request that hit this error, so you see the surrounding calls, not just the throw site.
- Per-service breakdown for fingerprints that fire in more than one place.
- Affected versions, from
service.version. - Recent occurrences, each linking to its own trace.
- Assignee and status.
The stack tells you where. The trend tells you when it started, which is usually a faster route to why — a step change at 14:05 next to a deploy at 14:04 is a stronger signal than any stack trace.
05 Retention and scope #
Exception events are kept for 30 days. Triage state lives in Postgres and does not expire, so an issue that stops firing keeps its resolution and reappears with history intact if it returns.
Nothing in the inbox pages anyone. To be woken up, create an alert rule on apm_error_rate, or define an SLO so error-budget burn raises an alert. The inbox is where you work errors; alerting is what tells you to start.