rs-zero 0.1.1

Rust-first microservice framework inspired by go-zero engineering practices
Documentation
use std::{
    collections::BTreeMap,
    fmt::Write,
    sync::{Arc, Mutex},
    time::Duration,
};

/// Low-cardinality labels recorded for each HTTP request.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct HttpMetricLabels {
    /// HTTP method.
    pub method: String,
    /// Route pattern or explicit route name. Raw request paths should not be used.
    pub route: String,
    /// HTTP status code.
    pub status: u16,
}

impl HttpMetricLabels {
    /// Creates a label set for one HTTP request.
    pub fn new(method: impl Into<String>, route: impl Into<String>, status: u16) -> Self {
        Self {
            method: method.into(),
            route: route.into(),
            status,
        }
    }
}

#[derive(Debug, Clone, Default)]
struct HttpMetricValue {
    count: u64,
    duration_seconds_sum: f64,
}

/// In-process metrics registry with Prometheus text export.
#[derive(Debug, Clone, Default)]
pub struct MetricsRegistry {
    http_requests: Arc<Mutex<BTreeMap<HttpMetricLabels, HttpMetricValue>>>,
}

impl MetricsRegistry {
    /// Creates an empty registry.
    pub fn new() -> Self {
        Self::default()
    }

    /// Records an HTTP request duration.
    pub fn record_http_request(&self, labels: HttpMetricLabels, duration: Duration) {
        let mut metrics = self.http_requests.lock().expect("metrics mutex");
        let entry = metrics.entry(labels).or_default();
        entry.count += 1;
        entry.duration_seconds_sum += duration.as_secs_f64();
    }

    /// Renders all metrics in Prometheus text exposition format.
    pub fn render_prometheus(&self) -> String {
        let metrics = self.http_requests.lock().expect("metrics mutex");
        let mut output = String::new();
        output.push_str("# HELP rs_zero_http_requests_total Total number of HTTP requests.\n");
        output.push_str("# TYPE rs_zero_http_requests_total counter\n");
        for (labels, value) in metrics.iter() {
            let _ = writeln!(
                output,
                "rs_zero_http_requests_total{{method=\"{}\",route=\"{}\",status=\"{}\"}} {}",
                escape_label(&labels.method),
                escape_label(&labels.route),
                labels.status,
                value.count
            );
        }

        output.push_str(
            "# HELP rs_zero_http_request_duration_seconds_sum Sum of HTTP request durations.\n",
        );
        output.push_str("# TYPE rs_zero_http_request_duration_seconds_sum counter\n");
        for (labels, value) in metrics.iter() {
            let _ = writeln!(
                output,
                "rs_zero_http_request_duration_seconds_sum{{method=\"{}\",route=\"{}\",status=\"{}\"}} {:.6}",
                escape_label(&labels.method),
                escape_label(&labels.route),
                labels.status,
                value.duration_seconds_sum
            );
        }
        output
    }
}

fn escape_label(value: &str) -> String {
    value.replace('\\', "\\\\").replace('"', "\\\"")
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use super::{HttpMetricLabels, MetricsRegistry};

    #[test]
    fn metrics_render_prometheus_text() {
        let registry = MetricsRegistry::new();
        registry.record_http_request(
            HttpMetricLabels::new("GET", "/users/:id", 200),
            Duration::from_millis(20),
        );
        let text = registry.render_prometheus();
        assert!(text.contains("rs_zero_http_requests_total"));
        assert!(text.contains("route=\"/users/:id\""));
        assert!(!text.contains("/users/42"));
    }
}