ADR-0013: Metrics instrumentation
Metadata
- Status: Accepted
- Date: 2026-07-25
- Deciders: EagraΓ Clainne Team
- Related: ADR-0007 (JWT authentication) and ADR-0008 (RBAC) β the auth
surface counted here; ADR-0009 (service-layer authorization) β the svc seam
the entity counter hangs off; ADR-0002 (centralised RPC authorization behind
auth.Allow) β the interceptor-layer denial point
Context
The server already exports three OTel signals over one OTLP pipeline (push to
a collector when EAG_OTLP_ENDPOINT is set, stdout exporters otherwise), but
the only metrics on it are what otelconnect emits per RPC:
rpc.server.duration, request/response sizes and message counts, tagged by
service and method. Nothing else is instrumented β no domain events, no
database health, no runtime, no auth outcomes. There is no Meter() call in
the codebase, and the MeterProvider built in cmd/server is handed only to
the otelconnect interceptor.
Two audiences want more, with equal weight:
- Operations: is the system healthy β DB pool saturation, query latency, GC pressure, error and denial rates.
- The family: what is the system doing β sign-ins, chores completed, rewards claimed.
The codebase gives natural seams to hang this on: every entity write funnels
through svc.Create/svc.Mutate/svc.Delete (ADR-0009), every query through
the typed table methods in internal/database, and every authorization
decision through auth.Allow or the svc guards.
Decision
-
One pipeline, no scrape endpoint. Metrics continue to leave the process exclusively via the existing OTLP push (stdout fallback). No Prometheus
/metricsendpoint; a collector fans out to whatever backend wants the data.cmd/servernow also callsotel.SetMeterProvider(mp), so packages reach the meter through the global β matching how the log bridge already uses the global provider β rather than threading it through constructors. -
All custom instruments live in
internal/telemetry. One package owns instrument creation (lazy,sync.Once) and exposes intent-named recording functions (CountLogin,RecordDBOperation, β¦). Call sites never touch the OTel API directly;auth,svc,databaseand handlers depend only oninternal/telemetry, avoiding instrument duplication and import cycles. -
Domain metrics are hybrid: one generic seam counter plus a few named counters.
eagraiclainne.entity.mutations{entity, action, outcome}is recorded once in the svc seam (Create/Mutate/Delete), covering all CRUD on all entities with zero per-handler code. The entity name is derived from the proto message descriptor, so new entities are covered automatically.outcomeissuccess,skipped(idempotent no-op viaErrSkipMutation) orerror.- Where the seam cannot see semantics, an explicit counter records the
domain event:
eagraiclainne.auth.logins{outcome},eagraiclainne.auth.tokens.issued,eagraiclainne.item.completions{complete},eagraiclainne.reward.claims.
-
Authorization denials are counted at both layers.
eagraiclainne.authz.denialscarrieslayer=interceptorwith the RPCprocedure(a closed set) whenauth.Allowrefuses, andlayer=servicewith theguardname (owner,event_member,admin) when an ADR-0009 guard refuses. Failed logins plus denial rates are the brute-force and misconfigured-permission signals. -
Database instrumentation sits on our own seam, not a driver wrapper.
eagraiclainne.db.operation.duration{table, operation, outcome}is recorded in the typed table methods β table-aware attributes a driver-level wrapper cannot provide, and no new dependency. Pool health comes fromsql.DBStatsobservable gauges (open/idle/in-use connections, wait count and wait duration), registered when the production pool is created and unregistered on close. -
Go runtime metrics come from
go.opentelemetry.io/contrib/instrumentation/runtimestarted at boot β memory, GC, goroutines under the standardgo.*semconv names. -
Cardinality is bounded by construction: no user identifiers in metric attributes. Every attribute is a closed set β entity, action, outcome, procedure, guard, table, operation. Per-person questions ("who completed the most chores?") are answered from the database and its mutation audit trail, which already record every actor; the metrics pipeline stays fixed-size forever.
-
Naming: custom instruments are OTel-style under the
eagraiclainne.prefix; standard areas (RPC, runtime) keep their semconv names. Default histogram buckets and temporality β no custom views until a dashboard proves the need. -
Testing bar: unit tests with
sdk/metric.NewManualReaderassert the load-bearing instruments fire with the right attributes β the seam counter, the DB histogram, logins and denials β not every attribute combination.
Consequences
Positive
- Both halves of the audience are served from one pipeline: RED-style ops signals (RPC metrics + DB + runtime + denials) and family-level activity (logins, completions, claims) with no second export path to run.
- The seam counter means CRUD coverage is automatic: a future entity type
gets mutation metrics the day
svc.Mutatehandles it. - Bounded attribute sets mean the series count cannot grow with usage β safe for any backend, nothing PII-ish in telemetry.
internal/telemetrykeeps the OTel API out of business code; swapping buckets, adding views or renaming instruments is a one-package change.
Negative / trade-offs
- No pull endpoint: a bare Prometheus cannot scrape the binary; a collector (or one deployed later) is a hard prerequisite for metrics in production.
- Global meter provider trades explicit dependency wiring for convenience; tests must set the global before instruments first fire in the process.
- Per-person analytics deliberately excluded from metrics β Grafana alone cannot draw a leaderboard; that view must query the DB.
- The generic
entity.mutationscounter records that an update happened, not what changed; anything needing more semantics gets its own counter, which is a judgement call each time.
Alternatives considered
- Native Prometheus
/metricsendpoint (alongside or instead of OTLP): works collector-less, but adds a second pipeline to keep consistent and diverges metrics from the traces/logs path. Rejected while the OTLP collector route serves the homelab. - Per-user attributes on domain counters: a family is small, so cardinality is bounded in practice β but the DB already answers per-person questions exactly, and identity labels in telemetry are a liability with zero ops value. Rejected.
- otelsql-style driver wrapper: per-query spans and metrics for free, but attributes are raw-SQL-level (no table/operation semantics) and it adds a dependency for what four table methods can record themselves. Rejected.
- Injecting the MeterProvider through constructors: more explicit, but touches every constructor and fixture for no behavioural gain when the global is already the pattern for logs. Rejected.
- Per-handler explicit counters everywhere (no seam counter): maximally explicit, ~25 call sites to keep in step, drift guaranteed. Rejected.