use std::collections::HashMap;
use std::fmt::Write;
use std::sync::RwLock;
pub const DEFAULT_BUCKETS: &[f64] = &[
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];
pub type Labels = Vec<(String, String)>;
fn sort_labels(mut labels: Labels) -> Labels {
labels.sort_by(|a, b| a.0.cmp(&b.0));
labels
}
fn format_labels(labels: &Labels) -> String {
if labels.is_empty() {
return String::new();
}
let mut out = String::from("{");
for (i, (k, v)) in labels.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(k);
out.push_str("=\"");
for ch in v.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
c => out.push(c),
}
}
out.push('"');
}
out.push('}');
out
}
#[derive(Debug, Default)]
struct CounterFamily {
help: String,
values: HashMap<Labels, u64>,
}
#[derive(Debug, Default)]
struct HistogramFamily {
help: String,
buckets: Vec<f64>,
series: HashMap<Labels, HistogramSeries>,
}
#[derive(Debug, Default, Clone)]
struct HistogramSeries {
counts: Vec<u64>,
sum: f64,
count: u64,
}
#[derive(Debug, Default)]
pub struct MetricsRegistry {
counters: RwLock<HashMap<String, CounterFamily>>,
histograms: RwLock<HashMap<String, HistogramFamily>>,
}
impl MetricsRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register_counter(&self, name: &str, help: &str) {
let mut map = self.counters.write().unwrap_or_else(|e| e.into_inner());
map.entry(name.to_string()).or_default().help = help.to_string();
}
pub fn register_histogram(&self, name: &str, help: &str, buckets: &[f64]) {
let mut normalised: Vec<f64> = buckets.to_vec();
normalised.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
normalised.dedup();
let mut map = self.histograms.write().unwrap_or_else(|e| e.into_inner());
let family = map.entry(name.to_string()).or_default();
family.help = help.to_string();
family.buckets = normalised;
}
pub fn counter_inc(&self, name: &str, labels: Labels, delta: u64) {
let labels = sort_labels(labels);
let mut map = self.counters.write().unwrap_or_else(|e| e.into_inner());
let family = map.entry(name.to_string()).or_default();
*family.values.entry(labels).or_insert(0) += delta;
}
pub fn histogram_record(&self, name: &str, labels: Labels, value: f64) {
let labels = sort_labels(labels);
let mut map = self.histograms.write().unwrap_or_else(|e| e.into_inner());
let family = map.entry(name.to_string()).or_default();
if family.buckets.is_empty() {
family.buckets = DEFAULT_BUCKETS.to_vec();
}
let series = family
.series
.entry(labels)
.or_insert_with(|| HistogramSeries {
counts: vec![0; family.buckets.len()],
sum: 0.0,
count: 0,
});
if series.counts.len() < family.buckets.len() {
series.counts.resize(family.buckets.len(), 0);
}
for (i, &upper) in family.buckets.iter().enumerate() {
if value <= upper {
series.counts[i] += 1;
}
}
series.sum += value;
series.count += 1;
}
pub fn render_prometheus(&self) -> String {
let mut out = String::new();
let counters = self.counters.read().unwrap_or_else(|e| e.into_inner());
let mut names: Vec<&String> = counters.keys().collect();
names.sort();
for name in names {
let family = &counters[name];
if !family.help.is_empty() {
let _ = writeln!(out, "# HELP {name} {}", family.help);
}
let _ = writeln!(out, "# TYPE {name} counter");
let mut label_keys: Vec<&Labels> = family.values.keys().collect();
label_keys.sort();
for labels in label_keys {
let _ = writeln!(
out,
"{name}{} {}",
format_labels(labels),
family.values[labels]
);
}
out.push('\n');
}
drop(counters);
let histograms = self.histograms.read().unwrap_or_else(|e| e.into_inner());
let mut names: Vec<&String> = histograms.keys().collect();
names.sort();
for name in names {
let family = &histograms[name];
if !family.help.is_empty() {
let _ = writeln!(out, "# HELP {name} {}", family.help);
}
let _ = writeln!(out, "# TYPE {name} histogram");
let mut label_keys: Vec<&Labels> = family.series.keys().collect();
label_keys.sort();
for labels in label_keys {
let series = &family.series[labels];
let base = format_labels(labels);
let inner = trim_outer_braces(&base);
let le_prefix: &str = if inner.is_empty() { "" } else { "," };
for (i, &upper) in family.buckets.iter().enumerate() {
let _ = writeln!(
out,
"{name}_bucket{{{inner}{le_prefix}le=\"{}\"}} {}",
fmt_f64_prom(upper),
series.counts[i],
);
}
let _ = writeln!(
out,
"{name}_bucket{{{inner}{le_prefix}le=\"+Inf\"}} {}",
series.count
);
let _ = writeln!(out, "{name}_sum{} {}", base, fmt_f64_prom(series.sum),);
let _ = writeln!(out, "{name}_count{} {}", base, series.count,);
}
out.push('\n');
}
out
}
}
fn trim_outer_braces(formatted: &str) -> &str {
if formatted.is_empty() {
return "";
}
let inner = formatted
.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.unwrap_or(formatted);
if inner.is_empty() { "" } else { inner }
}
fn fmt_f64_prom(v: f64) -> String {
if v.is_infinite() {
return if v.is_sign_positive() {
"+Inf".into()
} else {
"-Inf".into()
};
}
if v.is_nan() {
return "NaN".into();
}
format!("{v}")
}
#[cfg(test)]
mod tests {
use super::*;
fn registry() -> MetricsRegistry {
let r = MetricsRegistry::new();
r.register_counter(
"phostt_http_requests_total",
"Total HTTP requests processed",
);
r.register_histogram(
"phostt_http_request_duration_seconds",
"HTTP request duration",
DEFAULT_BUCKETS,
);
r
}
#[test]
fn test_render_empty_registry() {
let r = MetricsRegistry::new();
assert_eq!(r.render_prometheus(), "");
}
#[test]
fn test_counter_increment_and_render() {
let r = registry();
r.counter_inc(
"phostt_http_requests_total",
vec![
("method".into(), "GET".into()),
("path".into(), "/health".into()),
("status".into(), "200".into()),
],
1,
);
r.counter_inc(
"phostt_http_requests_total",
vec![
("method".into(), "GET".into()),
("path".into(), "/health".into()),
("status".into(), "200".into()),
],
2,
);
let text = r.render_prometheus();
assert!(text.contains("# HELP phostt_http_requests_total Total HTTP requests processed"));
assert!(text.contains("# TYPE phostt_http_requests_total counter"));
assert!(text.contains(
"phostt_http_requests_total{method=\"GET\",path=\"/health\",status=\"200\"} 3"
));
}
#[test]
fn test_histogram_bucket_cumulative() {
let r = registry();
let labels = vec![("method".into(), "GET".into())];
for v in [0.001, 0.03, 0.3, 1.5] {
r.histogram_record("phostt_http_request_duration_seconds", labels.clone(), v);
}
let text = r.render_prometheus();
assert!(text.contains(
"phostt_http_request_duration_seconds_bucket{method=\"GET\",le=\"0.005\"} 1"
));
assert!(
text.contains(
"phostt_http_request_duration_seconds_bucket{method=\"GET\",le=\"0.05\"} 2"
)
);
assert!(
text.contains(
"phostt_http_request_duration_seconds_bucket{method=\"GET\",le=\"0.5\"} 3"
)
);
assert!(
text.contains(
"phostt_http_request_duration_seconds_bucket{method=\"GET\",le=\"+Inf\"} 4"
)
);
assert!(text.contains("phostt_http_request_duration_seconds_count{method=\"GET\"} 4"));
}
#[test]
fn test_label_ordering_stable() {
let r = MetricsRegistry::new();
r.counter_inc(
"c",
vec![("b".into(), "1".into()), ("a".into(), "2".into())],
1,
);
r.counter_inc(
"c",
vec![("a".into(), "2".into()), ("b".into(), "1".into())],
4,
);
let text = r.render_prometheus();
assert!(text.contains("c{a=\"2\",b=\"1\"} 5"));
}
#[test]
fn test_label_escaping() {
let r = MetricsRegistry::new();
r.counter_inc("c", vec![("l".into(), "a\"b\\c\nd".into())], 1);
let text = r.render_prometheus();
assert!(
text.contains("c{l=\"a\\\"b\\\\c\\nd\"} 1"),
"escape failed: {text}"
);
}
#[test]
fn test_empty_labels_render() {
let r = MetricsRegistry::new();
r.counter_inc("c", vec![], 7);
let text = r.render_prometheus();
assert!(text.contains("c 7"));
}
#[test]
fn test_histogram_sum_tracks_observations() {
let r = MetricsRegistry::new();
r.register_histogram("h", "H", &[1.0, 2.0]);
r.histogram_record("h", vec![], 0.5);
r.histogram_record("h", vec![], 1.5);
r.histogram_record("h", vec![], 2.5);
let text = r.render_prometheus();
assert!(text.contains("h_sum 4.5"));
assert!(text.contains("h_count 3"));
}
#[test]
fn test_metrics_survive_poison() {
let r = MetricsRegistry::new();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = r.counters.write().expect("counters lock");
panic!("simulated panic during metric write");
}));
r.counter_inc("c", vec![], 1);
let text = r.render_prometheus();
assert!(
text.contains("c 1"),
"metrics should survive poison: {text}"
);
}
#[test]
fn test_metrics_multiple_poison_cycles() {
let r = MetricsRegistry::new();
for i in 0..3 {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = r.counters.write().expect("counters lock");
panic!("poison cycle {i}");
}));
r.counter_inc("c", vec![], 1);
}
let text = r.render_prometheus();
assert!(
text.contains("c 3"),
"metrics should accumulate across poison cycles: {text}"
);
}
}