Skip to main content

hyperi_rustlib/metrics/
manifest.rs

1// Project:   hyperi-rustlib
2// File:      src/metrics/manifest.rs
3// Purpose:   Metric manifest types and registry for /metrics/manifest endpoint
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Metric manifest types for the `/metrics/manifest` endpoint.
10//!
11//! Provides [`MetricDescriptor`], [`MetricRegistry`], and [`ManifestResponse`]
12//! for exposing machine-readable metric metadata. Field semantics align with
13//! [OpenMetrics](https://prometheus.io/docs/specs/om/open_metrics_spec/) (type,
14//! description, unit) and [OTel Advisory Parameters](https://opentelemetry.io/docs/specs/otel/metrics/api/)
15//! (labels, buckets). Novel fields (`group`, `use_cases`, `dashboard_hint`)
16//! are HyperI extensions.
17//!
18//! ## Standards Alignment
19//!
20//! | Field | Standard |
21//! |-------|----------|
22//! | `type` | OpenMetrics `TYPE` |
23//! | `description` | OpenMetrics `HELP` |
24//! | `unit` | OpenMetrics `UNIT` |
25//! | `labels` | OTel Advisory `Attributes` |
26//! | `buckets` | OTel Advisory `ExplicitBucketBoundaries` |
27//! | `group`, `use_cases`, `dashboard_hint` | HyperI extensions |
28
29use std::sync::{Arc, RwLock};
30
31use serde::{Deserialize, Serialize};
32
33/// Describes a single registered metric for the manifest.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct MetricDescriptor {
36    /// Full metric name including namespace prefix.
37    pub name: String,
38    /// Metric type (aligns with OpenMetrics TYPE).
39    #[serde(rename = "type")]
40    pub metric_type: MetricType,
41    /// Human-readable description (aligns with OpenMetrics HELP).
42    pub description: String,
43    /// Unit suffix (aligns with OpenMetrics UNIT). Empty for counters.
44    pub unit: String,
45    /// Known label keys (aligns with OTel Advisory Attributes).
46    pub labels: Vec<String>,
47    /// Metric group membership. Always present. Defaults to `"custom"`.
48    pub group: String,
49    /// Histogram bucket boundaries (aligns with OTel Advisory ExplicitBucketBoundaries).
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub buckets: Option<Vec<f64>>,
52    /// Operational guidance: when to alert, what dashboard to use.
53    /// Novel HyperI extension. Omitted from JSON when empty.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub use_cases: Vec<String>,
56    /// Suggested Grafana panel type. Novel HyperI extension.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub dashboard_hint: Option<String>,
59}
60
61/// Metric type discriminator (aligns with OpenMetrics TYPE).
62///
63/// Derives `Copy` for efficient pattern matching and comparison.
64#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(rename_all = "snake_case")]
66pub enum MetricType {
67    Counter,
68    Gauge,
69    Histogram,
70}
71
72/// JSON response for `GET /metrics/manifest`.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ManifestResponse {
75    pub schema_version: u32,
76    pub app: String,
77    pub version: String,
78    pub commit: String,
79    pub registered_at: String,
80    pub metrics: Vec<MetricDescriptor>,
81}
82
83/// Inner state of the metric registry.
84struct MetricRegistryInner {
85    descriptors: Vec<MetricDescriptor>,
86    app: String,
87    version: String,
88    commit: String,
89    registered_at: String,
90}
91
92/// Cloneable handle to the metric registry.
93///
94/// Obtained via [`super::MetricsManager::registry`]. Safe to clone into
95/// axum route handlers or share across tasks.
96#[derive(Clone)]
97pub struct MetricRegistry {
98    inner: Arc<RwLock<MetricRegistryInner>>,
99}
100
101impl MetricRegistry {
102    /// Create a new registry for the given app namespace.
103    pub(crate) fn new(app: &str) -> Self {
104        Self {
105            inner: Arc::new(RwLock::new(MetricRegistryInner {
106                descriptors: Vec::new(),
107                app: app.to_string(),
108                version: String::new(),
109                commit: String::new(),
110                registered_at: now_rfc3339(),
111            })),
112        }
113    }
114
115    /// Push a metric descriptor into the registry.
116    pub(crate) fn push(&self, descriptor: MetricDescriptor) {
117        if let Ok(mut inner) = self.inner.write() {
118            inner.descriptors.push(descriptor);
119        }
120    }
121
122    /// Set the application version and commit.
123    pub(crate) fn set_build_info(&self, version: &str, commit: &str) {
124        if let Ok(mut inner) = self.inner.write() {
125            inner.version = version.to_string();
126            inner.commit = commit.to_string();
127        }
128    }
129
130    /// Set use cases for a metric by full name. No-op if not found.
131    pub(crate) fn set_use_cases(&self, metric_name: &str, use_cases: &[&str]) {
132        if let Ok(mut inner) = self.inner.write() {
133            if let Some(desc) = inner.descriptors.iter_mut().find(|d| d.name == metric_name) {
134                desc.use_cases = use_cases.iter().map(|s| (*s).to_string()).collect();
135            } else {
136                #[cfg(feature = "logger")]
137                tracing::warn!(
138                    metric = metric_name,
139                    "set_use_cases: metric not found in registry"
140                );
141            }
142        }
143    }
144
145    /// Set dashboard hint for a metric by full name. No-op if not found.
146    pub(crate) fn set_dashboard_hint(&self, metric_name: &str, hint: &str) {
147        if let Ok(mut inner) = self.inner.write() {
148            if let Some(desc) = inner.descriptors.iter_mut().find(|d| d.name == metric_name) {
149                desc.dashboard_hint = Some(hint.to_string());
150            } else {
151                #[cfg(feature = "logger")]
152                tracing::warn!(
153                    metric = metric_name,
154                    "set_dashboard_hint: metric not found in registry"
155                );
156            }
157        }
158    }
159
160    /// Build the manifest response snapshot.
161    #[must_use]
162    pub fn manifest(&self) -> ManifestResponse {
163        let inner = self.inner.read().expect("registry lock poisoned");
164        ManifestResponse {
165            schema_version: 1,
166            app: inner.app.clone(),
167            version: inner.version.clone(),
168            commit: inner.commit.clone(),
169            registered_at: inner.registered_at.clone(),
170            metrics: inner.descriptors.clone(),
171        }
172    }
173}
174
175/// Format current UTC time as RFC 3339 with second precision (no sub-seconds).
176///
177/// Output: `2026-03-31T02:00:00Z`
178///
179/// Pure function, no global state, trivially thread-safe.
180pub(crate) fn now_rfc3339() -> String {
181    let d = std::time::SystemTime::now()
182        .duration_since(std::time::UNIX_EPOCH)
183        .unwrap_or_default();
184    let total_secs = d.as_secs();
185
186    #[allow(clippy::cast_possible_wrap)]
187    let days = (total_secs / 86400) as i64;
188    let time_of_day = total_secs % 86400;
189
190    let hours = time_of_day / 3600;
191    let minutes = (time_of_day % 3600) / 60;
192    let seconds = time_of_day % 60;
193
194    // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm)
195    let z = days + 719_468;
196    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
197    #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
198    let doe = (z - era * 146_097) as u64;
199    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
200    #[allow(clippy::cast_possible_wrap)]
201    let y = (yoe as i64) + era * 400;
202    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
203    let mp = (5 * doy + 2) / 153;
204    let d = doy - (153 * mp + 2) / 5 + 1;
205    let m = if mp < 10 { mp + 3 } else { mp - 9 };
206    let y = if m <= 2 { y + 1 } else { y };
207
208    format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_metric_type_serializes_to_snake_case() {
217        assert_eq!(
218            serde_json::to_string(&MetricType::Counter).unwrap(),
219            "\"counter\""
220        );
221        assert_eq!(
222            serde_json::to_string(&MetricType::Gauge).unwrap(),
223            "\"gauge\""
224        );
225        assert_eq!(
226            serde_json::to_string(&MetricType::Histogram).unwrap(),
227            "\"histogram\""
228        );
229    }
230
231    #[test]
232    fn test_metric_descriptor_serializes_type_as_type() {
233        let desc = MetricDescriptor {
234            name: "test_total".into(),
235            metric_type: MetricType::Counter,
236            description: "A test counter".into(),
237            unit: String::new(),
238            labels: vec![],
239            group: "custom".into(),
240            buckets: None,
241            use_cases: vec![],
242            dashboard_hint: None,
243        };
244        let json = serde_json::to_value(&desc).unwrap();
245        assert_eq!(json["type"], "counter");
246        assert!(json.get("metric_type").is_none());
247    }
248
249    #[test]
250    fn test_empty_use_cases_omitted_from_json() {
251        let desc = MetricDescriptor {
252            name: "test_gauge".into(),
253            metric_type: MetricType::Gauge,
254            description: "A gauge".into(),
255            unit: String::new(),
256            labels: vec![],
257            group: "custom".into(),
258            buckets: None,
259            use_cases: vec![],
260            dashboard_hint: None,
261        };
262        let json = serde_json::to_value(&desc).unwrap();
263        assert!(json.get("use_cases").is_none());
264        assert!(json.get("buckets").is_none());
265        assert!(json.get("dashboard_hint").is_none());
266    }
267
268    #[test]
269    fn test_populated_use_cases_included() {
270        let desc = MetricDescriptor {
271            name: "test_hist".into(),
272            metric_type: MetricType::Histogram,
273            description: "A histogram".into(),
274            unit: "seconds".into(),
275            labels: vec!["backend".into()],
276            group: "sink".into(),
277            buckets: Some(vec![0.01, 0.1, 1.0]),
278            use_cases: vec!["Alert when p99 > 5s".into()],
279            dashboard_hint: Some("heatmap".into()),
280        };
281        let json = serde_json::to_value(&desc).unwrap();
282        assert_eq!(
283            json["use_cases"],
284            serde_json::json!(["Alert when p99 > 5s"])
285        );
286        assert_eq!(json["buckets"], serde_json::json!([0.01, 0.1, 1.0]));
287        assert_eq!(json["dashboard_hint"], "heatmap");
288    }
289
290    #[test]
291    fn test_manifest_response_round_trips() {
292        let manifest = ManifestResponse {
293            schema_version: 1,
294            app: "test_app".into(),
295            version: "1.0.0".into(),
296            commit: "abc123".into(),
297            registered_at: "2026-03-31T00:00:00Z".into(),
298            metrics: vec![MetricDescriptor {
299                name: "test_total".into(),
300                metric_type: MetricType::Counter,
301                description: "test".into(),
302                unit: String::new(),
303                labels: vec![],
304                group: "custom".into(),
305                buckets: None,
306                use_cases: vec![],
307                dashboard_hint: None,
308            }],
309        };
310        let json = serde_json::to_string(&manifest).unwrap();
311        let parsed: ManifestResponse = serde_json::from_str(&json).unwrap();
312        assert_eq!(parsed.schema_version, 1);
313        assert_eq!(parsed.app, "test_app");
314        assert_eq!(parsed.metrics.len(), 1);
315        assert_eq!(parsed.metrics[0].metric_type, MetricType::Counter);
316    }
317
318    #[test]
319    fn test_counter_unit_is_empty_not_total() {
320        let desc = MetricDescriptor {
321            name: "requests_total".into(),
322            metric_type: MetricType::Counter,
323            description: "Requests".into(),
324            unit: String::new(),
325            labels: vec![],
326            group: "custom".into(),
327            buckets: None,
328            use_cases: vec![],
329            dashboard_hint: None,
330        };
331        let json = serde_json::to_value(&desc).unwrap();
332        assert_eq!(json["unit"], "");
333    }
334
335    #[test]
336    fn test_now_rfc3339_format() {
337        let ts = now_rfc3339();
338        assert_eq!(ts.len(), 20);
339        assert!(ts.ends_with('Z'));
340        assert_eq!(&ts[4..5], "-");
341        assert_eq!(&ts[7..8], "-");
342        assert_eq!(&ts[10..11], "T");
343        assert_eq!(&ts[13..14], ":");
344        assert_eq!(&ts[16..17], ":");
345    }
346
347    #[test]
348    fn test_registry_push_and_manifest() {
349        let reg = MetricRegistry::new("test_app");
350        reg.push(MetricDescriptor {
351            name: "test_app_requests_total".into(),
352            metric_type: MetricType::Counter,
353            description: "Total requests".into(),
354            unit: String::new(),
355            labels: vec!["method".into()],
356            group: "app".into(),
357            buckets: None,
358            use_cases: vec![],
359            dashboard_hint: None,
360        });
361        let manifest = reg.manifest();
362        assert_eq!(manifest.app, "test_app");
363        assert_eq!(manifest.schema_version, 1);
364        assert_eq!(manifest.metrics.len(), 1);
365        assert_eq!(manifest.metrics[0].name, "test_app_requests_total");
366        assert_eq!(manifest.metrics[0].labels, vec!["method"]);
367    }
368
369    #[test]
370    fn test_registry_set_build_info() {
371        let reg = MetricRegistry::new("test_app");
372        reg.set_build_info("2.0.0", "def456");
373        let manifest = reg.manifest();
374        assert_eq!(manifest.version, "2.0.0");
375        assert_eq!(manifest.commit, "def456");
376    }
377
378    #[test]
379    fn test_registry_set_use_cases() {
380        let reg = MetricRegistry::new("test_app");
381        reg.push(MetricDescriptor {
382            name: "my_metric".into(),
383            metric_type: MetricType::Gauge,
384            description: "test".into(),
385            unit: String::new(),
386            labels: vec![],
387            group: "custom".into(),
388            buckets: None,
389            use_cases: vec![],
390            dashboard_hint: None,
391        });
392        reg.set_use_cases("my_metric", &["Alert when > 90%"]);
393        let manifest = reg.manifest();
394        assert_eq!(manifest.metrics[0].use_cases, vec!["Alert when > 90%"]);
395    }
396
397    #[test]
398    fn test_registry_set_use_cases_nonexistent_is_noop() {
399        let reg = MetricRegistry::new("test_app");
400        // Should not panic
401        reg.set_use_cases("nonexistent", &["some use case"]);
402    }
403
404    #[test]
405    fn test_registry_set_dashboard_hint() {
406        let reg = MetricRegistry::new("test_app");
407        reg.push(MetricDescriptor {
408            name: "my_metric".into(),
409            metric_type: MetricType::Gauge,
410            description: "test".into(),
411            unit: String::new(),
412            labels: vec![],
413            group: "custom".into(),
414            buckets: None,
415            use_cases: vec![],
416            dashboard_hint: None,
417        });
418        reg.set_dashboard_hint("my_metric", "stat");
419        let manifest = reg.manifest();
420        assert_eq!(manifest.metrics[0].dashboard_hint, Some("stat".to_string()));
421    }
422
423    #[test]
424    fn test_group_always_present_in_json() {
425        let desc = MetricDescriptor {
426            name: "test".into(),
427            metric_type: MetricType::Counter,
428            description: "test".into(),
429            unit: String::new(),
430            labels: vec![],
431            group: "custom".into(),
432            buckets: None,
433            use_cases: vec![],
434            dashboard_hint: None,
435        };
436        let json = serde_json::to_value(&desc).unwrap();
437        assert_eq!(json["group"], "custom");
438    }
439}