aster_forge_metrics/
lib.rs

1//! Shared metrics recorder traits and subsystem registration primitives.
2//!
3//! Applications often share the same infrastructure metrics while exposing different
4//! product-domain metrics. This crate keeps the common recorder surface small and provides a
5//! registration catalog so each subsystem can describe the metrics it owns without forcing every
6//! product-specific method into a single shared trait. Concrete backends are selected once at the
7//! product entrypoint through Forge feature flags, so business modules record metric semantics
8//! without depending on exporter crates.
9#![cfg_attr(
10    not(test),
11    deny(
12        clippy::unwrap_used,
13        clippy::unreachable,
14        clippy::expect_used,
15        clippy::panic,
16        clippy::unimplemented,
17        clippy::todo
18    )
19)]
20
21use std::future::Future;
22use std::pin::Pin;
23use std::sync::Arc;
24use std::time::Duration;
25
26use tokio_util::sync::CancellationToken;
27
28#[cfg(feature = "backend-prometheus")]
29pub mod prometheus;
30
31// One more than the number of enabled backend-* features. The +1 offset keeps the
32// compile-time guard below from degenerating into a comparison against usize's
33// minimum value when every backend is disabled, which would trip
34// clippy::absurd_extreme_comparisons only in that one feature configuration.
35const ENABLED_BACKEND_SLOTS: usize = 1 + cfg!(feature = "backend-prometheus") as usize;
36
37const _: () = assert!(
38    ENABLED_BACKEND_SLOTS <= 2,
39    "aster_forge_metrics allows only one backend-* feature at a time"
40);
41
42/// Normalized database backend label used by infrastructure metrics.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum DbMetricBackend {
45    /// `SQLite` backend.
46    Sqlite,
47    /// `MySQL` backend.
48    MySql,
49    /// `PostgreSQL` backend.
50    Postgres,
51    /// A backend not recognized by this shared metrics surface.
52    Other,
53}
54
55impl DbMetricBackend {
56    /// Returns the stable label used for metrics exporters.
57    #[must_use]
58    pub const fn as_label(self) -> &'static str {
59        match self {
60            Self::Sqlite => "sqlite",
61            Self::MySql => "mysql",
62            Self::Postgres => "postgres",
63            Self::Other => "other",
64        }
65    }
66}
67
68/// Normalized database query kind used by infrastructure metrics.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum DbQueryKind {
71    /// SELECT or read-like query.
72    Select,
73    /// INSERT query.
74    Insert,
75    /// UPDATE query.
76    Update,
77    /// DELETE query.
78    Delete,
79    /// Common table expression query.
80    With,
81    /// Transaction control statement.
82    Transaction,
83    /// Data definition statement.
84    Ddl,
85    /// `SQLite` PRAGMA statement.
86    Pragma,
87    /// Query kind that could not be classified cheaply.
88    Other,
89}
90
91impl DbQueryKind {
92    /// Returns the stable label used for metrics exporters.
93    #[must_use]
94    pub const fn as_label(self) -> &'static str {
95        match self {
96            Self::Select => "select",
97            Self::Insert => "insert",
98            Self::Update => "update",
99            Self::Delete => "delete",
100            Self::With => "with",
101            Self::Transaction => "transaction",
102            Self::Ddl => "ddl",
103            Self::Pragma => "pragma",
104            Self::Other => "other",
105        }
106    }
107}
108
109/// Product-neutral database query metric emitted by database adapters.
110///
111/// This shape intentionally avoids exposing raw SQL to metrics recorders. Products should keep DB
112/// metrics low-cardinality and free of query parameters.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct DbQueryMetric {
115    /// Database backend that executed the query.
116    pub backend: DbMetricBackend,
117    /// Low-cardinality query kind.
118    pub kind: DbQueryKind,
119    /// Whether the query failed.
120    pub failed: bool,
121    /// Query duration observed by the database adapter.
122    pub elapsed: Duration,
123}
124
125impl DbQueryMetric {
126    /// Creates a database query metric.
127    #[must_use]
128    pub const fn new(
129        backend: DbMetricBackend,
130        kind: DbQueryKind,
131        failed: bool,
132        elapsed: Duration,
133    ) -> Self {
134        Self {
135            backend,
136            kind,
137            failed,
138            elapsed,
139        }
140    }
141
142    /// Returns the stable status label.
143    #[must_use]
144    pub const fn status_label(&self) -> &'static str {
145        if self.failed { "error" } else { "ok" }
146    }
147}
148
149/// Minimal metrics hook used by database connection helpers.
150pub trait DbMetricsRecorder: Send + Sync {
151    /// Returns whether metrics are actively recorded.
152    fn enabled(&self) -> bool;
153
154    /// Records one database query metric.
155    fn record_db_query(&self, metric: &DbQueryMetric);
156}
157
158/// Metrics recorder that ignores every database query.
159#[derive(Debug, Default)]
160pub struct NoopDbMetrics;
161
162impl DbMetricsRecorder for NoopDbMetrics {
163    fn enabled(&self) -> bool {
164        false
165    }
166
167    fn record_db_query(&self, _metric: &DbQueryMetric) {}
168}
169
170/// Shared trait object for database metrics recorders.
171pub type SharedDbMetricsRecorder = Arc<dyn DbMetricsRecorder>;
172
173/// Application-wide metrics recorder interface.
174///
175/// Product crates should keep domain-specific metrics in extension traits or subsystem recorders.
176/// The methods here cover infrastructure signals that are shared by Aster services.
177#[expect(
178    unused_variables,
179    reason = "Default trait methods intentionally ignore metric inputs so products can implement only the signals they collect."
180)]
181pub trait MetricsRecorder: DbMetricsRecorder + Send + Sync {
182    /// Records an HTTP request.
183    ///
184    /// `method` and `route` become metric label values and MUST be low
185    /// cardinality: pass the route template (e.g. `/api/v1/files/{id}`),
186    /// never the raw request path. Raw paths containing IDs or UUIDs allocate
187    /// a new, never-freed time series per distinct value — a user-input-driven
188    /// memory bomb.
189    fn record_http_request(&self, method: &str, route: &str, status: u16, duration_seconds: f64) {}
190
191    /// Records an authentication event.
192    fn record_auth_event(&self, action: &'static str, status: &'static str, reason: &'static str) {}
193
194    /// Records a generic application event.
195    fn record_application_event(
196        &self,
197        category: &'static str,
198        event: &'static str,
199        status: &'static str,
200    ) {
201    }
202
203    /// Records a runtime configuration reload attempt.
204    fn record_config_reload(
205        &self,
206        source: &'static str,
207        decision: &'static str,
208        status: &'static str,
209        changed_keys: u64,
210        duration_seconds: f64,
211    ) {
212    }
213
214    /// Records a runtime configuration mutation.
215    fn record_config_mutation(
216        &self,
217        source: &'static str,
218        operation: &'static str,
219        status: &'static str,
220        changed_keys: u64,
221    ) {
222    }
223
224    /// Records a background task state transition.
225    fn record_background_task_transition(&self, kind: &'static str, status: &'static str) {}
226
227    /// Sets the number of pending background tasks.
228    fn set_background_tasks_pending(&self, pending: u64) {}
229
230    /// Records an operation against an external system.
231    fn record_external_operation(
232        &self,
233        system: &'static str,
234        operation: &'static str,
235        status: &'static str,
236        duration_seconds: f64,
237    ) {
238    }
239
240    /// Creates an optional background task that updates system-level metrics.
241    fn system_metrics_updater_task(
242        &self,
243        shutdown_token: CancellationToken,
244    ) -> Option<Pin<Box<dyn Future<Output = ()> + Send + 'static>>> {
245        None
246    }
247}
248
249/// Shared trait object for application metrics recorders.
250pub type SharedMetricsRecorder = Arc<dyn MetricsRecorder>;
251
252/// Metrics recorder that ignores every event.
253#[derive(Debug, Default)]
254pub struct NoopMetrics;
255
256impl MetricsRecorder for NoopMetrics {}
257
258impl DbMetricsRecorder for NoopMetrics {
259    fn enabled(&self) -> bool {
260        false
261    }
262
263    fn record_db_query(&self, _metric: &DbQueryMetric) {}
264}
265
266impl NoopMetrics {
267    /// Creates a noop recorder.
268    #[must_use]
269    pub fn new() -> Self {
270        Self
271    }
272
273    /// Creates a shared noop recorder.
274    #[must_use]
275    pub fn arc() -> SharedMetricsRecorder {
276        Arc::new(Self::new())
277    }
278}
279
280/// Initializes a concrete metrics backend or falls back to [`NoopMetrics`].
281///
282/// Product crates own concrete exporters and feature flags. This helper only centralizes the
283/// common startup mechanics used by Aster services: try to initialize the product metrics backend,
284/// return the concrete recorder on success, and keep startup working with a no-op recorder on
285/// initialization failure.
286pub fn init_metrics_or_noop<I, B, E, R>(init_metrics: I, build_recorder: B) -> SharedMetricsRecorder
287where
288    I: FnOnce() -> std::result::Result<(), E>,
289    B: FnOnce() -> R,
290    E: std::fmt::Display,
291    R: MetricsRecorder + 'static,
292{
293    match init_metrics() {
294        Ok(()) => {
295            tracing::info!("metrics backend initialized");
296            Arc::new(build_recorder())
297        }
298        Err(error) => {
299            tracing::warn!(
300                error = %error,
301                "failed to initialize metrics backend; using noop metrics"
302            );
303            NoopMetrics::arc()
304        }
305    }
306}
307
308/// Initializes the metrics backend selected by enabled Forge features.
309///
310/// Product entrypoints should prefer this helper over selecting a concrete backend directly. If no
311/// backend feature is enabled, or if backend initialization fails, the returned recorder is
312/// [`NoopMetrics`].
313#[must_use]
314pub fn init_configured_or_noop() -> SharedMetricsRecorder {
315    #[cfg(feature = "backend-prometheus")]
316    {
317        prometheus::init_or_noop()
318    }
319
320    #[cfg(not(feature = "backend-prometheus"))]
321    {
322        NoopMetrics::arc()
323    }
324}
325
326/// Kind of metric described by a subsystem.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub enum MetricKind {
329    /// Monotonically increasing counter.
330    Counter,
331    /// Point-in-time value.
332    Gauge,
333    /// Duration or distribution bucket metric.
334    Histogram,
335}
336
337/// Static metric descriptor registered by a subsystem.
338#[derive(Debug, Clone, Copy, PartialEq)]
339pub struct MetricDescriptor {
340    /// Owning subsystem name.
341    pub subsystem: &'static str,
342    /// Metric name without backend-specific namespace decoration.
343    pub name: &'static str,
344    /// Human-readable help text.
345    pub help: &'static str,
346    /// Metric kind.
347    pub kind: MetricKind,
348    /// Ordered label names used by the metric.
349    pub labels: &'static [&'static str],
350    /// Optional histogram buckets.
351    pub buckets: &'static [f64],
352}
353
354impl MetricDescriptor {
355    /// Creates a descriptor for a counter metric.
356    #[must_use]
357    pub const fn counter(
358        subsystem: &'static str,
359        name: &'static str,
360        help: &'static str,
361        labels: &'static [&'static str],
362    ) -> Self {
363        Self {
364            subsystem,
365            name,
366            help,
367            kind: MetricKind::Counter,
368            labels,
369            buckets: &[],
370        }
371    }
372
373    /// Creates a descriptor for a gauge metric.
374    #[must_use]
375    pub const fn gauge(
376        subsystem: &'static str,
377        name: &'static str,
378        help: &'static str,
379        labels: &'static [&'static str],
380    ) -> Self {
381        Self {
382            subsystem,
383            name,
384            help,
385            kind: MetricKind::Gauge,
386            labels,
387            buckets: &[],
388        }
389    }
390
391    /// Creates a descriptor for a histogram metric.
392    #[must_use]
393    pub const fn histogram(
394        subsystem: &'static str,
395        name: &'static str,
396        help: &'static str,
397        labels: &'static [&'static str],
398    ) -> Self {
399        Self {
400            subsystem,
401            name,
402            help,
403            kind: MetricKind::Histogram,
404            labels,
405            buckets: &[],
406        }
407    }
408
409    /// Creates a descriptor for a histogram metric with explicit buckets.
410    #[must_use]
411    pub const fn histogram_with_buckets(
412        subsystem: &'static str,
413        name: &'static str,
414        help: &'static str,
415        labels: &'static [&'static str],
416        buckets: &'static [f64],
417    ) -> Self {
418        Self {
419            subsystem,
420            name,
421            help,
422            kind: MetricKind::Histogram,
423            labels,
424            buckets,
425        }
426    }
427}
428
429/// Errors returned by metrics registration.
430#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
431pub enum MetricRegistrationError {
432    /// A subsystem tried to register the same metric name twice.
433    #[error("duplicate metric registration: {subsystem}.{name}")]
434    DuplicateMetric {
435        /// Owning subsystem name.
436        subsystem: &'static str,
437        /// Duplicate metric name.
438        name: &'static str,
439    },
440}
441
442/// Result type returned by metrics registration helpers.
443pub type Result<T> = std::result::Result<T, MetricRegistrationError>;
444
445/// Catalog of metric descriptors registered by application subsystems.
446#[derive(Debug, Default)]
447pub struct MetricCatalog {
448    descriptors: Vec<MetricDescriptor>,
449}
450
451impl MetricCatalog {
452    /// Creates an empty catalog.
453    #[must_use]
454    pub fn new() -> Self {
455        Self::default()
456    }
457
458    /// Registers one metric descriptor.
459    ///
460    /// # Errors
461    ///
462    /// Returns [`MetricCatalogError`] when another descriptor has the same subsystem and name.
463    pub fn register(&mut self, descriptor: MetricDescriptor) -> Result<()> {
464        if self.descriptors.iter().any(|existing| {
465            existing.subsystem == descriptor.subsystem && existing.name == descriptor.name
466        }) {
467            return Err(MetricRegistrationError::DuplicateMetric {
468                subsystem: descriptor.subsystem,
469                name: descriptor.name,
470            });
471        }
472
473        self.descriptors.push(descriptor);
474        Ok(())
475    }
476
477    /// Returns all descriptors in registration order.
478    #[must_use]
479    pub fn descriptors(&self) -> &[MetricDescriptor] {
480        &self.descriptors
481    }
482
483    /// Returns descriptors owned by `subsystem`.
484    pub fn subsystem<'a>(
485        &'a self,
486        subsystem: &'a str,
487    ) -> impl Iterator<Item = &'a MetricDescriptor> + 'a {
488        self.descriptors
489            .iter()
490            .filter(move |descriptor| descriptor.subsystem == subsystem)
491    }
492}
493
494/// A subsystem that owns and registers a set of metric descriptors.
495pub trait MetricsSubsystem {
496    /// Stable subsystem name.
497    fn name(&self) -> &'static str;
498
499    /// Registers metric descriptors owned by this subsystem.
500    ///
501    /// # Errors
502    ///
503    /// Returns [`MetricCatalogError`] when the subsystem registers a duplicate metric.
504    fn register_metrics(&self, catalog: &mut MetricCatalog) -> Result<()>;
505}
506
507/// Registers every subsystem into one catalog.
508///
509/// # Errors
510///
511/// Returns [`MetricCatalogError`] when any subsystem registers a duplicate metric.
512pub fn register_subsystems(
513    catalog: &mut MetricCatalog,
514    subsystems: &[&dyn MetricsSubsystem],
515) -> Result<()> {
516    for subsystem in subsystems {
517        subsystem.register_metrics(catalog)?;
518    }
519    Ok(())
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[derive(Default)]
527    struct EnabledMetrics;
528
529    impl DbMetricsRecorder for EnabledMetrics {
530        fn enabled(&self) -> bool {
531            true
532        }
533
534        fn record_db_query(&self, _metric: &DbQueryMetric) {}
535    }
536
537    impl MetricsRecorder for EnabledMetrics {}
538
539    struct HttpSubsystem;
540
541    impl MetricsSubsystem for HttpSubsystem {
542        fn name(&self) -> &'static str {
543            "http"
544        }
545
546        fn register_metrics(&self, catalog: &mut MetricCatalog) -> Result<()> {
547            catalog.register(MetricDescriptor::histogram(
548                self.name(),
549                "request_duration_seconds",
550                "HTTP request duration.",
551                &["method", "route", "status"],
552            ))
553        }
554    }
555
556    #[test]
557    fn noop_metrics_reports_disabled() {
558        let recorder = NoopMetrics::new();
559
560        assert!(!recorder.enabled());
561        recorder.record_http_request("GET", "/health", 200, 0.01);
562        recorder.record_auth_event("login", "ok", "password");
563        recorder.record_application_event("config", "updated", "ok");
564        recorder.record_config_reload("api", "reloaded", "ok", 2, 0.01);
565        recorder.record_config_mutation("api", "upsert", "ok", 1);
566        recorder.record_background_task_transition("cleanup", "completed");
567        recorder.set_background_tasks_pending(2);
568        recorder.record_external_operation("oidc", "token", "ok", 0.02);
569        assert!(
570            recorder
571                .system_metrics_updater_task(CancellationToken::new())
572                .is_none()
573        );
574    }
575
576    #[test]
577    fn database_metric_labels_are_stable() {
578        assert_eq!(DbMetricBackend::Sqlite.as_label(), "sqlite");
579        assert_eq!(DbMetricBackend::MySql.as_label(), "mysql");
580        assert_eq!(DbMetricBackend::Postgres.as_label(), "postgres");
581        assert_eq!(DbMetricBackend::Other.as_label(), "other");
582
583        assert_eq!(DbQueryKind::Select.as_label(), "select");
584        assert_eq!(DbQueryKind::Insert.as_label(), "insert");
585        assert_eq!(DbQueryKind::Update.as_label(), "update");
586        assert_eq!(DbQueryKind::Delete.as_label(), "delete");
587        assert_eq!(DbQueryKind::With.as_label(), "with");
588        assert_eq!(DbQueryKind::Transaction.as_label(), "transaction");
589        assert_eq!(DbQueryKind::Ddl.as_label(), "ddl");
590        assert_eq!(DbQueryKind::Pragma.as_label(), "pragma");
591        assert_eq!(DbQueryKind::Other.as_label(), "other");
592
593        let ok = DbQueryMetric::new(
594            DbMetricBackend::Sqlite,
595            DbQueryKind::Select,
596            false,
597            std::time::Duration::from_millis(3),
598        );
599        assert_eq!(ok.status_label(), "ok");
600
601        let failed = DbQueryMetric::new(
602            DbMetricBackend::Sqlite,
603            DbQueryKind::Select,
604            true,
605            std::time::Duration::from_millis(3),
606        );
607        assert_eq!(failed.status_label(), "error");
608    }
609
610    #[test]
611    fn init_metrics_or_noop_returns_concrete_recorder_after_successful_init() {
612        let recorder = init_metrics_or_noop(|| Ok::<(), &'static str>(()), || EnabledMetrics);
613
614        assert!(recorder.enabled());
615    }
616
617    #[test]
618    fn init_metrics_or_noop_returns_noop_recorder_after_failed_init() {
619        let recorder = init_metrics_or_noop(|| Err::<(), _>("registry failed"), || EnabledMetrics);
620
621        assert!(!recorder.enabled());
622    }
623
624    #[test]
625    #[cfg(not(feature = "backend-prometheus"))]
626    fn init_configured_or_noop_returns_noop_without_backend_feature() {
627        let recorder = init_configured_or_noop();
628
629        assert!(!recorder.enabled());
630    }
631
632    #[test]
633    #[cfg(feature = "backend-prometheus")]
634    fn init_configured_or_noop_uses_enabled_backend_feature() {
635        let recorder = init_configured_or_noop();
636
637        assert!(recorder.enabled());
638    }
639
640    #[test]
641    fn histogram_descriptor_preserves_explicit_buckets() {
642        let descriptor = MetricDescriptor::histogram_with_buckets(
643            "worker",
644            "duration_seconds",
645            "Worker duration.",
646            &["kind"],
647            &[0.1, 1.0],
648        );
649
650        assert_eq!(descriptor.kind, MetricKind::Histogram);
651        assert_eq!(descriptor.labels, &["kind"]);
652        assert_eq!(descriptor.buckets, &[0.1, 1.0]);
653    }
654
655    #[test]
656    fn catalog_registers_descriptors_in_order() {
657        let mut catalog = MetricCatalog::new();
658
659        catalog
660            .register(MetricDescriptor::counter(
661                "auth",
662                "events_total",
663                "Authentication events.",
664                &["action", "status"],
665            ))
666            .expect("auth metric should register");
667        catalog
668            .register(MetricDescriptor::gauge(
669                "tasks",
670                "pending",
671                "Pending background tasks.",
672                &[],
673            ))
674            .expect("task metric should register");
675
676        assert_eq!(catalog.descriptors().len(), 2);
677        assert_eq!(catalog.descriptors()[0].name, "events_total");
678        assert_eq!(catalog.subsystem("tasks").count(), 1);
679    }
680
681    #[test]
682    fn catalog_rejects_duplicate_subsystem_metric_names() {
683        let mut catalog = MetricCatalog::new();
684        let first = MetricDescriptor::counter("auth", "events_total", "first", &[]);
685        let duplicate = MetricDescriptor::counter("auth", "events_total", "second", &["status"]);
686
687        catalog
688            .register(first)
689            .expect("first metric should register");
690        let error = catalog
691            .register(duplicate)
692            .expect_err("duplicate metric should be rejected");
693
694        assert_eq!(
695            error,
696            MetricRegistrationError::DuplicateMetric {
697                subsystem: "auth",
698                name: "events_total"
699            }
700        );
701    }
702
703    #[test]
704    fn register_subsystems_delegates_to_each_subsystem() {
705        let mut catalog = MetricCatalog::new();
706
707        register_subsystems(&mut catalog, &[&HttpSubsystem])
708            .expect("subsystem metrics should register");
709
710        assert_eq!(catalog.descriptors().len(), 1);
711        assert_eq!(catalog.descriptors()[0].subsystem, "http");
712        assert_eq!(catalog.descriptors()[0].kind, MetricKind::Histogram);
713    }
714}