1use std::sync::{Arc, RwLock};
30
31use serde::{Deserialize, Serialize};
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct MetricDescriptor {
36 pub name: String,
38 #[serde(rename = "type")]
40 pub metric_type: MetricType,
41 pub description: String,
43 pub unit: String,
45 pub labels: Vec<String>,
47 pub group: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub buckets: Option<Vec<f64>>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub use_cases: Vec<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub dashboard_hint: Option<String>,
59}
60
61#[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#[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
83struct MetricRegistryInner {
85 descriptors: Vec<MetricDescriptor>,
86 app: String,
87 version: String,
88 commit: String,
89 registered_at: String,
90}
91
92#[derive(Clone)]
97pub struct MetricRegistry {
98 inner: Arc<RwLock<MetricRegistryInner>>,
99}
100
101impl MetricRegistry {
102 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 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 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 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 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 #[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
175pub(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 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 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}