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}