ZenPlus APM

Error tracking and triage

How exceptions are grouped into issues, what the fingerprint collapses and what it deliberately keeps apart, and how to work the inbox so triage actually reduces it.

Applies to ZenPlus 1.6.0 Updated 2026-08-06 Audience Developers + operators Reading time ~8 min

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 awayExampleWhy
Memory addresses0x7f3a...0xADDRDiffers on every run
UUIDsany UUID → UUIDRequest and entity ids are not part of the bug
Line/column suffixesapp.py:214app.py:NShifts with unrelated edits
Bare integersretry 3retry NCounters 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.

Errors without an exception event

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.
Use the trend before the stack

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.

Errors are not alerts

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.