1use crate::{
11 DbMetricsRecorder, DbQueryMetric, MetricDescriptor, MetricKind, MetricsRecorder,
12 SharedMetricsRecorder,
13};
14use prometheus::{
15 Encoder, Gauge, GaugeVec, HistogramOpts, HistogramVec, IntCounterVec, IntGauge, Opts, Registry,
16 TextEncoder,
17};
18use std::collections::BTreeMap;
19use std::future::Future;
20use std::pin::Pin;
21use std::sync::{Mutex, OnceLock};
22use std::time::Instant;
23use tokio_util::sync::CancellationToken;
24
25static METRICS: OnceLock<PrometheusMetrics> = OnceLock::new();
26static PROCESS_STARTED_AT: OnceLock<Instant> = OnceLock::new();
27
28fn boxed_collector<C>(collector: C) -> Box<dyn prometheus::core::Collector>
29where
30 C: prometheus::core::Collector + 'static,
31{
32 Box::new(collector)
33}
34
35pub struct PrometheusMetrics {
37 registry: Registry,
38 http_requests_total: IntCounterVec,
39 http_request_duration_seconds: HistogramVec,
40 db_queries_total: IntCounterVec,
41 db_query_duration_seconds: HistogramVec,
42 auth_events_total: IntCounterVec,
43 application_events_total: IntCounterVec,
44 config_reloads_total: IntCounterVec,
45 config_reload_duration_seconds: HistogramVec,
46 config_reload_changed_keys: HistogramVec,
47 config_mutations_total: IntCounterVec,
48 config_mutation_changed_keys: HistogramVec,
49 background_tasks_total: IntCounterVec,
50 background_tasks_pending: IntGauge,
51 background_task_retries_total: IntCounterVec,
52 external_operations_total: IntCounterVec,
53 external_operation_duration_seconds: HistogramVec,
54 health_report_status: GaugeVec,
55 health_report_duration_seconds: HistogramVec,
56 health_component_status: GaugeVec,
57 health_component_duration_seconds: HistogramVec,
58 process_memory_rss_bytes: Gauge,
59 process_cpu_milliseconds_total: IntGauge,
60 uptime_seconds: Gauge,
61 #[cfg(feature = "allocator-metrics")]
62 process_heap_memory_mib: GaugeVec,
63 product_metrics: Mutex<ProductMetricRegistry>,
64}
65
66impl PrometheusMetrics {
67 fn new() -> Result<Self, prometheus::Error> {
68 let registry = Registry::new();
69
70 let http_requests_total = IntCounterVec::new(
71 Opts::new("http_requests_total", "Total HTTP requests"),
72 &["method", "route", "status"],
73 )?;
74 let http_request_duration_seconds = HistogramVec::new(
75 HistogramOpts::new(
76 "http_request_duration_seconds",
77 "HTTP request duration in seconds",
78 )
79 .buckets(vec![0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0]),
80 &["method", "route", "status"],
81 )?;
82 let db_queries_total = IntCounterVec::new(
83 Opts::new(
84 "db_queries_total",
85 "Total database queries observed through the shared database metrics adapter",
86 ),
87 &["backend", "kind", "status"],
88 )?;
89 let db_query_duration_seconds = HistogramVec::new(
90 HistogramOpts::new(
91 "db_query_duration_seconds",
92 "Database query duration in seconds",
93 )
94 .buckets(vec![
95 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0,
96 ]),
97 &["backend", "kind", "status"],
98 )?;
99 let auth_events_total = IntCounterVec::new(
100 Opts::new("auth_events_total", "Total authentication events"),
101 &["action", "status", "reason"],
102 )?;
103 let application_events_total = IntCounterVec::new(
104 Opts::new(
105 "application_events_total",
106 "Total low-cardinality application events",
107 ),
108 &["category", "event", "status"],
109 )?;
110 let config_reloads_total = IntCounterVec::new(
111 Opts::new(
112 "config_reloads_total",
113 "Total runtime config reload attempts",
114 ),
115 &["source", "decision", "status"],
116 )?;
117 let config_reload_duration_seconds = HistogramVec::new(
118 HistogramOpts::new(
119 "config_reload_duration_seconds",
120 "Runtime config reload duration in seconds",
121 )
122 .buckets(vec![
123 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0,
124 ]),
125 &["source", "decision", "status"],
126 )?;
127 let config_reload_changed_keys = HistogramVec::new(
128 HistogramOpts::new(
129 "config_reload_changed_keys",
130 "Number of changed keys observed by runtime config reload attempts",
131 )
132 .buckets(vec![0.0, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 500.0]),
133 &["source", "decision", "status"],
134 )?;
135 let config_mutations_total = IntCounterVec::new(
136 Opts::new(
137 "config_mutations_total",
138 "Total runtime config mutation attempts",
139 ),
140 &["source", "operation", "status"],
141 )?;
142 let config_mutation_changed_keys = HistogramVec::new(
143 HistogramOpts::new(
144 "config_mutation_changed_keys",
145 "Number of changed keys in runtime config mutation attempts",
146 )
147 .buckets(vec![0.0, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0]),
148 &["source", "operation", "status"],
149 )?;
150 let background_tasks_total = IntCounterVec::new(
151 Opts::new(
152 "background_tasks_total",
153 "Total background task state transitions",
154 ),
155 &["kind", "status"],
156 )?;
157 let background_tasks_pending = IntGauge::new(
158 "background_tasks_pending",
159 "Pending or retryable background task backlog",
160 )?;
161 let background_task_retries_total = IntCounterVec::new(
162 Opts::new(
163 "background_task_retries_total",
164 "Total background task retry transitions",
165 ),
166 &["kind"],
167 )?;
168 let external_operations_total = IntCounterVec::new(
169 Opts::new(
170 "external_operations_total",
171 "Total operations against external systems",
172 ),
173 &["system", "operation", "status"],
174 )?;
175 let external_operation_duration_seconds = HistogramVec::new(
176 HistogramOpts::new(
177 "external_operation_duration_seconds",
178 "External system operation duration in seconds",
179 )
180 .buckets(vec![
181 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0, 15.0, 60.0,
182 ]),
183 &["system", "operation", "status"],
184 )?;
185 let health_report_status = GaugeVec::new(
186 Opts::new(
187 "health_report_status",
188 "Aggregate health status for a health check scope: healthy=0, degraded=1, unhealthy=2",
189 ),
190 &["scope"],
191 )?;
192 let health_report_duration_seconds = HistogramVec::new(
193 HistogramOpts::new(
194 "health_report_duration_seconds",
195 "Aggregate health check duration in seconds",
196 )
197 .buckets(vec![
198 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0,
199 ]),
200 &["scope", "status"],
201 )?;
202 let health_component_status = GaugeVec::new(
203 Opts::new(
204 "health_component_status",
205 "Health component status for a health check scope: healthy=0, degraded=1, unhealthy=2",
206 ),
207 &["scope", "component"],
208 )?;
209 let health_component_duration_seconds = HistogramVec::new(
210 HistogramOpts::new(
211 "health_component_duration_seconds",
212 "Health component check duration in seconds",
213 )
214 .buckets(vec![
215 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0,
216 ]),
217 &["scope", "component", "status"],
218 )?;
219 let process_memory_rss_bytes =
220 Gauge::new("process_memory_rss_bytes", "Process RSS memory in bytes")?;
221 let process_cpu_milliseconds_total = IntGauge::new(
222 "process_cpu_milliseconds_total",
223 "Process accumulated CPU time in milliseconds",
224 )?;
225 let uptime_seconds = Gauge::new("process_uptime_seconds", "Process uptime in seconds")?;
226 #[cfg(feature = "allocator-metrics")]
227 let process_heap_memory_mib = GaugeVec::new(
228 Opts::new(
229 "process_heap_memory_mib",
230 "Allocator heap memory in MiB: allocated and peak_or_resident",
231 ),
232 &["kind"],
233 )?;
234
235 let collectors: Vec<Box<dyn prometheus::core::Collector>> = vec![
236 boxed_collector(http_requests_total.clone()),
237 boxed_collector(http_request_duration_seconds.clone()),
238 boxed_collector(db_queries_total.clone()),
239 boxed_collector(db_query_duration_seconds.clone()),
240 boxed_collector(auth_events_total.clone()),
241 boxed_collector(application_events_total.clone()),
242 boxed_collector(config_reloads_total.clone()),
243 boxed_collector(config_reload_duration_seconds.clone()),
244 boxed_collector(config_reload_changed_keys.clone()),
245 boxed_collector(config_mutations_total.clone()),
246 boxed_collector(config_mutation_changed_keys.clone()),
247 boxed_collector(background_tasks_total.clone()),
248 boxed_collector(background_tasks_pending.clone()),
249 boxed_collector(background_task_retries_total.clone()),
250 boxed_collector(external_operations_total.clone()),
251 boxed_collector(external_operation_duration_seconds.clone()),
252 boxed_collector(health_report_status.clone()),
253 boxed_collector(health_report_duration_seconds.clone()),
254 boxed_collector(health_component_status.clone()),
255 boxed_collector(health_component_duration_seconds.clone()),
256 boxed_collector(process_memory_rss_bytes.clone()),
257 boxed_collector(process_cpu_milliseconds_total.clone()),
258 boxed_collector(uptime_seconds.clone()),
259 #[cfg(feature = "allocator-metrics")]
260 boxed_collector(process_heap_memory_mib.clone()),
261 ];
262
263 for collector in collectors {
264 registry.register(collector)?;
265 }
266
267 Ok(Self {
268 registry,
269 http_requests_total,
270 http_request_duration_seconds,
271 db_queries_total,
272 db_query_duration_seconds,
273 auth_events_total,
274 application_events_total,
275 config_reloads_total,
276 config_reload_duration_seconds,
277 config_reload_changed_keys,
278 config_mutations_total,
279 config_mutation_changed_keys,
280 background_tasks_total,
281 background_tasks_pending,
282 background_task_retries_total,
283 external_operations_total,
284 external_operation_duration_seconds,
285 health_report_status,
286 health_report_duration_seconds,
287 health_component_status,
288 health_component_duration_seconds,
289 process_memory_rss_bytes,
290 process_cpu_milliseconds_total,
291 uptime_seconds,
292 #[cfg(feature = "allocator-metrics")]
293 process_heap_memory_mib,
294 product_metrics: Mutex::new(ProductMetricRegistry::default()),
295 })
296 }
297
298 fn export(&self) -> Result<String, String> {
299 self.refresh_allocator_metrics();
300 let encoder = TextEncoder::new();
301 let metric_families = self.registry.gather();
302 let mut buf = Vec::new();
303 encoder
304 .encode(&metric_families, &mut buf)
305 .map_err(|error| error.to_string())?;
306 String::from_utf8(buf).map_err(|error| error.to_string())
307 }
308
309 #[cfg(feature = "allocator-metrics")]
310 fn refresh_allocator_metrics(&self) {
311 let (allocated_mib, peak_or_resident_mib) = aster_forge_alloc::stats();
312 self.process_heap_memory_mib
313 .with_label_values(&["allocated"])
314 .set(allocated_mib);
315 self.process_heap_memory_mib
316 .with_label_values(&["peak_or_resident"])
317 .set(peak_or_resident_mib);
318 }
319
320 #[cfg(not(feature = "allocator-metrics"))]
321 fn refresh_allocator_metrics(&self) {}
322}
323
324#[derive(Default)]
325struct ProductMetricRegistry {
326 collectors: BTreeMap<ProductMetricKey, ProductMetricCollector>,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
330struct ProductMetricKey {
331 subsystem: &'static str,
332 name: &'static str,
333}
334
335#[derive(Clone)]
336enum ProductMetricCollector {
337 Counter {
338 labels: &'static [&'static str],
339 collector: IntCounterVec,
340 },
341 Gauge {
342 labels: &'static [&'static str],
343 collector: GaugeVec,
344 },
345 Histogram {
346 labels: &'static [&'static str],
347 collector: HistogramVec,
348 },
349}
350
351impl ProductMetricCollector {
352 fn kind(&self) -> MetricKind {
353 match self {
354 Self::Counter { .. } => MetricKind::Counter,
355 Self::Gauge { .. } => MetricKind::Gauge,
356 Self::Histogram { .. } => MetricKind::Histogram,
357 }
358 }
359
360 fn label_count(&self) -> usize {
361 match self {
362 Self::Counter { labels, .. }
363 | Self::Gauge { labels, .. }
364 | Self::Histogram { labels, .. } => labels.len(),
365 }
366 }
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371pub struct ProductMetricHandle {
372 subsystem: &'static str,
373 name: &'static str,
374 kind: MetricKind,
375 label_count: usize,
376}
377
378impl ProductMetricHandle {
379 const fn new(descriptor: &MetricDescriptor) -> Self {
381 Self {
382 subsystem: descriptor.subsystem,
383 name: descriptor.name,
384 kind: descriptor.kind,
385 label_count: descriptor.labels.len(),
386 }
387 }
388
389 pub const fn subsystem(&self) -> &'static str {
391 self.subsystem
392 }
393
394 pub const fn name(&self) -> &'static str {
396 self.name
397 }
398
399 pub const fn kind(&self) -> MetricKind {
401 self.kind
402 }
403
404 pub const fn label_count(&self) -> usize {
406 self.label_count
407 }
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
412pub struct ProductCounter {
413 handle: ProductMetricHandle,
414}
415
416impl ProductCounter {
417 pub fn from_handle(handle: ProductMetricHandle) -> ProductMetricResult<Self> {
419 typed_product_metric_handle(handle, MetricKind::Counter).map(|handle| Self { handle })
420 }
421
422 pub const fn handle(&self) -> ProductMetricHandle {
424 self.handle
425 }
426
427 pub fn inc(&self, label_values: &[&str], value: u64) {
429 if let Err(error) = self.try_inc(label_values, value) {
430 log_product_metric_error(error);
431 }
432 }
433
434 pub fn try_inc(&self, label_values: &[&str], value: u64) -> ProductMetricResult<()> {
436 inc_product_counter(self.handle, label_values, value)
437 }
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub struct ProductGauge {
443 handle: ProductMetricHandle,
444}
445
446impl ProductGauge {
447 pub fn from_handle(handle: ProductMetricHandle) -> ProductMetricResult<Self> {
449 typed_product_metric_handle(handle, MetricKind::Gauge).map(|handle| Self { handle })
450 }
451
452 pub const fn handle(&self) -> ProductMetricHandle {
454 self.handle
455 }
456
457 pub fn set(&self, label_values: &[&str], value: f64) {
459 if let Err(error) = self.try_set(label_values, value) {
460 log_product_metric_error(error);
461 }
462 }
463
464 pub fn try_set(&self, label_values: &[&str], value: f64) -> ProductMetricResult<()> {
466 set_product_gauge(self.handle, label_values, value)
467 }
468
469 pub fn add(&self, label_values: &[&str], value: f64) {
471 if let Err(error) = self.try_add(label_values, value) {
472 log_product_metric_error(error);
473 }
474 }
475
476 pub fn try_add(&self, label_values: &[&str], value: f64) -> ProductMetricResult<()> {
478 add_product_gauge(self.handle, label_values, value)
479 }
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
484pub struct ProductHistogram {
485 handle: ProductMetricHandle,
486}
487
488impl ProductHistogram {
489 pub fn from_handle(handle: ProductMetricHandle) -> ProductMetricResult<Self> {
491 typed_product_metric_handle(handle, MetricKind::Histogram).map(|handle| Self { handle })
492 }
493
494 pub const fn handle(&self) -> ProductMetricHandle {
496 self.handle
497 }
498
499 pub fn observe(&self, label_values: &[&str], value: f64) {
501 if let Err(error) = self.try_observe(label_values, value) {
502 log_product_metric_error(error);
503 }
504 }
505
506 pub fn try_observe(&self, label_values: &[&str], value: f64) -> ProductMetricResult<()> {
508 observe_product_histogram(self.handle, label_values, value)
509 }
510}
511
512fn log_product_metric_error(error: ProductMetricError) {
513 tracing::warn!(error = %error, "failed to record product metric");
514}
515
516fn typed_product_metric_handle(
517 handle: ProductMetricHandle,
518 expected_kind: MetricKind,
519) -> ProductMetricResult<ProductMetricHandle> {
520 if handle.kind != expected_kind {
521 return Err(ProductMetricError::WrongKind {
522 subsystem: handle.subsystem,
523 name: handle.name,
524 actual: handle.kind,
525 expected: expected_kind,
526 });
527 }
528
529 Ok(handle)
530}
531
532#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
534pub enum ProductMetricError {
535 #[error("metrics registry is not initialized")]
537 NotInitialized,
538 #[error("duplicate product metric registration: {subsystem}.{name}")]
540 DuplicateRegistration {
541 subsystem: &'static str,
543 name: &'static str,
545 },
546 #[error("unknown product metric: {subsystem}.{name}")]
548 UnknownMetric {
549 subsystem: &'static str,
551 name: &'static str,
553 },
554 #[error("product metric {subsystem}.{name} has kind {actual:?}, expected {expected:?}")]
556 WrongKind {
557 subsystem: &'static str,
559 name: &'static str,
561 actual: MetricKind,
563 expected: MetricKind,
565 },
566 #[error(
568 "product metric {subsystem}.{name} expected {expected} label values, received {actual}"
569 )]
570 LabelCountMismatch {
571 subsystem: &'static str,
573 name: &'static str,
575 expected: usize,
577 actual: usize,
579 },
580 #[error("prometheus metric error: {0}")]
582 Prometheus(String),
583 #[error("product metric registry lock is poisoned")]
585 LockPoisoned,
586}
587
588pub type ProductMetricResult<T> = std::result::Result<T, ProductMetricError>;
590
591pub fn init_metrics() -> Result<(), prometheus::Error> {
593 if METRICS.get().is_some() {
594 return Ok(());
595 }
596
597 let _ = PROCESS_STARTED_AT.get_or_init(Instant::now);
598 let metrics = PrometheusMetrics::new()?;
599 let _ = METRICS.set(metrics);
600 Ok(())
601}
602
603pub fn init_or_noop() -> SharedMetricsRecorder {
605 crate::init_metrics_or_noop(init_metrics, || PrometheusMetricsRecorder)
606}
607
608pub fn is_initialized() -> bool {
610 METRICS.get().is_some()
611}
612
613pub fn export_metrics() -> Result<String, String> {
615 let metrics = METRICS
616 .get()
617 .ok_or_else(|| "metrics registry is not initialized".to_string())?;
618 metrics.export()
619}
620
621pub fn register_product_metric(
627 descriptor: MetricDescriptor,
628) -> ProductMetricResult<ProductMetricHandle> {
629 let metrics = METRICS.get().ok_or(ProductMetricError::NotInitialized)?;
630 let key = ProductMetricKey {
631 subsystem: descriptor.subsystem,
632 name: descriptor.name,
633 };
634 let handle = ProductMetricHandle::new(&descriptor);
635 let collector = build_product_metric_collector(&descriptor)?;
636 let mut product_metrics = metrics
637 .product_metrics
638 .lock()
639 .map_err(|_| ProductMetricError::LockPoisoned)?;
640 if product_metrics.collectors.contains_key(&key) {
641 return Err(ProductMetricError::DuplicateRegistration {
642 subsystem: descriptor.subsystem,
643 name: descriptor.name,
644 });
645 }
646 metrics
647 .registry
648 .register(product_collector_box(&collector))
649 .map_err(|error| ProductMetricError::Prometheus(error.to_string()))?;
650 product_metrics.collectors.insert(key, collector);
651 Ok(handle)
652}
653
654pub fn register_product_metrics<I>(descriptors: I) -> ProductMetricResult<Vec<ProductMetricHandle>>
660where
661 I: IntoIterator<Item = MetricDescriptor>,
662{
663 let mut handles = Vec::new();
664 for descriptor in descriptors {
665 match register_product_metric(descriptor) {
666 Ok(handle) => handles.push(handle),
667 Err(error) => {
668 for handle in &handles {
669 if let Err(rollback_error) =
670 unregister_product_metric(handle.subsystem(), handle.name())
671 {
672 tracing::warn!(
673 error = %rollback_error,
674 subsystem = handle.subsystem(),
675 name = handle.name(),
676 "product metric rollback after batch registration failure failed"
677 );
678 }
679 }
680 return Err(error);
681 }
682 }
683 }
684 Ok(handles)
685}
686
687fn unregister_product_metric(
692 subsystem: &'static str,
693 name: &'static str,
694) -> ProductMetricResult<()> {
695 let metrics = METRICS.get().ok_or(ProductMetricError::NotInitialized)?;
696 let key = ProductMetricKey { subsystem, name };
697 let mut product_metrics = metrics
698 .product_metrics
699 .lock()
700 .map_err(|_| ProductMetricError::LockPoisoned)?;
701 let Some(collector) = product_metrics.collectors.remove(&key) else {
702 return Ok(());
703 };
704 metrics
705 .registry
706 .unregister(product_collector_box(&collector))
707 .map_err(|error| ProductMetricError::Prometheus(error.to_string()))
708}
709
710pub fn register_product_counter(
712 descriptor: MetricDescriptor,
713) -> ProductMetricResult<ProductCounter> {
714 ensure_descriptor_kind(&descriptor, MetricKind::Counter)?;
715 ProductCounter::from_handle(register_product_metric(descriptor)?)
716}
717
718pub fn register_product_gauge(descriptor: MetricDescriptor) -> ProductMetricResult<ProductGauge> {
720 ensure_descriptor_kind(&descriptor, MetricKind::Gauge)?;
721 ProductGauge::from_handle(register_product_metric(descriptor)?)
722}
723
724pub fn register_product_histogram(
726 descriptor: MetricDescriptor,
727) -> ProductMetricResult<ProductHistogram> {
728 ensure_descriptor_kind(&descriptor, MetricKind::Histogram)?;
729 ProductHistogram::from_handle(register_product_metric(descriptor)?)
730}
731
732fn ensure_descriptor_kind(
733 descriptor: &MetricDescriptor,
734 expected_kind: MetricKind,
735) -> ProductMetricResult<()> {
736 if descriptor.kind != expected_kind {
737 return Err(ProductMetricError::WrongKind {
738 subsystem: descriptor.subsystem,
739 name: descriptor.name,
740 actual: descriptor.kind,
741 expected: expected_kind,
742 });
743 }
744
745 Ok(())
746}
747
748fn build_product_metric_collector(
749 descriptor: &MetricDescriptor,
750) -> ProductMetricResult<ProductMetricCollector> {
751 match descriptor.kind {
752 MetricKind::Counter => {
753 let metric_name = product_metric_name(descriptor);
754 let collector =
755 IntCounterVec::new(Opts::new(metric_name, descriptor.help), descriptor.labels)
756 .map_err(|error| ProductMetricError::Prometheus(error.to_string()))?;
757 Ok(ProductMetricCollector::Counter {
758 labels: descriptor.labels,
759 collector,
760 })
761 }
762 MetricKind::Gauge => {
763 let metric_name = product_metric_name(descriptor);
764 let collector =
765 GaugeVec::new(Opts::new(metric_name, descriptor.help), descriptor.labels)
766 .map_err(|error| ProductMetricError::Prometheus(error.to_string()))?;
767 Ok(ProductMetricCollector::Gauge {
768 labels: descriptor.labels,
769 collector,
770 })
771 }
772 MetricKind::Histogram => {
773 let metric_name = product_metric_name(descriptor);
774 let mut opts = HistogramOpts::new(metric_name, descriptor.help);
775 if !descriptor.buckets.is_empty() {
776 opts = opts.buckets(descriptor.buckets.to_vec());
777 }
778 let collector = HistogramVec::new(opts, descriptor.labels)
779 .map_err(|error| ProductMetricError::Prometheus(error.to_string()))?;
780 Ok(ProductMetricCollector::Histogram {
781 labels: descriptor.labels,
782 collector,
783 })
784 }
785 }
786}
787
788fn product_metric_name(descriptor: &MetricDescriptor) -> String {
789 format!("{}_{}", descriptor.subsystem, descriptor.name)
790}
791
792fn product_collector_box(
793 collector: &ProductMetricCollector,
794) -> Box<dyn prometheus::core::Collector> {
795 match collector {
796 ProductMetricCollector::Counter { collector, .. } => Box::new(collector.clone()),
797 ProductMetricCollector::Gauge { collector, .. } => Box::new(collector.clone()),
798 ProductMetricCollector::Histogram { collector, .. } => Box::new(collector.clone()),
799 }
800}
801
802pub fn inc_product_counter(
804 handle: ProductMetricHandle,
805 label_values: &[&str],
806 value: u64,
807) -> ProductMetricResult<()> {
808 with_product_metric(handle, MetricKind::Counter, label_values, |collector| {
809 let ProductMetricCollector::Counter { collector, .. } = collector else {
810 return;
811 };
812 collector.with_label_values(label_values).inc_by(value);
813 })
814}
815
816pub fn set_product_gauge(
818 handle: ProductMetricHandle,
819 label_values: &[&str],
820 value: f64,
821) -> ProductMetricResult<()> {
822 with_product_metric(handle, MetricKind::Gauge, label_values, |collector| {
823 let ProductMetricCollector::Gauge { collector, .. } = collector else {
824 return;
825 };
826 collector.with_label_values(label_values).set(value);
827 })
828}
829
830pub fn add_product_gauge(
832 handle: ProductMetricHandle,
833 label_values: &[&str],
834 value: f64,
835) -> ProductMetricResult<()> {
836 with_product_metric(handle, MetricKind::Gauge, label_values, |collector| {
837 let ProductMetricCollector::Gauge { collector, .. } = collector else {
838 return;
839 };
840 collector.with_label_values(label_values).add(value);
841 })
842}
843
844pub fn observe_product_histogram(
846 handle: ProductMetricHandle,
847 label_values: &[&str],
848 value: f64,
849) -> ProductMetricResult<()> {
850 with_product_metric(handle, MetricKind::Histogram, label_values, |collector| {
851 let ProductMetricCollector::Histogram { collector, .. } = collector else {
852 return;
853 };
854 collector.with_label_values(label_values).observe(value);
855 })
856}
857
858fn with_product_metric<F>(
859 handle: ProductMetricHandle,
860 expected_kind: MetricKind,
861 label_values: &[&str],
862 record: F,
863) -> ProductMetricResult<()>
864where
865 F: FnOnce(&ProductMetricCollector),
866{
867 let metrics = METRICS.get().ok_or(ProductMetricError::NotInitialized)?;
868 let product_metrics = metrics
869 .product_metrics
870 .lock()
871 .map_err(|_| ProductMetricError::LockPoisoned)?;
872 let key = ProductMetricKey {
873 subsystem: handle.subsystem,
874 name: handle.name,
875 };
876 let collector =
877 product_metrics
878 .collectors
879 .get(&key)
880 .ok_or(ProductMetricError::UnknownMetric {
881 subsystem: handle.subsystem,
882 name: handle.name,
883 })?;
884 if collector.kind() != expected_kind || handle.kind != expected_kind {
885 return Err(ProductMetricError::WrongKind {
886 subsystem: handle.subsystem,
887 name: handle.name,
888 actual: collector.kind(),
889 expected: expected_kind,
890 });
891 }
892 let expected_labels = collector.label_count();
893 if expected_labels != label_values.len() || handle.label_count != label_values.len() {
894 return Err(ProductMetricError::LabelCountMismatch {
895 subsystem: handle.subsystem,
896 name: handle.name,
897 expected: expected_labels,
898 actual: label_values.len(),
899 });
900 }
901
902 record(collector);
903 Ok(())
904}
905
906#[macro_export]
934macro_rules! product_metrics {
935 (
936 $(#[$struct_meta:meta])*
937 $vis:vis struct $name:ident {
938 $(
939 $(#[$field_meta:meta])*
940 $field:ident : $kind:ident (
941 $subsystem:expr,
942 $metric_name:expr,
943 $help:expr,
944 $labels:expr
945 $(, $buckets:expr)?
946 $(,)?
947 )
948 ),* $(,)?
949 }
950 ) => {
951 $(#[$struct_meta])*
952 $vis struct $name {
953 $(
954 $(#[$field_meta])*
955 pub $field: $crate::product_metrics!(@field_type $kind),
956 )*
957 }
958
959 impl $name {
960 pub fn register() -> $crate::prometheus::ProductMetricResult<Self> {
962 Ok(Self {
963 $(
964 $field: $crate::product_metrics!(
965 @register
966 $kind,
967 $subsystem,
968 $metric_name,
969 $help,
970 $labels
971 $(, $buckets)?
972 )?,
973 )*
974 })
975 }
976 }
977 };
978 (@field_type counter) => {
979 $crate::prometheus::ProductCounter
980 };
981 (@field_type gauge) => {
982 $crate::prometheus::ProductGauge
983 };
984 (@field_type histogram) => {
985 $crate::prometheus::ProductHistogram
986 };
987 (@field_type histogram_with_buckets) => {
988 $crate::prometheus::ProductHistogram
989 };
990 (@register counter, $subsystem:expr, $metric_name:expr, $help:expr, $labels:expr) => {
991 $crate::prometheus::register_product_counter($crate::MetricDescriptor::counter(
992 $subsystem,
993 $metric_name,
994 $help,
995 $labels,
996 ))
997 };
998 (@register gauge, $subsystem:expr, $metric_name:expr, $help:expr, $labels:expr) => {
999 $crate::prometheus::register_product_gauge($crate::MetricDescriptor::gauge(
1000 $subsystem,
1001 $metric_name,
1002 $help,
1003 $labels,
1004 ))
1005 };
1006 (@register histogram, $subsystem:expr, $metric_name:expr, $help:expr, $labels:expr) => {
1007 $crate::prometheus::register_product_histogram($crate::MetricDescriptor::histogram(
1008 $subsystem,
1009 $metric_name,
1010 $help,
1011 $labels,
1012 ))
1013 };
1014 (@register histogram_with_buckets, $subsystem:expr, $metric_name:expr, $help:expr, $labels:expr, $buckets:expr) => {
1015 $crate::prometheus::register_product_histogram(
1016 $crate::MetricDescriptor::histogram_with_buckets(
1017 $subsystem,
1018 $metric_name,
1019 $help,
1020 $labels,
1021 $buckets,
1022 ),
1023 )
1024 };
1025}
1026
1027#[derive(Debug, Clone, Copy, Default)]
1029pub struct PrometheusMetricsRecorder;
1030
1031impl DbMetricsRecorder for PrometheusMetricsRecorder {
1032 fn enabled(&self) -> bool {
1033 is_initialized()
1037 }
1038
1039 fn record_db_query(&self, metric: &DbQueryMetric) {
1040 record_db_query(metric);
1041 }
1042}
1043
1044impl MetricsRecorder for PrometheusMetricsRecorder {
1045 fn record_http_request(&self, method: &str, route: &str, status: u16, duration_seconds: f64) {
1046 record_http_request(method, route, status, duration_seconds);
1047 }
1048
1049 fn record_auth_event(&self, action: &'static str, status: &'static str, reason: &'static str) {
1050 record_auth_event(action, status, reason);
1051 }
1052
1053 fn record_application_event(
1054 &self,
1055 category: &'static str,
1056 event: &'static str,
1057 status: &'static str,
1058 ) {
1059 record_application_event(category, event, status);
1060 }
1061
1062 fn record_config_reload(
1063 &self,
1064 source: &'static str,
1065 decision: &'static str,
1066 status: &'static str,
1067 changed_keys: u64,
1068 duration_seconds: f64,
1069 ) {
1070 record_config_reload(source, decision, status, changed_keys, duration_seconds);
1071 }
1072
1073 fn record_config_mutation(
1074 &self,
1075 source: &'static str,
1076 operation: &'static str,
1077 status: &'static str,
1078 changed_keys: u64,
1079 ) {
1080 record_config_mutation(source, operation, status, changed_keys);
1081 }
1082
1083 fn record_background_task_transition(&self, kind: &'static str, status: &'static str) {
1084 record_background_task_transition(kind, status);
1085 }
1086
1087 fn set_background_tasks_pending(&self, pending: u64) {
1088 set_background_tasks_pending(pending);
1089 }
1090
1091 fn record_external_operation(
1092 &self,
1093 system: &'static str,
1094 operation: &'static str,
1095 status: &'static str,
1096 duration_seconds: f64,
1097 ) {
1098 record_external_operation(system, operation, status, duration_seconds);
1099 }
1100
1101 fn system_metrics_updater_task(
1102 &self,
1103 shutdown_token: CancellationToken,
1104 ) -> Option<Pin<Box<dyn Future<Output = ()> + Send + 'static>>> {
1105 Some(Box::pin(system_metrics_updater_task(shutdown_token)))
1106 }
1107}
1108
1109#[cfg(feature = "runtime-health")]
1110impl aster_forge_runtime::HealthMetricsRecorder for PrometheusMetricsRecorder {
1111 fn record_health_report(
1112 &self,
1113 scope: &'static str,
1114 status: aster_forge_runtime::HealthStatus,
1115 duration_seconds: f64,
1116 ) {
1117 record_health_report(
1118 scope,
1119 status.as_str(),
1120 health_status_value(status.as_str()),
1121 duration_seconds,
1122 );
1123 }
1124
1125 fn record_health_component(
1126 &self,
1127 scope: &'static str,
1128 component: &aster_forge_runtime::HealthComponentReport,
1129 duration_seconds: f64,
1130 ) {
1131 record_health_component(
1132 scope,
1133 component.name,
1134 component.status.as_str(),
1135 health_status_value(component.status.as_str()),
1136 duration_seconds,
1137 );
1138 }
1139}
1140
1141fn record_http_request(method: &str, route: &str, status: u16, duration_seconds: f64) {
1142 let Some(metrics) = METRICS.get() else {
1143 return;
1144 };
1145
1146 let status = status.to_string();
1147 metrics
1148 .http_requests_total
1149 .with_label_values(&[method, route, &status])
1150 .inc();
1151 metrics
1152 .http_request_duration_seconds
1153 .with_label_values(&[method, route, &status])
1154 .observe(duration_seconds);
1155}
1156
1157fn record_db_query(metric: &DbQueryMetric) {
1158 let Some(metrics) = METRICS.get() else {
1159 return;
1160 };
1161
1162 let backend = metric.backend.as_label();
1163 let kind = metric.kind.as_label();
1164 let status = metric.status_label();
1165
1166 metrics
1167 .db_queries_total
1168 .with_label_values(&[backend, kind, status])
1169 .inc();
1170 metrics
1171 .db_query_duration_seconds
1172 .with_label_values(&[backend, kind, status])
1173 .observe(metric.elapsed.as_secs_f64());
1174}
1175
1176fn record_auth_event(action: &'static str, status: &'static str, reason: &'static str) {
1177 let Some(metrics) = METRICS.get() else {
1178 return;
1179 };
1180
1181 metrics
1182 .auth_events_total
1183 .with_label_values(&[action, status, reason])
1184 .inc();
1185}
1186
1187fn record_application_event(category: &'static str, event: &'static str, status: &'static str) {
1188 let Some(metrics) = METRICS.get() else {
1189 return;
1190 };
1191
1192 metrics
1193 .application_events_total
1194 .with_label_values(&[category, event, status])
1195 .inc();
1196}
1197
1198fn record_config_reload(
1199 source: &'static str,
1200 decision: &'static str,
1201 status: &'static str,
1202 changed_keys: u64,
1203 duration_seconds: f64,
1204) {
1205 let Some(metrics) = METRICS.get() else {
1206 return;
1207 };
1208
1209 metrics
1210 .config_reloads_total
1211 .with_label_values(&[source, decision, status])
1212 .inc();
1213 metrics
1214 .config_reload_duration_seconds
1215 .with_label_values(&[source, decision, status])
1216 .observe(duration_seconds);
1217 metrics
1218 .config_reload_changed_keys
1219 .with_label_values(&[source, decision, status])
1220 .observe(changed_keys as f64);
1221}
1222
1223fn record_config_mutation(
1224 source: &'static str,
1225 operation: &'static str,
1226 status: &'static str,
1227 changed_keys: u64,
1228) {
1229 let Some(metrics) = METRICS.get() else {
1230 return;
1231 };
1232
1233 metrics
1234 .config_mutations_total
1235 .with_label_values(&[source, operation, status])
1236 .inc();
1237 metrics
1238 .config_mutation_changed_keys
1239 .with_label_values(&[source, operation, status])
1240 .observe(changed_keys as f64);
1241}
1242
1243fn record_background_task_transition(kind: &'static str, status: &'static str) {
1244 let Some(metrics) = METRICS.get() else {
1245 return;
1246 };
1247
1248 metrics
1249 .background_tasks_total
1250 .with_label_values(&[kind, status])
1251 .inc();
1252 if status == "retry" {
1253 metrics
1254 .background_task_retries_total
1255 .with_label_values(&[kind])
1256 .inc();
1257 }
1258}
1259
1260fn set_background_tasks_pending(pending: u64) {
1261 let Some(metrics) = METRICS.get() else {
1262 return;
1263 };
1264
1265 metrics
1266 .background_tasks_pending
1267 .set(i64::try_from(pending).unwrap_or(i64::MAX));
1268}
1269
1270fn record_external_operation(
1271 system: &'static str,
1272 operation: &'static str,
1273 status: &'static str,
1274 duration_seconds: f64,
1275) {
1276 let Some(metrics) = METRICS.get() else {
1277 return;
1278 };
1279
1280 metrics
1281 .external_operations_total
1282 .with_label_values(&[system, operation, status])
1283 .inc();
1284 metrics
1285 .external_operation_duration_seconds
1286 .with_label_values(&[system, operation, status])
1287 .observe(duration_seconds);
1288}
1289
1290pub fn record_health_report(
1292 scope: &'static str,
1293 status_label: &'static str,
1294 status_value: f64,
1295 duration_seconds: f64,
1296) {
1297 let Some(metrics) = METRICS.get() else {
1298 return;
1299 };
1300
1301 metrics
1302 .health_report_status
1303 .with_label_values(&[scope])
1304 .set(status_value);
1305 metrics
1306 .health_report_duration_seconds
1307 .with_label_values(&[scope, status_label])
1308 .observe(duration_seconds);
1309}
1310
1311pub fn record_health_component(
1313 scope: &'static str,
1314 component: &'static str,
1315 status_label: &'static str,
1316 status_value: f64,
1317 duration_seconds: f64,
1318) {
1319 let Some(metrics) = METRICS.get() else {
1320 return;
1321 };
1322
1323 metrics
1324 .health_component_status
1325 .with_label_values(&[scope, component])
1326 .set(status_value);
1327 metrics
1328 .health_component_duration_seconds
1329 .with_label_values(&[scope, component, status_label])
1330 .observe(duration_seconds);
1331}
1332
1333#[cfg(feature = "runtime-health")]
1334fn health_status_value(status: &'static str) -> f64 {
1335 match status {
1336 "healthy" => 0.0,
1337 "degraded" => 1.0,
1338 "unhealthy" => 2.0,
1339 _ => 2.0,
1340 }
1341}
1342
1343async fn system_metrics_updater_task(shutdown_token: CancellationToken) {
1344 use std::sync::Mutex;
1345 use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
1346
1347 static SYSTEM: OnceLock<Mutex<System>> = OnceLock::new();
1348
1349 let mut interval = tokio::time::interval(std::time::Duration::from_secs(15));
1350 loop {
1351 tokio::select! {
1352 biased;
1353 _ = shutdown_token.cancelled() => break,
1354 _ = interval.tick() => {}
1355 }
1356
1357 if shutdown_token.is_cancelled() {
1358 break;
1359 }
1360
1361 let Some(metrics) = METRICS.get() else {
1362 continue;
1363 };
1364
1365 let update = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1366 let pid = Pid::from_u32(std::process::id());
1367 let sys_mutex = SYSTEM.get_or_init(|| Mutex::new(System::new()));
1368 let Ok(mut sys) = sys_mutex.lock() else {
1369 tracing::warn!("system metrics updater lock is poisoned");
1370 return;
1371 };
1372 sys.refresh_processes_specifics(
1373 ProcessesToUpdate::Some(&[pid]),
1374 true,
1375 ProcessRefreshKind::nothing().with_memory().with_cpu(),
1376 );
1377 if let Some(process) = sys.process(pid) {
1378 metrics
1379 .process_memory_rss_bytes
1380 .set(process.memory() as f64);
1381 let cpu_millis = i64::try_from(process.accumulated_cpu_time()).unwrap_or(i64::MAX);
1382 metrics.process_cpu_milliseconds_total.set(cpu_millis);
1383 }
1384 let uptime = PROCESS_STARTED_AT
1385 .get()
1386 .map(Instant::elapsed)
1387 .unwrap_or_default()
1388 .as_secs_f64();
1389 metrics.uptime_seconds.set(uptime);
1390 metrics.refresh_allocator_metrics();
1391 }));
1392
1393 if let Err(panic) = update {
1394 tracing::error!(panic = %panic_message(panic), "system metrics updater panicked");
1395 }
1396 }
1397}
1398
1399fn panic_message(panic: Box<dyn std::any::Any + Send>) -> String {
1400 if let Some(message) = panic.downcast_ref::<&str>() {
1401 (*message).to_string()
1402 } else if let Some(message) = panic.downcast_ref::<String>() {
1403 message.clone()
1404 } else {
1405 "unknown panic payload".to_string()
1406 }
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411 use super::{
1412 ProductCounter, ProductGauge, ProductHistogram, ProductMetricError, ProductMetricHandle,
1413 PrometheusMetricsRecorder, export_metrics, inc_product_counter, init_metrics,
1414 is_initialized, observe_product_histogram, record_health_component, record_health_report,
1415 register_product_counter, register_product_gauge, register_product_histogram,
1416 register_product_metric, register_product_metrics, set_product_gauge,
1417 };
1418 use crate::{DbMetricsRecorder, MetricDescriptor, MetricKind, MetricsRecorder};
1419
1420 #[test]
1421 fn prometheus_recorder_exports_low_cardinality_metrics() {
1422 init_metrics().expect("metrics registry should initialize");
1423 let recorder = PrometheusMetricsRecorder;
1424
1425 recorder.record_http_request("GET", "/health", 200, 0.01);
1426 recorder.record_config_reload("pubsub", "reloaded", "ok", 3, 0.02);
1427 recorder.record_config_mutation("api", "upsert", "error", 1);
1428 record_health_report("diagnostics", "degraded", 1.0, 0.25);
1429 record_health_component("diagnostics", "cache", "degraded", 1.0, 0.05);
1430
1431 let body = export_metrics().expect("metrics should export");
1432 assert!(recorder.enabled());
1433 assert!(body.contains("http_requests_total"));
1434 assert!(body.contains("config_reloads_total"));
1435 assert!(body.contains("config_reload_duration_seconds_count"));
1436 assert!(body.contains("config_mutations_total"));
1437 assert!(body.contains("health_report_status"));
1438 assert!(body.contains("health_component_status"));
1439 assert!(body.contains("source=\"pubsub\""));
1440 assert!(body.contains("decision=\"reloaded\""));
1441 assert!(body.contains("operation=\"upsert\""));
1442 }
1443
1444 #[cfg(feature = "allocator-metrics")]
1445 #[test]
1446 fn allocator_metrics_export_heap_memory_kinds() {
1447 init_metrics().expect("metrics registry should initialize");
1448
1449 let body = export_metrics().expect("metrics should export");
1450
1451 assert!(body.contains("process_heap_memory_mib"));
1452 assert!(body.contains("kind=\"allocated\""));
1453 assert!(body.contains("kind=\"peak_or_resident\""));
1454 }
1455
1456 #[test]
1457 fn recorder_enabled_tracks_registry_initialization() {
1458 init_metrics().expect("metrics registry should initialize");
1459
1460 assert_eq!(PrometheusMetricsRecorder.enabled(), is_initialized());
1467 }
1468
1469 #[test]
1470 fn register_product_metrics_rolls_back_partial_registrations_on_failure() {
1471 init_metrics().expect("metrics registry should initialize");
1472 let descriptor = || {
1473 MetricDescriptor::counter(
1474 "batch_rollback_test",
1475 "events_total",
1476 "Batch rollback test events.",
1477 &[],
1478 )
1479 };
1480
1481 let error = register_product_metrics(vec![descriptor(), descriptor()])
1484 .expect_err("duplicate registration should fail the batch");
1485 assert!(matches!(
1486 error,
1487 ProductMetricError::DuplicateRegistration { .. }
1488 ));
1489
1490 register_product_metrics(vec![descriptor()])
1493 .expect("retry after rollback should register cleanly");
1494 }
1495
1496 #[test]
1497 fn product_metric_operations_fail_before_registration() {
1498 init_metrics().expect("metrics registry should initialize");
1499 let handle = ProductMetricHandle {
1500 subsystem: "missing_product_metric_test",
1501 name: "events_total",
1502 kind: MetricKind::Counter,
1503 label_count: 0,
1504 };
1505
1506 let error = inc_product_counter(handle, &[], 1)
1507 .expect_err("unknown metric handle should be rejected");
1508
1509 assert_eq!(
1510 error,
1511 ProductMetricError::UnknownMetric {
1512 subsystem: "missing_product_metric_test",
1513 name: "events_total"
1514 }
1515 );
1516 }
1517
1518 #[test]
1519 fn typed_product_metric_handles_record_and_validate_kind() {
1520 init_metrics().expect("metrics registry should initialize");
1521
1522 let counter = register_product_counter(MetricDescriptor::counter(
1523 "typed_product_metric_test",
1524 "events_total",
1525 "Typed product metric events.",
1526 &["status"],
1527 ))
1528 .expect("counter should register");
1529 let gauge = register_product_gauge(MetricDescriptor::gauge(
1530 "typed_product_metric_test",
1531 "queue_depth",
1532 "Typed product metric queue depth.",
1533 &["queue"],
1534 ))
1535 .expect("gauge should register");
1536 let histogram = register_product_histogram(MetricDescriptor::histogram_with_buckets(
1537 "typed_product_metric_test",
1538 "duration_seconds",
1539 "Typed product metric duration.",
1540 &["kind"],
1541 &[0.1, 1.0],
1542 ))
1543 .expect("histogram should register");
1544
1545 counter.try_inc(&["ok"], 2).expect("counter should record");
1546 counter.inc(&["ok"], 1);
1547 gauge.try_set(&["mail"], 4.0).expect("gauge should set");
1548 gauge.add(&["mail"], 1.0);
1549 histogram
1550 .try_observe(&["dispatch"], 0.2)
1551 .expect("histogram should observe");
1552 histogram.observe(&["dispatch"], 0.3);
1553
1554 let wrong_kind = ProductGauge::from_handle(counter.handle())
1555 .expect_err("counter handle should not become a gauge");
1556 assert_eq!(
1557 wrong_kind,
1558 ProductMetricError::WrongKind {
1559 subsystem: "typed_product_metric_test",
1560 name: "events_total",
1561 actual: MetricKind::Counter,
1562 expected: MetricKind::Gauge
1563 }
1564 );
1565 assert!(ProductCounter::from_handle(counter.handle()).is_ok());
1566 assert!(ProductHistogram::from_handle(histogram.handle()).is_ok());
1567
1568 let body = export_metrics().expect("metrics should export");
1569 assert!(body.contains("typed_product_metric_test_events_total"));
1570 assert!(body.contains("typed_product_metric_test_queue_depth"));
1571 assert!(body.contains("typed_product_metric_test_duration_seconds_bucket"));
1572 }
1573
1574 #[test]
1575 fn product_metrics_macro_registers_typed_metric_set() {
1576 init_metrics().expect("metrics registry should initialize");
1577
1578 crate::product_metrics! {
1579 #[derive(Clone, Copy)]
1580 pub struct MacroProductMetrics {
1581 requests: counter(
1583 "macro_product_metric_test",
1584 "requests_total",
1585 "Macro product metric requests.",
1586 &["status"],
1587 ),
1588 queue_depth: gauge(
1589 "macro_product_metric_test",
1590 "queue_depth",
1591 "Macro product metric queue depth.",
1592 &["queue"],
1593 ),
1594 latency: histogram_with_buckets(
1595 "macro_product_metric_test",
1596 "latency_seconds",
1597 "Macro product metric latency.",
1598 &["route"],
1599 &[0.01, 0.1],
1600 ),
1601 }
1602 }
1603
1604 let metrics = MacroProductMetrics::register().expect("metric set should register");
1605 metrics.requests.inc(&["ok"], 1);
1606 metrics.queue_depth.set(&["mail"], 3.0);
1607 metrics.latency.observe(&["/healthz"], 0.02);
1608
1609 let body = export_metrics().expect("metrics should export");
1610 assert!(body.contains("macro_product_metric_test_requests_total"));
1611 assert!(body.contains("macro_product_metric_test_queue_depth"));
1612 assert!(body.contains("macro_product_metric_test_latency_seconds_bucket"));
1613 }
1614
1615 #[test]
1616 fn product_metrics_register_record_and_validate_boundaries() {
1617 init_metrics().expect("metrics registry should initialize");
1618
1619 let counter = register_product_metric(MetricDescriptor::counter(
1620 "product_registration_test",
1621 "events_total",
1622 "Product registration test events.",
1623 &["kind", "status"],
1624 ))
1625 .expect("counter should register");
1626 assert_eq!(counter.subsystem(), "product_registration_test");
1627 assert_eq!(counter.name(), "events_total");
1628 assert_eq!(counter.kind(), MetricKind::Counter);
1629 assert_eq!(counter.label_count(), 2);
1630
1631 let duplicate = register_product_metric(MetricDescriptor::counter(
1632 "product_registration_test",
1633 "events_total",
1634 "Duplicate product registration test events.",
1635 &["kind", "status"],
1636 ))
1637 .expect_err("duplicate should be rejected before touching prometheus registry");
1638 assert_eq!(
1639 duplicate,
1640 ProductMetricError::DuplicateRegistration {
1641 subsystem: "product_registration_test",
1642 name: "events_total"
1643 }
1644 );
1645
1646 let handles = register_product_metrics([
1647 MetricDescriptor::gauge(
1648 "product_registration_test",
1649 "queue_depth",
1650 "Product registration test queue depth.",
1651 &["queue"],
1652 ),
1653 MetricDescriptor::histogram_with_buckets(
1654 "product_registration_test",
1655 "job_duration_seconds",
1656 "Product registration test job duration.",
1657 &["kind"],
1658 &[0.1, 0.5, 1.0],
1659 ),
1660 ])
1661 .expect("gauge and histogram should register");
1662 assert_eq!(handles.len(), 2);
1663
1664 inc_product_counter(counter, &["dispatch", "ok"], 3)
1665 .expect("counter should record with matching labels");
1666 set_product_gauge(handles[0], &["mail"], 7.0).expect("gauge should record");
1667 observe_product_histogram(handles[1], &["dispatch"], 0.25)
1668 .expect("histogram should record");
1669
1670 let wrong_label_count = inc_product_counter(counter, &["dispatch"], 1)
1671 .expect_err("wrong label count should be rejected");
1672 assert_eq!(
1673 wrong_label_count,
1674 ProductMetricError::LabelCountMismatch {
1675 subsystem: "product_registration_test",
1676 name: "events_total",
1677 expected: 2,
1678 actual: 1
1679 }
1680 );
1681
1682 let wrong_kind = set_product_gauge(counter, &["dispatch", "ok"], 1.0)
1683 .expect_err("wrong recording kind should be rejected");
1684 assert_eq!(
1685 wrong_kind,
1686 ProductMetricError::WrongKind {
1687 subsystem: "product_registration_test",
1688 name: "events_total",
1689 actual: MetricKind::Counter,
1690 expected: MetricKind::Gauge
1691 }
1692 );
1693
1694 let body = export_metrics().expect("metrics should export");
1695 assert!(body.contains("product_registration_test_events_total"));
1696 assert!(body.contains("kind=\"dispatch\""));
1697 assert!(body.contains("status=\"ok\""));
1698 assert!(body.contains("product_registration_test_queue_depth"));
1699 assert!(body.contains("queue=\"mail\""));
1700 assert!(body.contains("product_registration_test_job_duration_seconds_bucket"));
1701 assert!(body.contains("le=\"0.5\""));
1702 }
1703}