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