use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::sync::{Arc, Mutex};
use crate::sqlite::db::Database;
pub const ROUTE_UNMATCHED: &str = "<unmatched>";
pub const PROFILE_NONE: &str = "none";
type Labels = Vec<(&'static str, String)>;
pub struct Metrics {
requests: Mutex<BTreeMap<Labels, u64>>,
certificates_issued: Mutex<BTreeMap<Labels, u64>>,
certificate_issue_failures: Mutex<BTreeMap<Labels, u64>>,
database: Arc<Database>,
}
impl std::fmt::Debug for Metrics {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Metrics")
}
}
impl Metrics {
#[must_use]
pub fn new(database: Arc<Database>) -> Self {
Self {
requests: Mutex::new(BTreeMap::new()),
certificates_issued: Mutex::new(BTreeMap::new()),
certificate_issue_failures: Mutex::new(BTreeMap::new()),
database,
}
}
pub fn record_request(&self, profile: &str, route: &str, status: u16) {
self.bump(
&self.requests,
vec![
("profile", profile.to_string()),
("route", route.to_string()),
("status", status.to_string()),
],
);
}
pub fn record_audit(&self, record: &crate::audit::AuditRecord) {
use crate::audit::AuditEvent;
match record.event {
AuditEvent::CertificateIssued => self.bump(
&self.certificates_issued,
vec![("profile", record.profile.clone())],
),
AuditEvent::CertificateIssueFailed => self.bump(
&self.certificate_issue_failures,
vec![
("profile", record.profile.clone()),
(
"reason",
record.reason.clone().unwrap_or_else(|| "unknown".into()),
),
],
),
_ => {}
}
}
fn bump(&self, family: &Mutex<BTreeMap<Labels, u64>>, labels: Labels) {
let mut guard = match family.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
*guard.entry(labels).or_insert(0) += 1;
}
#[must_use]
pub fn render(&self) -> String {
let mut out = String::new();
self.render_family(
&mut out,
"acme_proxy_requests_total",
"counter",
"Requests served, by endpoint, matched route and response status.",
&self.requests,
);
self.render_family(
&mut out,
"acme_proxy_certificates_issued_total",
"counter",
"Certificates signed, by endpoint.",
&self.certificates_issued,
);
self.render_family(
&mut out,
"acme_proxy_certificate_issue_failures_total",
"counter",
"Issuance attempts the CA refused, by endpoint and ACME problem type.",
&self.certificate_issue_failures,
);
let size = u64::from(self.database.pool.size());
let idle = self.database.pool.num_idle() as u64;
out.push_str(
"# HELP acme_proxy_database_pool_connections Connections in the SQLite pool.\n",
);
out.push_str("# TYPE acme_proxy_database_pool_connections gauge\n");
let _ = writeln!(
out,
"acme_proxy_database_pool_connections{{state=\"idle\"}} {idle}"
);
let _ = writeln!(
out,
"acme_proxy_database_pool_connections{{state=\"busy\"}} {}",
size.saturating_sub(idle)
);
out
}
fn render_family(
&self,
out: &mut String,
name: &str,
kind: &str,
help: &str,
family: &Mutex<BTreeMap<Labels, u64>>,
) {
let _ = writeln!(out, "# HELP {name} {help}");
let _ = writeln!(out, "# TYPE {name} {kind}");
let guard = match family.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
for (labels, value) in guard.iter() {
let rendered: Vec<String> = labels
.iter()
.map(|(key, value)| format!("{key}=\"{}\"", escape_label(value)))
.collect();
let _ = writeln!(out, "{name}{{{}}} {value}", rendered.join(","));
}
}
}
fn escape_label(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
other => out.push(other),
}
}
out
}
#[must_use]
pub fn split_matched_path(matched: Option<&str>) -> (String, String) {
let Some(matched) = matched else {
return (PROFILE_NONE.to_string(), ROUTE_UNMATCHED.to_string());
};
let prefix = format!("{}/", crate::PROFILE_PREFIX);
let Some(rest) = matched.strip_prefix(&prefix) else {
return (PROFILE_NONE.to_string(), matched.to_string());
};
match rest.split_once('/') {
Some((profile, route)) => (profile.to_string(), format!("/{route}")),
None => (rest.to_string(), "/".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::{Actor, AuditEvent, AuditRecord};
async fn metrics() -> Metrics {
Metrics::new(Arc::new(Database::connect_in_memory().await.unwrap()))
}
#[tokio::test]
async fn requests_render_as_one_series_per_label_set() {
let metrics = metrics().await;
metrics.record_request("le", "/newOrder", 201);
metrics.record_request("le", "/newOrder", 201);
metrics.record_request("le", "/newOrder", 400);
let rendered = metrics.render();
assert!(rendered.contains(
"# HELP acme_proxy_requests_total Requests served, by endpoint, matched route and response status.\n"
));
assert!(rendered.contains("# TYPE acme_proxy_requests_total counter\n"));
assert!(rendered.contains(
"acme_proxy_requests_total{profile=\"le\",route=\"/newOrder\",status=\"201\"} 2\n"
));
assert!(rendered.contains(
"acme_proxy_requests_total{profile=\"le\",route=\"/newOrder\",status=\"400\"} 1\n"
));
}
#[tokio::test]
async fn an_empty_family_still_declares_itself() {
let rendered = metrics().await.render();
assert!(rendered.contains("# TYPE acme_proxy_certificates_issued_total counter\n"));
assert!(!rendered.contains("acme_proxy_certificates_issued_total{"));
}
#[tokio::test]
async fn the_pool_gauge_reports_both_states() {
let rendered = metrics().await.render();
assert!(rendered.contains("# TYPE acme_proxy_database_pool_connections gauge\n"));
assert!(rendered.contains("acme_proxy_database_pool_connections{state=\"idle\"}"));
assert!(rendered.contains("acme_proxy_database_pool_connections{state=\"busy\"}"));
}
#[tokio::test]
async fn the_certificate_counters_come_off_the_audit_record() {
let metrics = metrics().await;
metrics.record_audit(&AuditRecord::new(
AuditEvent::CertificateIssued,
"le",
Actor::acme("acct-1"),
));
metrics.record_audit(
&AuditRecord::new(
AuditEvent::CertificateIssueFailed,
"le",
Actor::acme("acct-1"),
)
.with_reason("badCSR"),
);
metrics.record_audit(&AuditRecord::new(
AuditEvent::CertificateRevoked,
"le",
Actor::acme("acct-1"),
));
let rendered = metrics.render();
assert!(rendered.contains("acme_proxy_certificates_issued_total{profile=\"le\"} 1\n"));
assert!(rendered.contains(
"acme_proxy_certificate_issue_failures_total{profile=\"le\",reason=\"badCSR\"} 1\n"
));
assert!(!rendered.contains("revoked"));
}
#[tokio::test]
async fn a_failure_with_no_reason_is_counted_as_unknown() {
let metrics = metrics().await;
metrics.record_audit(&AuditRecord::new(
AuditEvent::CertificateIssueFailed,
"le",
Actor::acme("acct-1"),
));
assert!(metrics.render().contains(
"acme_proxy_certificate_issue_failures_total{profile=\"le\",reason=\"unknown\"} 1\n"
));
}
#[test]
fn a_matched_path_splits_into_a_profile_and_a_route() {
for (matched, expected) in [
(Some("/profile/le/order/{id}"), ("le", "/order/{id}")),
(Some("/profile/staging/newOrder"), ("staging", "/newOrder")),
(Some("/health"), (PROFILE_NONE, "/health")),
(Some("/"), (PROFILE_NONE, "/")),
(None, (PROFILE_NONE, ROUTE_UNMATCHED)),
] {
let (profile, route) = split_matched_path(matched);
assert_eq!(
(profile.as_str(), route.as_str()),
expected,
"for {matched:?}"
);
}
}
#[test]
fn label_values_are_escaped() {
assert_eq!(escape_label("plain"), "plain");
assert_eq!(escape_label(r#"a"b"#), r#"a\"b"#);
assert_eq!(escape_label(r"a\b"), r"a\\b");
assert_eq!(escape_label("a\nb"), r"a\nb");
}
}