Skip to main content

grapsus_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 {
51            name: name.into(),
52            help: None,
53            labels: HashMap::new(),
54            value,
55        }
56    }
57}
58
59/// A gauge metric.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct GaugeMetric {
62    pub name: String,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub help: Option<String>,
65    #[serde(default)]
66    pub labels: HashMap<String, String>,
67    pub value: f64,
68}
69
70impl GaugeMetric {
71    pub fn new(name: impl Into<String>, value: f64) -> Self {
72        Self {
73            name: name.into(),
74            help: None,
75            labels: HashMap::new(),
76            value,
77        }
78    }
79}
80
81/// A histogram metric.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct HistogramMetric {
84    pub name: String,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub help: Option<String>,
87    #[serde(default)]
88    pub labels: HashMap<String, String>,
89    pub sum: f64,
90    pub count: u64,
91    pub buckets: Vec<HistogramBucket>,
92}
93
94/// A histogram bucket.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct HistogramBucket {
97    #[serde(serialize_with = "serialize_le", deserialize_with = "deserialize_le")]
98    pub le: f64,
99    pub count: u64,
100}
101
102impl HistogramBucket {
103    pub fn new(le: f64) -> Self {
104        Self { le, count: 0 }
105    }
106    pub fn infinity() -> Self {
107        Self {
108            le: f64::INFINITY,
109            count: 0,
110        }
111    }
112}
113
114fn serialize_le<S>(le: &f64, serializer: S) -> Result<S::Ok, S::Error>
115where
116    S: serde::Serializer,
117{
118    if le.is_infinite() {
119        serializer.serialize_str("+Inf")
120    } else {
121        serializer.serialize_f64(*le)
122    }
123}
124
125fn deserialize_le<'de, D>(deserializer: D) -> Result<f64, D::Error>
126where
127    D: serde::Deserializer<'de>,
128{
129    use serde::de::{self, Visitor};
130    struct LeVisitor;
131    impl<'de> Visitor<'de> for LeVisitor {
132        type Value = f64;
133        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
134            f.write_str("float or +Inf")
135        }
136        fn visit_f64<E: de::Error>(self, v: f64) -> Result<Self::Value, E> {
137            Ok(v)
138        }
139        fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
140            Ok(v as f64)
141        }
142        fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
143            Ok(v as f64)
144        }
145        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
146            if v == "+Inf" || v == "Inf" {
147                Ok(f64::INFINITY)
148            } else {
149                v.parse().map_err(de::Error::custom)
150            }
151        }
152    }
153    deserializer.deserialize_any(LeVisitor)
154}
155
156/// Standard metric names.
157pub mod standard {
158    pub const REQUESTS_TOTAL: &str = "agent_requests_total";
159    pub const REQUESTS_BLOCKED_TOTAL: &str = "agent_requests_blocked_total";
160    pub const REQUESTS_DURATION_SECONDS: &str = "agent_requests_duration_seconds";
161    pub const ERRORS_TOTAL: &str = "agent_errors_total";
162    pub const IN_FLIGHT_REQUESTS: &str = "agent_in_flight_requests";
163    pub const QUEUE_DEPTH: &str = "agent_queue_depth";
164}
165
166fn now_ms() -> u64 {
167    std::time::SystemTime::now()
168        .duration_since(std::time::UNIX_EPOCH)
169        .map(|d| d.as_millis() as u64)
170        .unwrap_or(0)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_metrics_report() {
179        let report = MetricsReport::new("test-agent", 10_000);
180        assert!(report.is_empty());
181    }
182
183    #[test]
184    fn test_counter_metric() {
185        let counter = CounterMetric::new("test_counter", 100);
186        assert_eq!(counter.value, 100);
187    }
188
189    #[test]
190    fn test_histogram_bucket_infinity() {
191        let bucket = HistogramBucket::infinity();
192        assert!(bucket.le.is_infinite());
193
194        let json = serde_json::to_string(&bucket).unwrap();
195        assert!(json.contains("+Inf"));
196
197        let parsed: HistogramBucket = serde_json::from_str(&json).unwrap();
198        assert!(parsed.le.is_infinite());
199    }
200}