Skip to main content

featherbit/acme/
metrics.rs

1//! Prometheus series for managed certificates — enough for the standard
2//! "expires in < 7 d" and "renewal failing" alerts.
3
4use std::sync::Arc;
5
6use prometheus::{IntCounterVec, IntGaugeVec, Opts, Registry};
7
8use super::CertState;
9
10pub struct AcmeMetrics {
11    /// `featherbit_acme_cert_not_after_timestamp_seconds{cert_id}` (0 for placeholders).
12    pub not_after: IntGaugeVec,
13    /// `featherbit_acme_cert_state{cert_id,state}` — 1 for the current state, 0 otherwise.
14    pub state: IntGaugeVec,
15    /// `featherbit_acme_renewals_total{cert_id,result="success"|"failure"}`.
16    pub renewals: IntCounterVec,
17    /// `featherbit_acme_last_renewal_attempt_timestamp_seconds{cert_id}`.
18    pub last_attempt: IntGaugeVec,
19}
20
21impl AcmeMetrics {
22    /// Creates the collectors and registers them on `registry`.
23    /// Returns an error if any collector is already registered on this registry
24    /// (programming error; each registry must be used for at most one `register` call).
25    pub fn register(registry: &Registry) -> Result<Arc<Self>, prometheus::Error> {
26        let not_after = IntGaugeVec::new(
27            Opts::new(
28                "featherbit_acme_cert_not_after_timestamp_seconds",
29                "Expiry of the served managed certificate (unix seconds; 0 = placeholder)",
30            ),
31            &["cert_id"],
32        )
33        .unwrap();
34        let state = IntGaugeVec::new(
35            Opts::new(
36                "featherbit_acme_cert_state",
37                "Current state of a managed certificate (1 = current)",
38            ),
39            &["cert_id", "state"],
40        )
41        .unwrap();
42        let renewals = IntCounterVec::new(
43            Opts::new(
44                "featherbit_acme_renewals_total",
45                "ACME issuance/renewal attempts by outcome",
46            ),
47            &["cert_id", "result"],
48        )
49        .unwrap();
50        let last_attempt = IntGaugeVec::new(
51            Opts::new(
52                "featherbit_acme_last_renewal_attempt_timestamp_seconds",
53                "Unix time of the last issuance attempt",
54            ),
55            &["cert_id"],
56        )
57        .unwrap();
58        for c in [
59            Box::new(not_after.clone()) as Box<dyn prometheus::core::Collector>,
60            Box::new(state.clone()),
61            Box::new(renewals.clone()),
62            Box::new(last_attempt.clone()),
63        ] {
64            registry.register(c)?;
65        }
66        Ok(Arc::new(Self {
67            not_after,
68            state,
69            renewals,
70            last_attempt,
71        }))
72    }
73
74    pub fn observe(&self, cert_id: &str, state: CertState, not_after: i64) {
75        self.not_after.with_label_values(&[cert_id]).set(not_after);
76        for s in CertState::ALL {
77            self.state
78                .with_label_values(&[cert_id, s.as_str()])
79                .set(i64::from(s == state));
80        }
81    }
82
83    pub fn attempt(&self, cert_id: &str, success: bool, at: i64) {
84        self.renewals
85            .with_label_values(&[cert_id, if success { "success" } else { "failure" }])
86            .inc();
87        self.last_attempt.with_label_values(&[cert_id]).set(at);
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use prometheus::{Encoder, TextEncoder};
95
96    fn render(r: &prometheus::Registry) -> String {
97        let mut buf = Vec::new();
98        TextEncoder::new().encode(&r.gather(), &mut buf).unwrap();
99        String::from_utf8(buf).unwrap()
100    }
101
102    #[test]
103    fn series_render_with_expected_names_and_labels() {
104        let registry = prometheus::Registry::new();
105        let m = AcmeMetrics::register(&registry).unwrap();
106        m.observe("a.example.com", CertState::Issued, 1_800_000_000);
107        m.attempt("a.example.com", true, 1_700_000_000);
108        m.attempt("a.example.com", false, 1_700_000_100);
109        let out = render(&registry);
110        assert!(out.contains("featherbit_acme_cert_not_after_timestamp_seconds{cert_id=\"a.example.com\"} 1800000000"), "{out}");
111        assert!(out
112            .contains("featherbit_acme_cert_state{cert_id=\"a.example.com\",state=\"issued\"} 1"));
113        assert!(out.contains(
114            "featherbit_acme_cert_state{cert_id=\"a.example.com\",state=\"placeholder\"} 0"
115        ));
116        assert!(out.contains(
117            "featherbit_acme_cert_state{cert_id=\"a.example.com\",state=\"renewing\"} 0"
118        ));
119        assert!(out
120            .contains("featherbit_acme_cert_state{cert_id=\"a.example.com\",state=\"failed\"} 0"));
121        assert!(out.contains(
122            "featherbit_acme_renewals_total{cert_id=\"a.example.com\",result=\"success\"} 1"
123        ));
124        assert!(out.contains(
125            "featherbit_acme_renewals_total{cert_id=\"a.example.com\",result=\"failure\"} 1"
126        ));
127        assert!(out.contains("featherbit_acme_last_renewal_attempt_timestamp_seconds{cert_id=\"a.example.com\"} 1700000100"));
128        // Placeholder reports 0 for not_after.
129        m.observe("b.example.com", CertState::Placeholder, 0);
130        assert!(render(&registry).contains(
131            "featherbit_acme_cert_not_after_timestamp_seconds{cert_id=\"b.example.com\"} 0"
132        ));
133        // Registering twice on the same registry must fail.
134        assert!(matches!(
135            AcmeMetrics::register(&registry),
136            Err(prometheus::Error::AlreadyReg)
137        ));
138    }
139}