khora_core/telemetry/
metrics.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Abstract definitions for engine metrics and telemetry.
16
17use std::fmt::{Debug, Display};
18use std::hash::Hash;
19use std::time::Instant;
20
21/// A unique, structured identifier for a metric.
22///
23/// A `MetricId` is composed of a namespace, a name, and a set of key-value labels,
24/// allowing for powerful filtering and querying of telemetry data.
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct MetricId {
27    /// The broad category of the metric (e.g., "renderer", "memory").
28    pub namespace: String,
29    /// The specific name of the metric (e.g., "frame_time_ms", "triangles_rendered").
30    pub name: String,
31    /// Optional, sorted key-value pairs for dimensional filtering.
32    pub labels: Vec<(String, String)>,
33}
34
35impl MetricId {
36    /// Creates a new `MetricId` with a namespace and a name.
37    pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
38        Self {
39            namespace: namespace.into(),
40            name: name.into(),
41            labels: Vec::new(),
42        }
43    }
44
45    /// Adds a dimensional label to the metric ID, returning a new `MetricId`.
46    /// Labels are kept sorted by key for consistent hashing and display.
47    pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
48        self.labels.push((key.into(), value.into()));
49        self.labels.sort_by(|a, b| a.0.cmp(&b.0));
50        self
51    }
52
53    /// Returns a formatted string representation of the ID (e.g., "namespace:name[k=v,...]").
54    pub fn to_string_formatted(&self) -> String {
55        if self.labels.is_empty() {
56            format!("{}:{}", self.namespace, self.name)
57        } else {
58            let labels_str = self
59                .labels
60                .iter()
61                .map(|(k, v)| format!("{k}={v}"))
62                .collect::<Vec<_>>()
63                .join(",");
64            format!("{}:{}[{}]", self.namespace, self.name, labels_str)
65        }
66    }
67}
68
69impl Display for MetricId {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(f, "{}", self.to_string_formatted())
72    }
73}
74
75/// The fundamental type of a metric.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum MetricType {
78    /// A value that only ever increases or resets to zero (e.g., total requests).
79    Counter,
80    /// A value that can go up or down (e.g., current memory usage).
81    Gauge,
82    /// A value that tracks the distribution of a set of measurements.
83    Histogram,
84}
85
86/// An enumeration of possible metric values.
87#[derive(Debug, Clone)]
88pub enum MetricValue {
89    /// A 64-bit unsigned integer for counters.
90    Counter(u64),
91    /// A 64-bit float for gauges.
92    Gauge(f64),
93    /// A collection of samples and their distribution across predefined buckets.
94    Histogram {
95        /// The raw samples recorded.
96        samples: Vec<f64>,
97        /// The upper bounds of the histogram buckets.
98        bucket_bounds: Vec<f64>,
99        /// The count of samples within each bucket.
100        bucket_counts: Vec<u64>,
101    },
102}
103
104impl MetricValue {
105    /// Returns the [`MetricType`] corresponding to this value.
106    pub fn metric_type(&self) -> MetricType {
107        match self {
108            MetricValue::Counter(_) => MetricType::Counter,
109            MetricValue::Gauge(_) => MetricType::Gauge,
110            MetricValue::Histogram { .. } => MetricType::Histogram,
111        }
112    }
113
114    /// Returns the value as an `f64` if it is a `Counter` or `Gauge`.
115    pub fn as_f64(&self) -> Option<f64> {
116        match self {
117            MetricValue::Counter(v) => Some(*v as f64),
118            MetricValue::Gauge(v) => Some(*v),
119            MetricValue::Histogram { .. } => None,
120        }
121    }
122
123    /// Returns the value as a `u64` if it is a `Counter`.
124    pub fn as_counter(&self) -> Option<u64> {
125        match self {
126            MetricValue::Counter(v) => Some(*v),
127            _ => None,
128        }
129    }
130
131    /// Returns the value as an `f64` if it is a `Gauge`.
132    pub fn as_gauge(&self) -> Option<f64> {
133        match self {
134            MetricValue::Gauge(v) => Some(*v),
135            _ => None,
136        }
137    }
138}
139
140/// Descriptive, static metadata about a metric.
141#[derive(Debug, Clone)]
142pub struct MetricMetadata {
143    /// The metric's unique identifier.
144    pub id: MetricId,
145    /// The type of the metric.
146    pub metric_type: MetricType,
147    /// A human-readable description of what the metric measures.
148    pub description: String,
149    /// The unit of measurement (e.g., "ms", "bytes").
150    pub unit: String,
151    /// The timestamp when this metric was first registered.
152    pub created_at: Instant,
153    /// The timestamp when this metric was last updated.
154    pub last_updated: Instant,
155}
156
157impl MetricMetadata {
158    /// Creates new metadata for a metric.
159    pub fn new(
160        id: MetricId,
161        metric_type: MetricType,
162        description: impl Into<String>,
163        unit: impl Into<String>,
164    ) -> Self {
165        let now = Instant::now();
166        Self {
167            id,
168            metric_type,
169            description: description.into(),
170            unit: unit.into(),
171            created_at: now,
172            last_updated: now,
173        }
174    }
175
176    /// Updates the `last_updated` timestamp to the current time.
177    pub fn update_timestamp(&mut self) {
178        self.last_updated = Instant::now();
179    }
180}
181
182/// A complete metric entry, combining its value with its descriptive metadata.
183#[derive(Debug, Clone)]
184pub struct Metric {
185    /// The static, descriptive metadata for the metric.
186    pub metadata: MetricMetadata,
187    /// The current, dynamic value of the metric.
188    pub value: MetricValue,
189}
190
191impl Metric {
192    /// A convenience constructor for creating a new `Counter` metric.
193    pub fn new_counter(id: MetricId, description: impl Into<String>, initial_value: u64) -> Self {
194        Self {
195            metadata: MetricMetadata::new(id, MetricType::Counter, description, "count"),
196            value: MetricValue::Counter(initial_value),
197        }
198    }
199
200    /// A convenience constructor for creating a new `Gauge` metric.
201    pub fn new_gauge(
202        id: MetricId,
203        description: impl Into<String>,
204        unit: impl Into<String>,
205        initial_value: f64,
206    ) -> Self {
207        Self {
208            metadata: MetricMetadata::new(id, MetricType::Gauge, description, unit),
209            value: MetricValue::Gauge(initial_value),
210        }
211    }
212
213    /// A convenience constructor for creating a new `Histogram` metric.
214    pub fn new_histogram(
215        id: MetricId,
216        description: impl Into<String>,
217        unit: impl Into<String>,
218        bucket_bounds: Vec<f64>,
219    ) -> Self {
220        let bucket_counts = vec![0; bucket_bounds.len()];
221        Self {
222            metadata: MetricMetadata::new(id, MetricType::Histogram, description, unit),
223            value: MetricValue::Histogram {
224                samples: Vec::new(),
225                bucket_bounds,
226                bucket_counts,
227            },
228        }
229    }
230}
231
232/// A specialized `Result` type for metric-related operations.
233pub type MetricsResult<T> = Result<T, MetricsError>;
234
235/// An error that can occur within the metrics system.
236#[derive(Debug, Clone)]
237pub enum MetricsError {
238    /// The requested metric was not found in the registry.
239    MetricNotFound(MetricId),
240    /// An operation was attempted on a metric of the wrong type
241    /// (e.g., trying to set a gauge value on a counter).
242    TypeMismatch {
243        /// The expected metric type for the operation.
244        expected: MetricType,
245        /// The actual metric type that was found.
246        found: MetricType,
247    },
248    /// An error originating from the backend storage layer.
249    StorageError(String),
250    /// An invalid operation was attempted (e.g., invalid histogram bounds).
251    InvalidOperation(String),
252}
253
254impl Display for MetricsError {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        match self {
257            MetricsError::MetricNotFound(id) => write!(f, "Metric not found: {id}"),
258            MetricsError::TypeMismatch { expected, found } => {
259                write!(f, "Type mismatch: expected {expected:?}, found {found:?}")
260            }
261            MetricsError::StorageError(msg) => write!(f, "Storage error: {msg}"),
262            MetricsError::InvalidOperation(msg) => write!(f, "Invalid operation: {msg}"),
263        }
264    }
265}
266
267impl std::error::Error for MetricsError {}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_metric_id_creation() {
275        let id = MetricId::new("engine", "frame_time")
276            .with_label("gpu", "nvidia")
277            .with_label("quality", "high");
278
279        assert_eq!(id.namespace, "engine");
280        assert_eq!(id.name, "frame_time");
281        assert_eq!(id.labels.len(), 2);
282
283        // Labels should be sorted
284        assert_eq!(id.labels[0], ("gpu".to_string(), "nvidia".to_string()));
285        assert_eq!(id.labels[1], ("quality".to_string(), "high".to_string()));
286    }
287
288    #[test]
289    fn test_metric_id_formatting() {
290        let id1 = MetricId::new("engine", "frame_time");
291        assert_eq!(id1.to_string_formatted(), "engine:frame_time");
292
293        let id2 = MetricId::new("renderer", "triangles")
294            .with_label("pass", "main")
295            .with_label("quality", "high");
296        assert_eq!(
297            id2.to_string_formatted(),
298            "renderer:triangles[pass=main,quality=high]"
299        );
300    }
301
302    #[test]
303    fn test_metric_value_types() {
304        let counter = MetricValue::Counter(42);
305        assert_eq!(counter.metric_type(), MetricType::Counter);
306        assert_eq!(counter.as_counter(), Some(42));
307        assert_eq!(counter.as_f64(), Some(42.0));
308
309        use crate::math::PI;
310
311        let gauge = MetricValue::Gauge(PI as f64);
312        assert_eq!(gauge.metric_type(), MetricType::Gauge);
313        assert_eq!(gauge.as_gauge(), Some(PI as f64));
314        assert_eq!(gauge.as_f64(), Some(PI as f64));
315    }
316
317    #[test]
318    fn test_metric_creation() {
319        let id = MetricId::new("test", "counter");
320        let metric = Metric::new_counter(id.clone(), "Test counter", 0);
321
322        assert_eq!(metric.metadata.id, id);
323        assert_eq!(metric.metadata.metric_type, MetricType::Counter);
324        assert_eq!(metric.metadata.unit, "count");
325        assert_eq!(metric.value.as_counter(), Some(0));
326    }
327}