Skip to main content

aster_forge_metrics/
prometheus.rs

1//! Prometheus backend for shared Aster infrastructure metrics.
2//!
3//! This module is enabled by the `prometheus` feature. Product crates can use
4//! [`init_or_noop`] to obtain a [`SharedMetricsRecorder`]
5//! and [`export_metrics`] for their HTTP metrics endpoint without depending on
6//! the `prometheus` crate directly. Product-specific metric families can be
7//! registered with Forge descriptors and recorded through opaque handles, so
8//! products keep ownership of domain labels without importing Prometheus types.
9
10use 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
35/// Prometheus metric families for product-neutral Aster infrastructure.
36pub 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/// Opaque handle returned after registering a product metric.
370#[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    /// Creates a handle for a metric descriptor.
380    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    /// Returns the subsystem that owns this metric.
390    pub const fn subsystem(&self) -> &'static str {
391        self.subsystem
392    }
393
394    /// Returns the descriptor-local metric name.
395    pub const fn name(&self) -> &'static str {
396        self.name
397    }
398
399    /// Returns the registered metric kind.
400    pub const fn kind(&self) -> MetricKind {
401        self.kind
402    }
403
404    /// Returns the number of label values required by this metric.
405    pub const fn label_count(&self) -> usize {
406        self.label_count
407    }
408}
409
410/// Typed handle for a registered product counter.
411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
412pub struct ProductCounter {
413    handle: ProductMetricHandle,
414}
415
416impl ProductCounter {
417    /// Creates a typed counter from an opaque product metric handle.
418    pub fn from_handle(handle: ProductMetricHandle) -> ProductMetricResult<Self> {
419        typed_product_metric_handle(handle, MetricKind::Counter).map(|handle| Self { handle })
420    }
421
422    /// Returns the underlying opaque handle.
423    pub const fn handle(&self) -> ProductMetricHandle {
424        self.handle
425    }
426
427    /// Increments this counter and logs recording failures.
428    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    /// Increments this counter and returns recording failures.
435    pub fn try_inc(&self, label_values: &[&str], value: u64) -> ProductMetricResult<()> {
436        inc_product_counter(self.handle, label_values, value)
437    }
438}
439
440/// Typed handle for a registered product gauge.
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub struct ProductGauge {
443    handle: ProductMetricHandle,
444}
445
446impl ProductGauge {
447    /// Creates a typed gauge from an opaque product metric handle.
448    pub fn from_handle(handle: ProductMetricHandle) -> ProductMetricResult<Self> {
449        typed_product_metric_handle(handle, MetricKind::Gauge).map(|handle| Self { handle })
450    }
451
452    /// Returns the underlying opaque handle.
453    pub const fn handle(&self) -> ProductMetricHandle {
454        self.handle
455    }
456
457    /// Sets this gauge and logs recording failures.
458    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    /// Sets this gauge and returns recording failures.
465    pub fn try_set(&self, label_values: &[&str], value: f64) -> ProductMetricResult<()> {
466        set_product_gauge(self.handle, label_values, value)
467    }
468
469    /// Adds to this gauge and logs recording failures.
470    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    /// Adds to this gauge and returns recording failures.
477    pub fn try_add(&self, label_values: &[&str], value: f64) -> ProductMetricResult<()> {
478        add_product_gauge(self.handle, label_values, value)
479    }
480}
481
482/// Typed handle for a registered product histogram.
483#[derive(Debug, Clone, Copy, PartialEq, Eq)]
484pub struct ProductHistogram {
485    handle: ProductMetricHandle,
486}
487
488impl ProductHistogram {
489    /// Creates a typed histogram from an opaque product metric handle.
490    pub fn from_handle(handle: ProductMetricHandle) -> ProductMetricResult<Self> {
491        typed_product_metric_handle(handle, MetricKind::Histogram).map(|handle| Self { handle })
492    }
493
494    /// Returns the underlying opaque handle.
495    pub const fn handle(&self) -> ProductMetricHandle {
496        self.handle
497    }
498
499    /// Observes a value in this histogram and logs recording failures.
500    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    /// Observes a value in this histogram and returns recording failures.
507    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/// Errors returned by product metric registration or recording.
533#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
534pub enum ProductMetricError {
535    /// The Prometheus registry has not been initialized.
536    #[error("metrics registry is not initialized")]
537    NotInitialized,
538    /// A product metric with the same subsystem/name was already registered.
539    #[error("duplicate product metric registration: {subsystem}.{name}")]
540    DuplicateRegistration {
541        /// Owning subsystem.
542        subsystem: &'static str,
543        /// Metric name.
544        name: &'static str,
545    },
546    /// The handle does not point to a registered metric.
547    #[error("unknown product metric: {subsystem}.{name}")]
548    UnknownMetric {
549        /// Owning subsystem.
550        subsystem: &'static str,
551        /// Metric name.
552        name: &'static str,
553    },
554    /// The recording operation does not match the registered metric kind.
555    #[error("product metric {subsystem}.{name} has kind {actual:?}, expected {expected:?}")]
556    WrongKind {
557        /// Owning subsystem.
558        subsystem: &'static str,
559        /// Metric name.
560        name: &'static str,
561        /// Registered kind.
562        actual: MetricKind,
563        /// Expected kind.
564        expected: MetricKind,
565    },
566    /// The number of label values did not match the descriptor.
567    #[error(
568        "product metric {subsystem}.{name} expected {expected} label values, received {actual}"
569    )]
570    LabelCountMismatch {
571        /// Owning subsystem.
572        subsystem: &'static str,
573        /// Metric name.
574        name: &'static str,
575        /// Expected label value count.
576        expected: usize,
577        /// Actual label value count.
578        actual: usize,
579    },
580    /// Prometheus rejected the metric collector.
581    #[error("prometheus metric error: {0}")]
582    Prometheus(String),
583    /// The product metric registry lock was poisoned.
584    #[error("product metric registry lock is poisoned")]
585    LockPoisoned,
586}
587
588/// Result type for product metric operations.
589pub type ProductMetricResult<T> = std::result::Result<T, ProductMetricError>;
590
591/// Initializes the shared Prometheus registry.
592pub 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
603/// Initializes Prometheus metrics and falls back to a no-op recorder on failure.
604pub fn init_or_noop() -> SharedMetricsRecorder {
605    crate::init_metrics_or_noop(init_metrics, || PrometheusMetricsRecorder)
606}
607
608/// Returns whether the Prometheus registry has been initialized.
609pub fn is_initialized() -> bool {
610    METRICS.get().is_some()
611}
612
613/// Exports the current Prometheus text exposition body.
614pub 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
621/// Registers a product-owned metric family in the shared Prometheus registry.
622///
623/// Product crates pass a Forge [`MetricDescriptor`] and receive an opaque handle
624/// for future record calls. This keeps product code independent from the
625/// `prometheus` crate while still allowing product-specific metric families.
626pub 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
654/// Registers multiple product-owned metric families in order.
655///
656/// The batch is all-or-nothing: when any descriptor fails, the families this
657/// batch already registered are rolled back so fixing the failure and
658/// retrying does not trip over `DuplicateRegistration`.
659pub 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
687/// Removes a product metric family from the shared registry.
688///
689/// Used to roll back partially-registered batches; a missing key is a no-op
690/// because there is nothing to roll back.
691fn 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
710/// Registers a product counter and returns a typed handle.
711pub 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
718/// Registers a product gauge and returns a typed handle.
719pub 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
724/// Registers a product histogram and returns a typed handle.
725pub 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
802/// Increments a registered product counter.
803pub 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
816/// Sets a registered product gauge.
817pub 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
830/// Adds to a registered product gauge.
831pub 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
844/// Observes a registered product histogram.
845pub 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/// Declares a typed product metric set backed by the shared Prometheus registry.
907///
908/// The generated struct owns typed metric handles and exposes a `register()` function that
909/// registers every declared metric in order.
910///
911/// ```
912/// # #[cfg(feature = "backend-prometheus")]
913/// # {
914/// aster_forge_metrics::product_metrics! {
915///     pub struct ProductMetrics {
916///         requests: counter(
917///             "example",
918///             "requests_total",
919///             "Total example requests.",
920///             &["status"],
921///         ),
922///         latency: histogram_with_buckets(
923///             "example",
924///             "request_duration_seconds",
925///             "Example request duration.",
926///             &["status"],
927///             &[0.1, 0.5, 1.0],
928///         ),
929///     }
930/// }
931/// # }
932/// ```
933#[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            /// Registers every metric in this set and returns typed handles.
961            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/// Prometheus recorder for shared infrastructure metrics.
1028#[derive(Debug, Clone, Copy, Default)]
1029pub struct PrometheusMetricsRecorder;
1030
1031impl DbMetricsRecorder for PrometheusMetricsRecorder {
1032    fn enabled(&self) -> bool {
1033        // The recorder is a public unit struct and can be constructed without
1034        // `init_metrics()`; in that state every `record_*` early-returns and
1035        // drops data, so it must not report itself as enabled.
1036        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
1290/// Records an aggregate health report into the shared Prometheus registry.
1291pub 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
1311/// Records one health component into the shared Prometheus registry.
1312pub 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        // The contract: `enabled()` is true exactly when the shared registry
1461        // is initialized. A bare `PrometheusMetricsRecorder` constructed
1462        // without `init_metrics()` must report false instead of silently
1463        // dropping every record while claiming to be enabled. (The false
1464        // branch cannot be exercised in this process because the registry is
1465        // a global OnceLock; it is pinned structurally by this assertion.)
1466        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        // The second descriptor duplicates the first, so the batch fails
1482        // after the first metric was already registered...
1483        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        // ...and the partial registration must have been rolled back, or this
1491        // corrected retry would trip over DuplicateRegistration again.
1492        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                /// Macro counter.
1582                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}