ADR-149: Cardinality guard panics at metric registration time
Status: Accepted Date: 2026-04-21
Context
ADR-005 sets the library rule: an
attribute becomes a Prometheus label only if its value set is bounded by
construction, the bound is small enough a human can name it, and a
dashboard actually needs to group by that attribute. Everything else
is a field. The rule is prose; it needs a mechanism.
High-cardinality labels are catastrophic-on-a-delay. A service can add
user_id as a label, ship the change, run fine for days as the user set
grows, then melt prometheus when the series count crosses the
memory ceiling. By the time symptoms appear, weeks of samples exist in
the bad shape and cleanup is expensive — the historical data cannot be
retroactively re-labelled, and the retention window is where the
cardinality lives.
Phase 2b's metrics sub-package exposes Counter, Histogram, and
Gauge wrapper constructors. Each has a pluggable label set. A wrapper
that accepted any label name would be a loaded foot-gun; the library's
job is to make the ADR-005 rule mechanically enforced for the known bad
names.
Three enforcement points are possible: registration time (when the
metric is constructed), observation time (when WithLabelValues is
called), or ingest time (Alloy relabel pipeline). Ingest already exists
for Loki labels but does not cover prometheus — prometheus scrape
targets are trusted and not passed through relabel for cardinality
reduction. That leaves registration and observation.
Decision
The metric-wrapper constructors panic at registration time if any
label name matches an entry in BannedLabels (normalised,
case-insensitive and underscore-insensitive). The banned list lives in
the metrics sub-package as a package-level var, extensible from
consumer init().
Panic, not return error: registration happens at process start, almost
always from init() or a startup wire-up function. A panic there fails
the process before it serves traffic — exactly the failure mode that
matches the class of problem ("you are about to create unbounded
series"). Returning an error would require every call site to handle it,
and "mishandle a metrics registration error" is not a failure mode
anyone tests.
Registration time, not observation time: WithLabelValues(userID)
happens on the hot path for every request. Checking each call against a
ban list is per-request cost for a startup-only concern. Registration is
a fixed small set of calls, run once; the cost of the guard is
negligible and paid at the moment the mistake is introduced.
The normalisation strips underscores and lowercases letters, so
UserId, user_id, USER_ID, and userid all trip the same entry.
Case-only matching would leak UserId past the guard because
EqualFold("UserId", "user_id") is false on the underscore — a
foot-gun we would rediscover in a code review.
Consequences
- A banned label fails the process at boot, not at first incident.
A PR that accidentally adds
"user_id"to a metric's label slice cannot ship: the test suite boots the package, theinit()or startup wire-up panics, the test run fails. Feedback arrives in seconds, not weeks. - Panic in
init()is harsh, and we accept that. If a consumer extendsBannedLabelsand then registers a metric using one of their extensions, their owninit()ordering matters — extensions must come before registration. Documented in the sub-package README. - The banned list is the library's policy, not the service's.
Extending it (e.g.
tenant_id,device_idin a multi-tenant service) is a deliberate act inside that service'sinit(); shrinking it is not supported. A service that wants to remove a banned entry is trying to do the exact thing the ADR exists to prevent — there is no API to do so. - Normalisation catches the most common mistake. Labels copied from
struct field names (
UserId) or from an env var pattern (USER_ID) all trip the guard. False positives are bounded: we would have to ban a word that legitimately appears as a metric label, and none of the current defaults qualify. - Alert on absence of the guard is not needed. The guard's correctness is enforced by the test suite — the sub-package tests cover every banned name and every casing variant. If the guard regresses, CI goes red before a PR merges.
Alternatives considered
- Log a WARN and register anyway. Preserves availability on a typo, but the whole failure mode is "silent-until-catastrophic". A log line that nobody reads is equivalent to no guard at all; this is the default-trap that ADR-005 exists to break. Rejected outright.
- Return an error and make the caller handle it. Pushes the decision
to every call site. In practice that means
registerOrDie(...)helpers that panic anyway, just one function deeper. We shortcut. - Check at observation time via a wrapped
With/WithLabelValues. Protects against runtime-computed label keys (impossible in Go given the label schema is fixed at registration) but costs a map lookup per observation on the hot path. Rejected: the API does not let you change the label schema per observation, so the runtime check would fire on every label set the wrapper constructed. - Lint rule on the gyrum-go repo. A static check would catch the mistake in PR review before it merges. Complements the runtime guard, does not replace it — a consuming service can still pass an untrusted label name through a variable the linter cannot trace. The runtime guard is the backstop; the linter (not yet built) would be the early warning.
- Case-insensitive only, no underscore normalisation. Simpler code,
catches fewer mistakes. Rejected when the tests proved that
EqualFold("UserId", "user_id")is false — that exact case is the most common struct-field-to-label conversion in Go.
Supersedes: none Superseded by: