Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

.nornir/metrics-design.md

Prometheus /metrics — design

Class: design · Function: metrics (uses serve/exposed::http for the scrape door and each backend's list() for the artifact gauge, and the retention executor for the run counter). The whole-system view is design.md; the parity gap this closes is feature-complete-gaps.md §11 (ops / observability — previously only /healthz + /readyz, /metrics ❌) and artifactory-parity-design.md §K.

What this is

A read-only GET /metrics door on holger's existing HTTP/OCI gateway that emits Prometheus text exposition format 0.0.4 for real registry activity: request counts per verb, bytes served, 5xx errors, retention runs, and a per-repository artifact-count gauge. It is the full-tier operability win the gap doc names — self-contained, high-value, zero new dependencies.

It ships as server/lib/src/metrics.rs — a handful of AtomicU64 counters behind a process-global metrics::global() accessor, plus a pure render function:

Metrics::render_prometheus(repo_counts: &[(String, usize)]) -> String

The render does no I/O of its own — the per-repo gauge is driven entirely by the (repo, count) list the caller gathers — so it is unit-testable with an isolated Metrics instance and reused verbatim by the live door.

The exposed families

Family Type Labels Bumped at
holger_requests_total counter verb = get/put/delete/promote/search/sbom every HTTP door outcome AND every gRPC RPC outcome
holger_bytes_served_total counter — every HTTP door outcome (response bytes)
holger_errors_total counter — every 5xx HTTP outcome
holger_retention_runs_total counter — Holger::execute_repo_retention
holger_proxy_requests_total counter result = primary_hit/upstream_hit/negcache_hit/miss/upstream_error every ProxyBackend read outcome (fetch + handle_http2_request)
holger_request_duration_seconds histogram — (_bucket{le} / _sum / _count) every HTTP door exit, alongside the request counter
holger_repo_artifacts gauge repo scrape time (list() per backend)

Counters are lifetime-of-process (reset on restart, as Prometheus expects); holger_repo_artifacts is a point-in-time gauge sampled on each scrape.

The chosen design: bump at the natural call sites, render on scrape

The gap doc frames it as "Prometheus /metrics (per-repo request / bytes / hit-miss counters) — self-contained, high-value for full operability." So:

Why the per-repo gauge is the one scrape-time I/O

Counters are just an atomic load, but holger_repo_artifacts needs the live artifact count per repository, which means calling each backend's list(). That is real I/O, so the door runs it under spawn_blocking (off the async reactor, exactly as the /-/search door does) and bounds each listing at METRICS_LIST_CAP (100k) — the gauge is a health signal, not a census. A backend that errors reports 0 rather than failing the whole scrape.

No hot-path regression (Law #2)

The only cost added to the request path is one Relaxed atomic add per request (the verb/bytes/error bump), issued at the same single exit point that already writes the audit row — no new lock, allocation, or syscall. The scrape's list() cost is paid by the (infrequent) scraper, off the reactor, not by artifact traffic.

gRPC-plane counting — one choke point, one shared classifier

gRPC is holger's primary protocol ("gRPC only by design"), so counting only the HTTP door left the main surface invisible in /metrics. Both doors now feed the same holger_requests_total{verb} family:

Proxy hit/miss counting — one bump per read outcome

The proxy/group repo (proxy::ProxyBackend) chains a primary backend with an ordered upstream fallback list; every read exits through exactly ONE clean outcome, and each is bumped on the process-global registry as holger_proxy_requests_total{result="…"} (Law #5 — the same metrics::global() registry, a new label family, no twin):

Summing the four request outcomes is total proxy traffic; (primary_hit + upstream_hit) / total is the cache hit-rate an operator watches. The bump is one Relaxed atomic add at each outcome point (no lock, no allocation) — dormant unless a repo is configured with upstreams, so a plain repo pays nothing.

Request-duration histogram — measured where the counter is

holger_request_duration_seconds is a lock-free Prometheus histogram over the HTTP door's request-handling latency: a std::time::Instant is captured at the top of exposed::http::handle and observed (observe_request_duration) at each door's exit right where record_request bumps the counter — so the histogram and the counter cover the exact same handled-request population (search, sbom, property-write, and the artifact record closure). Implementation:

Scope (honest): this observes the HTTP door. The gRPC plane's latency is a documented follow-up — the gRPC counter choke point (HolgerGrpc::record_audit) sees an AuditEvent with no duration, and the metrics design deliberately avoids threading a per-RPC start Instant into every handler. The right home for gRPC latency is a tower timing layer in the shared edda/nornir-service serve loop (Law #5 — it would time nornir/dwarves/holger uniformly), which is an edda change out of scope for a holger-repo slice.

What is deliberately NOT here (honest scope)

Testing (Law #4)