Skip to main content

forge_core/observability/
metric.rs

1use std::collections::HashMap;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6/// Metric kind.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum MetricKind {
10    /// Counter that only increases.
11    Counter,
12    /// Gauge that can increase or decrease.
13    Gauge,
14    /// Histogram for distributions.
15    Histogram,
16    /// Summary with quantiles.
17    Summary,
18}
19
20/// Error for parsing MetricKind from string.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ParseMetricKindError(pub String);
23
24impl std::fmt::Display for ParseMetricKindError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(f, "invalid metric kind: {}", self.0)
27    }
28}
29
30impl std::error::Error for ParseMetricKindError {}
31
32impl FromStr for MetricKind {
33    type Err = ParseMetricKindError;
34
35    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
36        match s.to_lowercase().as_str() {
37            "counter" => Ok(Self::Counter),
38            "gauge" => Ok(Self::Gauge),
39            "histogram" => Ok(Self::Histogram),
40            "summary" => Ok(Self::Summary),
41            _ => Err(ParseMetricKindError(s.to_string())),
42        }
43    }
44}
45
46impl std::fmt::Display for MetricKind {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::Counter => write!(f, "counter"),
50            Self::Gauge => write!(f, "gauge"),
51            Self::Histogram => write!(f, "histogram"),
52            Self::Summary => write!(f, "summary"),
53        }
54    }
55}
56
57/// Metric labels as key-value pairs.
58pub type MetricLabels = HashMap<String, String>;
59
60/// A metric value.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub enum MetricValue {
63    /// Counter or gauge value.
64    Value(f64),
65    /// Histogram buckets with counts.
66    Histogram {
67        buckets: Vec<(f64, u64)>,
68        count: u64,
69        sum: f64,
70    },
71    /// Summary with quantiles.
72    Summary {
73        quantiles: Vec<(f64, f64)>,
74        count: u64,
75        sum: f64,
76    },
77}
78
79impl MetricValue {
80    /// Create a simple value.
81    pub fn value(v: f64) -> Self {
82        Self::Value(v)
83    }
84
85    /// Create a histogram.
86    pub fn histogram(buckets: Vec<(f64, u64)>, count: u64, sum: f64) -> Self {
87        Self::Histogram {
88            buckets,
89            count,
90            sum,
91        }
92    }
93
94    /// Create a summary.
95    pub fn summary(quantiles: Vec<(f64, f64)>, count: u64, sum: f64) -> Self {
96        Self::Summary {
97            quantiles,
98            count,
99            sum,
100        }
101    }
102
103    /// Get the scalar value if applicable.
104    pub fn as_value(&self) -> Option<f64> {
105        match self {
106            Self::Value(v) => Some(*v),
107            _ => None,
108        }
109    }
110}
111
112/// A single metric data point.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct Metric {
115    /// Metric name.
116    pub name: String,
117    /// Metric kind.
118    pub kind: MetricKind,
119    /// Metric labels.
120    pub labels: MetricLabels,
121    /// Metric value.
122    pub value: MetricValue,
123    /// Timestamp.
124    pub timestamp: chrono::DateTime<chrono::Utc>,
125    /// Description (for registration).
126    pub description: Option<String>,
127}
128
129impl Metric {
130    /// Create a new counter metric.
131    pub fn counter(name: impl Into<String>, value: f64) -> Self {
132        Self {
133            name: name.into(),
134            kind: MetricKind::Counter,
135            labels: HashMap::new(),
136            value: MetricValue::Value(value),
137            timestamp: chrono::Utc::now(),
138            description: None,
139        }
140    }
141
142    /// Create a new gauge metric.
143    pub fn gauge(name: impl Into<String>, value: f64) -> Self {
144        Self {
145            name: name.into(),
146            kind: MetricKind::Gauge,
147            labels: HashMap::new(),
148            value: MetricValue::Value(value),
149            timestamp: chrono::Utc::now(),
150            description: None,
151        }
152    }
153
154    /// Create a histogram metric.
155    pub fn histogram(
156        name: impl Into<String>,
157        buckets: Vec<(f64, u64)>,
158        count: u64,
159        sum: f64,
160    ) -> Self {
161        Self {
162            name: name.into(),
163            kind: MetricKind::Histogram,
164            labels: HashMap::new(),
165            value: MetricValue::Histogram {
166                buckets,
167                count,
168                sum,
169            },
170            timestamp: chrono::Utc::now(),
171            description: None,
172        }
173    }
174
175    /// Add a label.
176    pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
177        self.labels.insert(key.into(), value.into());
178        self
179    }
180
181    /// Add multiple labels.
182    pub fn with_labels(mut self, labels: MetricLabels) -> Self {
183        self.labels.extend(labels);
184        self
185    }
186
187    /// Set the description.
188    pub fn with_description(mut self, description: impl Into<String>) -> Self {
189        self.description = Some(description.into());
190        self
191    }
192
193    /// Set the timestamp.
194    pub fn with_timestamp(mut self, timestamp: chrono::DateTime<chrono::Utc>) -> Self {
195        self.timestamp = timestamp;
196        self
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_metric_kind_from_str() {
206        assert_eq!("counter".parse::<MetricKind>(), Ok(MetricKind::Counter));
207        assert_eq!("GAUGE".parse::<MetricKind>(), Ok(MetricKind::Gauge));
208        assert_eq!("Histogram".parse::<MetricKind>(), Ok(MetricKind::Histogram));
209        assert!("unknown".parse::<MetricKind>().is_err());
210    }
211
212    #[test]
213    fn test_counter_metric() {
214        let metric = Metric::counter("http_requests_total", 100.0)
215            .with_label("method", "GET")
216            .with_label("status", "200");
217
218        assert_eq!(metric.name, "http_requests_total");
219        assert_eq!(metric.kind, MetricKind::Counter);
220        assert_eq!(metric.labels.get("method"), Some(&"GET".to_string()));
221        assert_eq!(metric.value.as_value(), Some(100.0));
222    }
223
224    #[test]
225    fn test_gauge_metric() {
226        let metric = Metric::gauge("active_connections", 42.0);
227        assert_eq!(metric.kind, MetricKind::Gauge);
228        assert_eq!(metric.value.as_value(), Some(42.0));
229    }
230
231    #[test]
232    fn test_histogram_metric() {
233        let buckets = vec![(0.1, 10), (0.5, 50), (1.0, 80), (5.0, 100)];
234        let metric = Metric::histogram("request_duration", buckets.clone(), 100, 45.5);
235
236        assert_eq!(metric.kind, MetricKind::Histogram);
237        if let MetricValue::Histogram {
238            buckets: b,
239            count,
240            sum,
241        } = &metric.value
242        {
243            assert_eq!(b, &buckets);
244            assert_eq!(*count, 100);
245            assert_eq!(*sum, 45.5);
246        } else {
247            panic!("Expected histogram value");
248        }
249    }
250}