Decisions

ADR-149: Cardinality guard panics at metric registration time

[ADR-005](./005-cardinality-labels-vs-fields.md) 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…

#149

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, the init() 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 extends BannedLabels and then registers a metric using one of their extensions, their own init() 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_id in a multi-tenant service) is a deliberate act inside that service's init(); 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: