use std::collections::BTreeMap;
use std::sync::RwLock;
pub const DEFAULT_HISTOGRAM_BUCKETS: &[f64] = &[0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0];
type LabelPairs = Vec<(String, String)>;
type Key = (String, LabelPairs);
fn make_key(name: &str, labels: &[(&str, &str)]) -> Key {
let mut labels: LabelPairs = labels
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
labels.sort_by(|a, b| a.0.cmp(&b.0));
(name.to_string(), labels)
}
fn format_labels(labels: &[(String, String)]) -> String {
if labels.is_empty() {
return String::new();
}
let inside = labels
.iter()
.map(|(k, v)| format!("{}=\"{}\"", k, escape_label_value(v)))
.collect::<Vec<_>>()
.join(",");
format!("{{{inside}}}")
}
fn escape_label_value(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
}
#[derive(Debug, Clone)]
struct HistogramValue {
buckets: Vec<f64>,
counts: Vec<u64>,
sum: f64,
count: u64,
}
impl HistogramValue {
fn new(buckets: &[f64]) -> Self {
let mut b = buckets.to_vec();
b.retain(|x| x.is_finite());
b.sort_by(|a, c| a.partial_cmp(c).unwrap_or(std::cmp::Ordering::Equal));
let counts = vec![0u64; b.len()];
Self {
buckets: b,
counts,
sum: 0.0,
count: 0,
}
}
fn observe(&mut self, value: f64) {
if !value.is_finite() {
return;
}
self.sum += value;
self.count = self.count.saturating_add(1);
for (i, b) in self.buckets.iter().enumerate() {
if value <= *b {
self.counts[i] = self.counts[i].saturating_add(1);
}
}
}
}
#[derive(Debug, Default)]
struct Inner {
counters: BTreeMap<Key, u64>,
gauges: BTreeMap<Key, i64>,
histograms: BTreeMap<Key, HistogramValue>,
default_buckets: Vec<f64>,
}
#[derive(Debug)]
pub struct MetricsRegistry {
inner: RwLock<Inner>,
}
impl Default for MetricsRegistry {
fn default() -> Self {
Self::new()
}
}
impl MetricsRegistry {
pub fn new() -> Self {
Self {
inner: RwLock::new(Inner {
counters: BTreeMap::new(),
gauges: BTreeMap::new(),
histograms: BTreeMap::new(),
default_buckets: DEFAULT_HISTOGRAM_BUCKETS.to_vec(),
}),
}
}
pub fn with_buckets(buckets: &[f64]) -> Self {
Self {
inner: RwLock::new(Inner {
counters: BTreeMap::new(),
gauges: BTreeMap::new(),
histograms: BTreeMap::new(),
default_buckets: buckets.to_vec(),
}),
}
}
pub fn inc_counter(&self, name: &str, labels: &[(&str, &str)]) {
self.add_counter(name, labels, 1);
}
pub fn add_counter(&self, name: &str, labels: &[(&str, &str)], value: u64) {
let key = make_key(name, labels);
let mut guard = match self.inner.write() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
let entry = guard.counters.entry(key).or_insert(0);
*entry = entry.saturating_add(value);
}
pub fn set_gauge(&self, name: &str, labels: &[(&str, &str)], value: i64) {
let key = make_key(name, labels);
let mut guard = match self.inner.write() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard.gauges.insert(key, value);
}
pub fn add_gauge(&self, name: &str, labels: &[(&str, &str)], delta: i64) {
let key = make_key(name, labels);
let mut guard = match self.inner.write() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
let entry = guard.gauges.entry(key).or_insert(0);
*entry = entry.saturating_add(delta);
}
pub fn observe_histogram(&self, name: &str, labels: &[(&str, &str)], value: f64) {
let key = make_key(name, labels);
let mut guard = match self.inner.write() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
let buckets = guard.default_buckets.clone();
let entry = guard
.histograms
.entry(key)
.or_insert_with(|| HistogramValue::new(&buckets));
entry.observe(value);
}
pub fn counter_value(&self, name: &str, labels: &[(&str, &str)]) -> u64 {
let key = make_key(name, labels);
let guard = match self.inner.read() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard.counters.get(&key).copied().unwrap_or(0)
}
pub fn gauge_value(&self, name: &str, labels: &[(&str, &str)]) -> i64 {
let key = make_key(name, labels);
let guard = match self.inner.read() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard.gauges.get(&key).copied().unwrap_or(0)
}
pub fn histogram_count(&self, name: &str, labels: &[(&str, &str)]) -> u64 {
let key = make_key(name, labels);
let guard = match self.inner.read() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard.histograms.get(&key).map(|h| h.count).unwrap_or(0)
}
pub fn histogram_bucket_counts(&self, name: &str, labels: &[(&str, &str)]) -> Vec<u64> {
let key = make_key(name, labels);
let guard = match self.inner.read() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard
.histograms
.get(&key)
.map(|h| h.counts.clone())
.unwrap_or_default()
}
pub fn render(&self) -> String {
let guard = match self.inner.read() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
let mut out = String::new();
let mut counter_names: BTreeMap<String, Vec<(&LabelPairs, &u64)>> = BTreeMap::new();
for ((n, l), v) in &guard.counters {
counter_names.entry(n.clone()).or_default().push((l, v));
}
for (name, series) in &counter_names {
out.push_str(&format!(
"# HELP {name} Counter auto-registered by oxibonsai-serve.\n"
));
out.push_str(&format!("# TYPE {name} counter\n"));
for (labels, value) in series {
out.push_str(&format!("{}{} {}\n", name, format_labels(labels), value));
}
}
let mut gauge_names: BTreeMap<String, Vec<(&LabelPairs, &i64)>> = BTreeMap::new();
for ((n, l), v) in &guard.gauges {
gauge_names.entry(n.clone()).or_default().push((l, v));
}
for (name, series) in &gauge_names {
out.push_str(&format!(
"# HELP {name} Gauge auto-registered by oxibonsai-serve.\n"
));
out.push_str(&format!("# TYPE {name} gauge\n"));
for (labels, value) in series {
out.push_str(&format!("{}{} {}\n", name, format_labels(labels), value));
}
}
let mut hist_names: BTreeMap<String, Vec<(&LabelPairs, &HistogramValue)>> = BTreeMap::new();
for ((n, l), v) in &guard.histograms {
hist_names.entry(n.clone()).or_default().push((l, v));
}
for (name, series) in &hist_names {
out.push_str(&format!(
"# HELP {name} Histogram auto-registered by oxibonsai-serve.\n"
));
out.push_str(&format!("# TYPE {name} histogram\n"));
for (labels, hv) in series {
for (i, b) in hv.buckets.iter().enumerate() {
let mut ls = (*labels).clone();
ls.push(("le".to_string(), format_float(*b)));
ls.sort_by(|a, c| a.0.cmp(&c.0));
out.push_str(&format!(
"{}_bucket{} {}\n",
name,
format_labels(&ls),
hv.counts[i]
));
}
let mut inf_labels = (*labels).clone();
inf_labels.push(("le".to_string(), "+Inf".to_string()));
inf_labels.sort_by(|a, c| a.0.cmp(&c.0));
out.push_str(&format!(
"{}_bucket{} {}\n",
name,
format_labels(&inf_labels),
hv.count
));
out.push_str(&format!(
"{}_sum{} {}\n",
name,
format_labels(labels),
format_float(hv.sum)
));
out.push_str(&format!(
"{}_count{} {}\n",
name,
format_labels(labels),
hv.count
));
}
}
out
}
}
fn format_float(v: f64) -> String {
if v.is_nan() {
return "NaN".to_string();
}
if v.is_infinite() {
return if v > 0.0 {
"+Inf".to_string()
} else {
"-Inf".to_string()
};
}
if v.fract() == 0.0 && v.abs() < 1.0e15 {
format!("{}", v as i64)
} else {
format!("{v}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counter_roundtrip() {
let r = MetricsRegistry::new();
r.inc_counter("foo_total", &[("a", "x")]);
r.inc_counter("foo_total", &[("a", "x")]);
assert_eq!(r.counter_value("foo_total", &[("a", "x")]), 2);
}
#[test]
fn gauge_set() {
let r = MetricsRegistry::new();
r.set_gauge("inflight", &[], 3);
assert_eq!(r.gauge_value("inflight", &[]), 3);
r.set_gauge("inflight", &[], 1);
assert_eq!(r.gauge_value("inflight", &[]), 1);
}
#[test]
fn histogram_observe_count() {
let r = MetricsRegistry::new();
r.observe_histogram("lat", &[("endpoint", "/h")], 0.005);
r.observe_histogram("lat", &[("endpoint", "/h")], 0.20);
assert_eq!(r.histogram_count("lat", &[("endpoint", "/h")]), 2);
}
#[test]
fn render_includes_type_and_help() {
let r = MetricsRegistry::new();
r.inc_counter("x_total", &[]);
let body = r.render();
assert!(body.contains("# HELP x_total"));
assert!(body.contains("# TYPE x_total counter"));
assert!(body.contains("x_total 1"));
}
#[test]
fn render_histogram_has_buckets_sum_count() {
let r = MetricsRegistry::new();
r.observe_histogram("lat_seconds", &[], 0.02);
let body = r.render();
assert!(body.contains("lat_seconds_bucket{le=\"0.01\"}"));
assert!(body.contains("lat_seconds_bucket{le=\"+Inf\"}"));
assert!(body.contains("lat_seconds_sum"));
assert!(body.contains("lat_seconds_count"));
}
#[test]
fn label_escape() {
assert_eq!(escape_label_value("a\"b"), "a\\\"b");
assert_eq!(escape_label_value("a\\b"), "a\\\\b");
assert_eq!(escape_label_value("a\nb"), "a\\nb");
}
}