Skip to main content

acme_proxy/
metrics.rs

1//! The Prometheus exposition endpoint (`GET /metrics`, `[metrics]`).
2//!
3//! ## Why this is hand-rolled
4//!
5//! The text exposition format is a metric name, an optional label set, and a
6//! number — three `# HELP`/`# TYPE` lines and a `write!` per series. A façade
7//! crate (`metrics` + `metrics-exporter-prometheus`) or a client library
8//! (`prometheus`, `prometheus-client`) each bring six or more crates into a
9//! graph `cargo deny` audits at `all-features = true`, and every one of them
10//! also brings a *global recorder*, which this crate has refused elsewhere for
11//! a concrete reason: `rustls`'s `CryptoProvider::install_default` was passed
12//! explicitly rather than installed globally precisely so test ordering could
13//! not matter. The same objection applies here, and more sharply, because a
14//! global would make two tests' counters each other's.
15//!
16//! This is the same trade `src/admin/totp.rs` (RFC 6238 by hand on `ring`),
17//! `src/signer/relay/client.rs` (an ACME client by hand on `hyper`) and
18//! `src/admin/password.rs` (PBKDF2 rather than four crates for Argon2) already
19//! made. Histograms are the thing a library would genuinely earn, and nothing
20//! here exposes one.
21//!
22//! ## Cardinality is bounded by construction
23//!
24//! Every label value comes from a closed set, and that is a requirement rather
25//! than an observation — a Prometheus series is memory in this process *and* in
26//! the scraper for as long as it is retained, so one unbounded label is a leak
27//! that outlives the request that caused it.
28//!
29//! - `profile` is a configured profile name, or `none` for the root routes.
30//! - `route` is the **matched route pattern** (`/order/{id}`), never the request
31//!   URI (`/order/9f3c…`), which would be one series per order ever finalized.
32//!   A request that matched nothing at all is [`ROUTE_UNMATCHED`], so a scanner
33//!   probing ten thousand paths adds one series and not ten thousand.
34//! - `status` is an HTTP status code, and `reason` an ACME problem type this
35//!   crate itself chose.
36//!
37//! [`Metrics::render`] is therefore the only place that needs to escape a label
38//! value, and it does — but nothing that reaches it can currently contain a
39//! quote or a newline. That is belt and braces for a format where getting it
40//! wrong produces a scrape the collector rejects wholesale.
41//!
42//! ## Counters survive a reload
43//!
44//! The registry lives in [`crate::Assembly`], which is built once and carried
45//! across every generation, rather than in a `Generation`, which is rebuilt on
46//! each `SIGHUP`. A rebuilt registry would reset every counter to zero, and a
47//! counter that goes backwards is exactly how Prometheus detects a process
48//! restart: `rate()` would report a spike of the entire pre-reload total on
49//! every configuration change. It does *not* survive a real restart, which is
50//! correct — that genuinely is a new process.
51
52use std::collections::BTreeMap;
53use std::fmt::Write as _;
54use std::sync::{Arc, Mutex};
55
56use crate::sqlite::db::Database;
57
58/// The `route` label of a request that matched no route at all.
59///
60/// A literal rather than the request's own path: an unmatched path is
61/// attacker-chosen, so using it would be an unbounded label.
62pub const ROUTE_UNMATCHED: &str = "<unmatched>";
63
64/// The `profile` label of a request served by the root router.
65///
66/// `/health`, `/metrics` itself, the redirect and the `http-01` responder
67/// belong to no endpoint. Spelled explicitly rather than left empty so a query
68/// can say `profile="none"` and mean it.
69pub const PROFILE_NONE: &str = "none";
70
71/// One series' label set, in the order it is rendered.
72type Labels = Vec<(&'static str, String)>;
73
74/// The process's counters, and the handle `GET /metrics` renders from.
75///
76/// Cloneable through an `Arc` by every recorder; the `Mutex` is held only for
77/// the increment itself. A lock per request is not a cost worth avoiding here —
78/// every ACME request already commits at least one database write, and the
79/// admission limiter caps concurrency at `server.max_concurrent_requests`.
80pub struct Metrics {
81    requests: Mutex<BTreeMap<Labels, u64>>,
82    certificates_issued: Mutex<BTreeMap<Labels, u64>>,
83    certificate_issue_failures: Mutex<BTreeMap<Labels, u64>>,
84    /// Read at scrape time rather than tracked: `sqlx` already knows, and a
85    /// gauge this crate maintained itself could only ever be a worse copy.
86    database: Arc<Database>,
87}
88
89impl std::fmt::Debug for Metrics {
90    /// Deliberately says nothing about the counters.
91    ///
92    /// `Config`'s `Debug` is this crate's configuration-identity primitive
93    /// (`reload::FROZEN` projects through it), and a `Debug` whose output
94    /// changed on every request would make anything built on that comparison
95    /// meaningless.
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str("Metrics")
98    }
99}
100
101impl Metrics {
102    #[must_use]
103    pub fn new(database: Arc<Database>) -> Self {
104        Self {
105            requests: Mutex::new(BTreeMap::new()),
106            certificates_issued: Mutex::new(BTreeMap::new()),
107            certificate_issue_failures: Mutex::new(BTreeMap::new()),
108            database,
109        }
110    }
111
112    /// Counts one served request.
113    ///
114    /// `route` must already be a matched pattern or [`ROUTE_UNMATCHED`]; see the
115    /// cardinality note on this module.
116    pub fn record_request(&self, profile: &str, route: &str, status: u16) {
117        self.bump(
118            &self.requests,
119            vec![
120                ("profile", profile.to_string()),
121                ("route", route.to_string()),
122                ("status", status.to_string()),
123            ],
124        );
125    }
126
127    /// Counts one CA action from the audit record that describes it.
128    ///
129    /// Driven off [`crate::audit::AuditRecord`] rather than called separately at
130    /// each issuance site, so the counter and the audit trail cannot disagree
131    /// about what happened: both are written from the same value. Events this
132    /// exposes no series for are ignored rather than enumerated, which is what
133    /// keeps a new `AuditEvent` variant from being a compile error in a
134    /// subsystem that has no opinion about it.
135    pub fn record_audit(&self, record: &crate::audit::AuditRecord) {
136        use crate::audit::AuditEvent;
137        match record.event {
138            AuditEvent::CertificateIssued => self.bump(
139                &self.certificates_issued,
140                vec![("profile", record.profile.clone())],
141            ),
142            AuditEvent::CertificateIssueFailed => self.bump(
143                &self.certificate_issue_failures,
144                vec![
145                    ("profile", record.profile.clone()),
146                    // An ACME problem type this crate chose (`badCSR`,
147                    // `serverInternal`), not text from the request.
148                    (
149                        "reason",
150                        record.reason.clone().unwrap_or_else(|| "unknown".into()),
151                    ),
152                ],
153            ),
154            _ => {}
155        }
156    }
157
158    fn bump(&self, family: &Mutex<BTreeMap<Labels, u64>>, labels: Labels) {
159        // A poisoned lock would mean a panic inside one of the three-line
160        // critical sections below, which cannot happen — but a metric must
161        // never be the thing that takes the server down, so this recovers
162        // rather than propagating.
163        let mut guard = match family.lock() {
164            Ok(guard) => guard,
165            Err(poisoned) => poisoned.into_inner(),
166        };
167        *guard.entry(labels).or_insert(0) += 1;
168    }
169
170    /// Renders the whole registry in the Prometheus text exposition format.
171    ///
172    /// Series come out in `BTreeMap` order, which makes the output stable
173    /// across scrapes and lets a test assert on it as text.
174    #[must_use]
175    pub fn render(&self) -> String {
176        let mut out = String::new();
177
178        self.render_family(
179            &mut out,
180            "acme_proxy_requests_total",
181            "counter",
182            "Requests served, by endpoint, matched route and response status.",
183            &self.requests,
184        );
185        self.render_family(
186            &mut out,
187            "acme_proxy_certificates_issued_total",
188            "counter",
189            "Certificates signed, by endpoint.",
190            &self.certificates_issued,
191        );
192        self.render_family(
193            &mut out,
194            "acme_proxy_certificate_issue_failures_total",
195            "counter",
196            "Issuance attempts the CA refused, by endpoint and ACME problem type.",
197            &self.certificate_issue_failures,
198        );
199
200        // A gauge, and the one number here that is read rather than
201        // accumulated: `size` is every connection the pool holds and `idle`
202        // those not currently checked out, so `size - idle` is in-flight
203        // database work. Reported as two series of one gauge rather than a
204        // derived third, so a scrape cannot see them disagree.
205        let size = u64::from(self.database.pool.size());
206        let idle = self.database.pool.num_idle() as u64;
207        out.push_str(
208            "# HELP acme_proxy_database_pool_connections Connections in the SQLite pool.\n",
209        );
210        out.push_str("# TYPE acme_proxy_database_pool_connections gauge\n");
211        let _ = writeln!(
212            out,
213            "acme_proxy_database_pool_connections{{state=\"idle\"}} {idle}"
214        );
215        let _ = writeln!(
216            out,
217            "acme_proxy_database_pool_connections{{state=\"busy\"}} {}",
218            size.saturating_sub(idle)
219        );
220
221        out
222    }
223
224    /// One metric family: its two metadata lines, then a line per series.
225    ///
226    /// A family with no series still emits `# HELP`/`# TYPE` and nothing else,
227    /// which is deliberate: a dashboard built against a name that has simply
228    /// not happened yet should find the name, not an absence it cannot tell
229    /// from a typo.
230    fn render_family(
231        &self,
232        out: &mut String,
233        name: &str,
234        kind: &str,
235        help: &str,
236        family: &Mutex<BTreeMap<Labels, u64>>,
237    ) {
238        let _ = writeln!(out, "# HELP {name} {help}");
239        let _ = writeln!(out, "# TYPE {name} {kind}");
240        let guard = match family.lock() {
241            Ok(guard) => guard,
242            Err(poisoned) => poisoned.into_inner(),
243        };
244        for (labels, value) in guard.iter() {
245            let rendered: Vec<String> = labels
246                .iter()
247                .map(|(key, value)| format!("{key}=\"{}\"", escape_label(value)))
248                .collect();
249            let _ = writeln!(out, "{name}{{{}}} {value}", rendered.join(","));
250        }
251    }
252}
253
254/// Escapes a label value for the text exposition format.
255///
256/// The format defines exactly three escapes — backslash, double quote and
257/// newline — and a collector rejects the *whole* scrape when one is missing, so
258/// a single stray character would take out every metric rather than one series.
259/// Nothing that currently reaches here can contain any of them; this exists so
260/// that stays true of whatever is added next.
261fn escape_label(value: &str) -> String {
262    let mut out = String::with_capacity(value.len());
263    for ch in value.chars() {
264        match ch {
265            '\\' => out.push_str("\\\\"),
266            '"' => out.push_str("\\\""),
267            '\n' => out.push_str("\\n"),
268            other => out.push(other),
269        }
270    }
271    out
272}
273
274/// Splits a matched route pattern into its `(profile, route)` labels.
275///
276/// A nested router reports the whole pattern, prefix and all
277/// (`/profile/le/order/{id}`), so this is what turns one string into the two
278/// dimensions a query wants: "how many 500s did `le` serve" and "how many 500s
279/// did `/newOrder` serve anywhere". The split is unambiguous because
280/// [`PROFILE_PREFIX`](crate::PROFILE_PREFIX) is reserved and a profile name
281/// matches `^[a-z0-9-]+$`, so the second segment can never itself contain a
282/// slash.
283#[must_use]
284pub fn split_matched_path(matched: Option<&str>) -> (String, String) {
285    let Some(matched) = matched else {
286        return (PROFILE_NONE.to_string(), ROUTE_UNMATCHED.to_string());
287    };
288    let prefix = format!("{}/", crate::PROFILE_PREFIX);
289    let Some(rest) = matched.strip_prefix(&prefix) else {
290        return (PROFILE_NONE.to_string(), matched.to_string());
291    };
292    match rest.split_once('/') {
293        Some((profile, route)) => (profile.to_string(), format!("/{route}")),
294        // `/profile/le` with nothing after it routes nowhere today, but a
295        // pattern this cannot split must still yield two bounded labels.
296        None => (rest.to_string(), "/".to_string()),
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::audit::{Actor, AuditEvent, AuditRecord};
304
305    async fn metrics() -> Metrics {
306        Metrics::new(Arc::new(Database::connect_in_memory().await.unwrap()))
307    }
308
309    /// The exposition format, asserted as text: a collector parses this, so the
310    /// bytes are the contract and not an implementation detail.
311    #[tokio::test]
312    async fn requests_render_as_one_series_per_label_set() {
313        let metrics = metrics().await;
314        metrics.record_request("le", "/newOrder", 201);
315        metrics.record_request("le", "/newOrder", 201);
316        metrics.record_request("le", "/newOrder", 400);
317
318        let rendered = metrics.render();
319
320        assert!(rendered.contains(
321            "# HELP acme_proxy_requests_total Requests served, by endpoint, matched route and response status.\n"
322        ));
323        assert!(rendered.contains("# TYPE acme_proxy_requests_total counter\n"));
324        assert!(rendered.contains(
325            "acme_proxy_requests_total{profile=\"le\",route=\"/newOrder\",status=\"201\"} 2\n"
326        ));
327        assert!(rendered.contains(
328            "acme_proxy_requests_total{profile=\"le\",route=\"/newOrder\",status=\"400\"} 1\n"
329        ));
330    }
331
332    /// A family nobody has exercised keeps its metadata lines. A dashboard
333    /// built on the name must be able to tell "has not happened yet" from
334    /// "misspelled".
335    #[tokio::test]
336    async fn an_empty_family_still_declares_itself() {
337        let rendered = metrics().await.render();
338
339        assert!(rendered.contains("# TYPE acme_proxy_certificates_issued_total counter\n"));
340        assert!(!rendered.contains("acme_proxy_certificates_issued_total{"));
341    }
342
343    /// The pool gauge is read from `sqlx`, so it reports a real connection
344    /// rather than a number this crate maintains in parallel.
345    #[tokio::test]
346    async fn the_pool_gauge_reports_both_states() {
347        let rendered = metrics().await.render();
348
349        assert!(rendered.contains("# TYPE acme_proxy_database_pool_connections gauge\n"));
350        assert!(rendered.contains("acme_proxy_database_pool_connections{state=\"idle\"}"));
351        assert!(rendered.contains("acme_proxy_database_pool_connections{state=\"busy\"}"));
352    }
353
354    /// Driving the counters off the audit record is what keeps the metric and
355    /// the trail from disagreeing, so the mapping is asserted from a real one.
356    #[tokio::test]
357    async fn the_certificate_counters_come_off_the_audit_record() {
358        let metrics = metrics().await;
359
360        metrics.record_audit(&AuditRecord::new(
361            AuditEvent::CertificateIssued,
362            "le",
363            Actor::acme("acct-1"),
364        ));
365        metrics.record_audit(
366            &AuditRecord::new(
367                AuditEvent::CertificateIssueFailed,
368                "le",
369                Actor::acme("acct-1"),
370            )
371            .with_reason("badCSR"),
372        );
373        // An event this subsystem has no series for changes nothing.
374        metrics.record_audit(&AuditRecord::new(
375            AuditEvent::CertificateRevoked,
376            "le",
377            Actor::acme("acct-1"),
378        ));
379
380        let rendered = metrics.render();
381
382        assert!(rendered.contains("acme_proxy_certificates_issued_total{profile=\"le\"} 1\n"));
383        assert!(rendered.contains(
384            "acme_proxy_certificate_issue_failures_total{profile=\"le\",reason=\"badCSR\"} 1\n"
385        ));
386        assert!(!rendered.contains("revoked"));
387    }
388
389    /// A refusal recorded without a reason still lands in a series rather than
390    /// silently vanishing, because "the CA refused and did not say why" is
391    /// itself worth seeing on a graph.
392    #[tokio::test]
393    async fn a_failure_with_no_reason_is_counted_as_unknown() {
394        let metrics = metrics().await;
395        metrics.record_audit(&AuditRecord::new(
396            AuditEvent::CertificateIssueFailed,
397            "le",
398            Actor::acme("acct-1"),
399        ));
400
401        assert!(metrics.render().contains(
402            "acme_proxy_certificate_issue_failures_total{profile=\"le\",reason=\"unknown\"} 1\n"
403        ));
404    }
405
406    /// The nested-router split, which is what keeps `route` comparable across
407    /// endpoints instead of one series per profile per route.
408    #[test]
409    fn a_matched_path_splits_into_a_profile_and_a_route() {
410        for (matched, expected) in [
411            (Some("/profile/le/order/{id}"), ("le", "/order/{id}")),
412            (Some("/profile/staging/newOrder"), ("staging", "/newOrder")),
413            // Root routes belong to no endpoint.
414            (Some("/health"), (PROFILE_NONE, "/health")),
415            (Some("/"), (PROFILE_NONE, "/")),
416            // A path that matched nothing is attacker-chosen and must collapse
417            // to one series.
418            (None, (PROFILE_NONE, ROUTE_UNMATCHED)),
419        ] {
420            let (profile, route) = split_matched_path(matched);
421            assert_eq!(
422                (profile.as_str(), route.as_str()),
423                expected,
424                "for {matched:?}"
425            );
426        }
427    }
428
429    /// The three escapes the format defines. Nothing reaching a label can carry
430    /// one today; a scrape a collector rejects wholesale is the cost of that
431    /// stopping being true unnoticed.
432    #[test]
433    fn label_values_are_escaped() {
434        assert_eq!(escape_label("plain"), "plain");
435        assert_eq!(escape_label(r#"a"b"#), r#"a\"b"#);
436        assert_eq!(escape_label(r"a\b"), r"a\\b");
437        assert_eq!(escape_label("a\nb"), r"a\nb");
438    }
439}