Skip to main content

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