sentinel_agent_protocol/v2/
metrics.rs

1//! Metrics export for Protocol v2.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Metrics report from an agent.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct MetricsReport {
9    pub agent_id: String,
10    pub timestamp_ms: u64,
11    pub interval_ms: u64,
12    #[serde(default)]
13    pub counters: Vec<CounterMetric>,
14    #[serde(default)]
15    pub gauges: Vec<GaugeMetric>,
16    #[serde(default)]
17    pub histograms: Vec<HistogramMetric>,
18}
19
20impl MetricsReport {
21    pub fn new(agent_id: impl Into<String>, interval_ms: u64) -> Self {
22        Self {
23            agent_id: agent_id.into(),
24            timestamp_ms: now_ms(),
25            interval_ms,
26            counters: Vec::new(),
27            gauges: Vec::new(),
28            histograms: Vec::new(),
29        }
30    }
31
32    pub fn is_empty(&self) -> bool {
33        self.counters.is_empty() && self.gauges.is_empty() && self.histograms.is_empty()
34    }
35}
36
37/// A counter metric.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CounterMetric {
40    pub name: String,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub help: Option<String>,
43    #[serde(default)]
44    pub labels: HashMap<String, String>,
45    pub value: u64,
46}
47
48impl CounterMetric {
49    pub fn new(name: impl Into<String>, value: u64) -> Self {
50        Self { name: name.into(), help: None, labels: HashMap::new(), value }
51    }
52}
53
54/// A gauge metric.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct GaugeMetric {
57    pub name: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub help: Option<String>,
60    #[serde(default)]
61    pub labels: HashMap<String, String>,
62    pub value: f64,
63}
64
65impl GaugeMetric {
66    pub fn new(name: impl Into<String>, value: f64) -> Self {
67        Self { name: name.into(), help: None, labels: HashMap::new(), value }
68    }
69}
70
71/// A histogram metric.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct HistogramMetric {
74    pub name: String,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub help: Option<String>,
77    #[serde(default)]
78    pub labels: HashMap<String, String>,
79    pub sum: f64,
80    pub count: u64,
81    pub buckets: Vec<HistogramBucket>,
82}
83
84/// A histogram bucket.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct HistogramBucket {
87    #[serde(serialize_with = "serialize_le", deserialize_with = "deserialize_le")]
88    pub le: f64,
89    pub count: u64,
90}
91
92impl HistogramBucket {
93    pub fn new(le: f64) -> Self { Self { le, count: 0 } }
94    pub fn infinity() -> Self { Self { le: f64::INFINITY, count: 0 } }
95}
96
97fn serialize_le<S>(le: &f64, serializer: S) -> Result<S::Ok, S::Error>
98where S: serde::Serializer {
99    if le.is_infinite() { serializer.serialize_str("+Inf") } else { serializer.serialize_f64(*le) }
100}
101
102fn deserialize_le<'de, D>(deserializer: D) -> Result<f64, D::Error>
103where D: serde::Deserializer<'de> {
104    use serde::de::{self, Visitor};
105    struct LeVisitor;
106    impl<'de> Visitor<'de> for LeVisitor {
107        type Value = f64;
108        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("float or +Inf") }
109        fn visit_f64<E: de::Error>(self, v: f64) -> Result<Self::Value, E> { Ok(v) }
110        fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> { Ok(v as f64) }
111        fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> { Ok(v as f64) }
112        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
113            if v == "+Inf" || v == "Inf" { Ok(f64::INFINITY) } else { v.parse().map_err(de::Error::custom) }
114        }
115    }
116    deserializer.deserialize_any(LeVisitor)
117}
118
119/// Standard metric names.
120pub mod standard {
121    pub const REQUESTS_TOTAL: &str = "agent_requests_total";
122    pub const REQUESTS_BLOCKED_TOTAL: &str = "agent_requests_blocked_total";
123    pub const REQUESTS_DURATION_SECONDS: &str = "agent_requests_duration_seconds";
124    pub const ERRORS_TOTAL: &str = "agent_errors_total";
125    pub const IN_FLIGHT_REQUESTS: &str = "agent_in_flight_requests";
126    pub const QUEUE_DEPTH: &str = "agent_queue_depth";
127}
128
129fn now_ms() -> u64 {
130    std::time::SystemTime::now()
131        .duration_since(std::time::UNIX_EPOCH)
132        .map(|d| d.as_millis() as u64)
133        .unwrap_or(0)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_metrics_report() {
142        let report = MetricsReport::new("test-agent", 10_000);
143        assert!(report.is_empty());
144    }
145
146    #[test]
147    fn test_counter_metric() {
148        let counter = CounterMetric::new("test_counter", 100);
149        assert_eq!(counter.value, 100);
150    }
151
152    #[test]
153    fn test_histogram_bucket_infinity() {
154        let bucket = HistogramBucket::infinity();
155        assert!(bucket.le.is_infinite());
156
157        let json = serde_json::to_string(&bucket).unwrap();
158        assert!(json.contains("+Inf"));
159
160        let parsed: HistogramBucket = serde_json::from_str(&json).unwrap();
161        assert!(parsed.le.is_infinite());
162    }
163}