0015. Prometheus metrics surface and ssl_exporter name compatibility
- Status: Accepted
- Date: 2026-07-10
Context
certel wants operators’ existing ssl_exporter dashboards and alerts to port over, but
its architecture differs fundamentally. ssl_exporter uses the multi-target pattern:
one scrape per endpoint, identity carried by a relabeled instance label. certel serves
every target from a single scrape target and attaches its own
host/address/protocol/servername labels. Claiming “compatible with ssl_exporter
dashboards” would set an expectation the first user with a real $instance-templated
Grafana dashboard disproves in five minutes. The whole metrics surface also needed
designing as a coherent whole rather than accreting one gauge at a time.
Decision
The full surface, its naming/label policy, per-metric semantics, absence rules and the
explicit rejections live in docs/metrics.md
; this ADR records the
load-bearing decisions:
- Compatibility is on metric names and meanings, not the label model. The
ssl_*prefix is a closed set of exactly three (ssl_probe_success,ssl_cert_not_after,ssl_verified_cert_not_after) — frozen, never renamed or grown; any new cert metric goes undercertel_*. The README says “ssl_exporter-compatible metric names” with aninstance → address/hostmapping note. certel_target_infois the presence anchor. Every measurement series exists only after a target’s first probe, so a never-probed target is invisible to alerting andabsent()can’t express “for every configured target.”certel_target_info(constant1, set from config alone at startup) is the one unconditional series, enablingcertel_target_info unless on(...) ssl_probe_success. Its property labels (e.g.insecure) join onto per-target series rather than widening the identity family.- Publication is atomic per scrape via a single snapshot collector. The last
probe.Resultper target sits in a map under a lock; every per-target series is derived inside oneCollect. Separate vecs would letGatherlock each family independently and a scrape could catch an impossible pair (freshssl_probe_success == 1beside a stale/deletedssl_cert_not_after). - Zero is never exported to mean “unknown.” A zero timestamp reads “expired in 1970”;
“no data” is expressed as series absence plus
ssl_probe_success == 0. The snapshot model makes absence fall out for free.
Consequences
- The identity label family (
host,address,protocol,servername) is frozen alongside thessl_*names: adding an identity label would break history, shiftsum without(), and stop full-list joins matching.servernameis the raw config value so the label set is injective with the store’starget_key— and its presence prevents two targets that differ only in servername from deleting each other’s series during stale-CN cleanup. - Cardinality invariant: every label is either config-bounded (O(targets)/
O(notifiers)) or churn-bounded (a stale series vanishes at the moment of change) —
never free-form. A CN-changing renewal replaces (not accretes) the
ssl_cert_not_afterseries, which also defuses a malicious server minting a fresh CN per probe (the cardinality-attack surface named in SECURITY.md). - A metric earns its place only if an operator would alert on it or trend it; the same bar applies to labels. Everything else stays in logs / SQLite.
- The scheduler exports
certel_probe_cycle_staleness_threshold_seconds(thelivenessThresholdderivation from 0014 ) beside the completed-timestamp gauge, so a staleness alert never hardcodes a bound that rots when config changes.
Alternatives considered
(Full list in docs/metrics.md
§“Considered and rejected”.)
certel_probes_totalcounter. Rejected: probing is constant-rate by construction (targets / check_interval, knowable from config); success-over-time isavg_over_time(ssl_probe_success[…]); retry churn lives inprobe_log.attempts.issuer_cn/serial_nolabels onssl_cert_not_after. Rejected — not on cardinality grounds (the snapshot collector bounds them) but because no alert or panel selects by issuer/serial; per-occurrence detail belongs in the log.cnstays only because imported dashboards template on it.- Severity as a state-set; a probe-status state-set; a probe-duration histogram; a
days-left gauge; a
kindlabel on the sends counter. All rejected, each for a reason recorded indocs/metrics.md(ordered gauge is one-third the series; one observation per interval makes a histogram pointless; a zero days-left gauge is ambiguous;kindre-smuggles kind-awareness into a queue designed without it — 0004 ). - A real
/probe?target=multi-target endpoint (which would make the compat claim literally true). Rejected: a separate architectural change, not worth it just for wording.
References
docs/metrics.md— the settled, living design.internal/metrics/metrics.go. Related: 0002 , 0014 .