1use std::sync::{Arc, RwLock};
29
30use serde::{Deserialize, Serialize};
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct MetricDescriptor {
35 pub name: String,
37 #[serde(rename = "type")]
39 pub metric_type: MetricType,
40 pub description: String,
42 pub unit: String,
44 pub labels: Vec<String>,
46 pub group: String,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub buckets: Option<Vec<f64>>,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub use_cases: Vec<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub dashboard_hint: Option<String>,
58}
59
60#[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#[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
80struct MetricRegistryInner {
82 descriptors: Vec<MetricDescriptor>,
83 app: String,
84 version: String,
85 commit: String,
86 registered_at: String,
87}
88
89#[derive(Clone)]
94pub struct MetricRegistry {
95 inner: Arc<RwLock<MetricRegistryInner>>,
96}
97
98impl MetricRegistry {
99 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 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 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 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 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 #[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
172pub(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 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 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}