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//! instance-owned provider so each subsystem can register 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
28mod instruments;
29mod provider;
30mod shared;
31pub use instruments::*;
32pub use provider::*;
33pub use shared::InfrastructureMetricsRecorder;
34#[cfg(test)]
35mod instance_tests {
36    include!("tests.rs");
37}
38#[cfg(feature = "otlp")]
39mod otlp;
40#[cfg(feature = "otlp")]
41pub use otlp::*;
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    #[must_use]
59    pub const fn as_label(self) -> &'static str {
60        match self {
61            Self::Sqlite => "sqlite",
62            Self::MySql => "mysql",
63            Self::Postgres => "postgres",
64            Self::Other => "other",
65        }
66    }
67}
68
69/// Normalized database query kind used by infrastructure metrics.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum DbQueryKind {
72    /// SELECT or read-like query.
73    Select,
74    /// INSERT query.
75    Insert,
76    /// UPDATE query.
77    Update,
78    /// DELETE query.
79    Delete,
80    /// Common table expression query.
81    With,
82    /// Transaction control statement.
83    Transaction,
84    /// Data definition statement.
85    Ddl,
86    /// `SQLite` PRAGMA statement.
87    Pragma,
88    /// Query kind that could not be classified cheaply.
89    Other,
90}
91
92impl DbQueryKind {
93    /// Returns the stable label used for metrics exporters.
94    #[must_use]
95    pub const fn as_label(self) -> &'static str {
96        match self {
97            Self::Select => "select",
98            Self::Insert => "insert",
99            Self::Update => "update",
100            Self::Delete => "delete",
101            Self::With => "with",
102            Self::Transaction => "transaction",
103            Self::Ddl => "ddl",
104            Self::Pragma => "pragma",
105            Self::Other => "other",
106        }
107    }
108}
109
110/// Product-neutral database query metric emitted by database adapters.
111///
112/// This shape intentionally avoids exposing raw SQL to metrics recorders. Products should keep DB
113/// metrics low-cardinality and free of query parameters.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct DbQueryMetric {
116    /// Database backend that executed the query.
117    pub backend: DbMetricBackend,
118    /// Low-cardinality query kind.
119    pub kind: DbQueryKind,
120    /// Whether the query failed.
121    pub failed: bool,
122    /// Query duration observed by the database adapter.
123    pub elapsed: Duration,
124}
125
126impl DbQueryMetric {
127    /// Creates a database query metric.
128    #[must_use]
129    pub const fn new(
130        backend: DbMetricBackend,
131        kind: DbQueryKind,
132        failed: bool,
133        elapsed: Duration,
134    ) -> Self {
135        Self {
136            backend,
137            kind,
138            failed,
139            elapsed,
140        }
141    }
142
143    /// Returns the stable status label.
144    #[must_use]
145    pub const fn status_label(&self) -> &'static str {
146        if self.failed { "error" } else { "ok" }
147    }
148}
149
150/// Minimal metrics hook used by database connection helpers.
151pub trait DbMetricsRecorder: Send + Sync {
152    /// Returns whether metrics are actively recorded.
153    fn enabled(&self) -> bool;
154
155    /// Records one database query metric.
156    fn record_db_query(&self, metric: &DbQueryMetric);
157}
158
159/// Metrics recorder that ignores every database query.
160#[derive(Debug, Default)]
161pub struct NoopDbMetrics;
162
163impl DbMetricsRecorder for NoopDbMetrics {
164    fn enabled(&self) -> bool {
165        false
166    }
167
168    fn record_db_query(&self, _metric: &DbQueryMetric) {}
169}
170
171/// Shared trait object for database metrics recorders.
172pub type SharedDbMetricsRecorder = Arc<dyn DbMetricsRecorder>;
173
174/// Application-wide metrics recorder interface.
175///
176/// Product crates should keep domain-specific metrics in extension traits or subsystem recorders.
177/// The methods here cover infrastructure signals that are shared by Aster services.
178#[expect(
179    unused_variables,
180    reason = "Default trait methods intentionally ignore metric inputs so products can implement only the signals they collect."
181)]
182pub trait MetricsRecorder: DbMetricsRecorder + Send + Sync {
183    /// Instance resources for endpoint injection and lifecycle management.
184    fn provider(&self) -> Option<MetricsProvider> {
185        None
186    }
187
188    #[cfg(feature = "runtime-health")]
189    fn record_health(
190        &self,
191        _scope: &'static str,
192        _report: &aster_forge_runtime::SystemHealthReport,
193    ) {
194    }
195    /// Records an HTTP request.
196    ///
197    /// `method` and `route` become metric label values and MUST be low
198    /// cardinality: pass the route template (e.g. `/api/v1/files/{id}`),
199    /// never the raw request path. Raw paths containing IDs or UUIDs allocate
200    /// a new, never-freed time series per distinct value — a user-input-driven
201    /// memory bomb.
202    fn record_http_request(&self, method: &str, route: &str, status: u16, duration_seconds: f64) {}
203
204    /// Records an authentication event.
205    fn record_auth_event(&self, action: &'static str, status: &'static str, reason: &'static str) {}
206
207    /// Records a generic application event.
208    fn record_application_event(
209        &self,
210        category: &'static str,
211        event: &'static str,
212        status: &'static str,
213    ) {
214    }
215
216    /// Records a runtime configuration reload attempt.
217    fn record_config_reload(
218        &self,
219        source: &'static str,
220        decision: &'static str,
221        status: &'static str,
222        changed_keys: u64,
223        duration_seconds: f64,
224    ) {
225    }
226
227    /// Records a runtime configuration mutation.
228    fn record_config_mutation(
229        &self,
230        source: &'static str,
231        operation: &'static str,
232        status: &'static str,
233        changed_keys: u64,
234    ) {
235    }
236
237    /// Records a background task state transition.
238    fn record_background_task_transition(&self, kind: &'static str, status: &'static str) {}
239
240    /// Sets the number of pending background tasks.
241    fn set_background_tasks_pending(&self, pending: u64) {}
242
243    /// Records an operation against an external system.
244    fn record_external_operation(
245        &self,
246        system: &'static str,
247        operation: &'static str,
248        status: &'static str,
249        duration_seconds: f64,
250    ) {
251    }
252
253    /// Creates an optional background task that updates system-level metrics.
254    fn system_metrics_updater_task(
255        &self,
256        shutdown_token: CancellationToken,
257    ) -> Option<Pin<Box<dyn Future<Output = ()> + Send + 'static>>> {
258        None
259    }
260}
261
262/// Shared trait object for application metrics recorders.
263pub type SharedMetricsRecorder = Arc<dyn MetricsRecorder>;
264
265/// Metrics recorder that ignores every event.
266#[derive(Debug, Default)]
267pub struct NoopMetrics;
268
269impl MetricsRecorder for NoopMetrics {}
270
271impl DbMetricsRecorder for NoopMetrics {
272    fn enabled(&self) -> bool {
273        false
274    }
275
276    fn record_db_query(&self, _metric: &DbQueryMetric) {}
277}
278
279impl NoopMetrics {
280    /// Creates a noop recorder.
281    #[must_use]
282    pub fn new() -> Self {
283        Self
284    }
285
286    /// Creates a shared noop recorder.
287    #[must_use]
288    pub fn arc() -> SharedMetricsRecorder {
289        Arc::new(Self::new())
290    }
291}
292
293/// Kind of metric described by a subsystem.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum MetricKind {
296    /// Monotonically increasing counter.
297    Counter,
298    /// Point-in-time value.
299    Gauge,
300    /// Duration or distribution bucket metric.
301    Histogram,
302}
303
304/// Static metric descriptor registered by a subsystem.
305#[derive(Debug, Clone, Copy, PartialEq)]
306pub struct MetricDescriptor {
307    /// Owning subsystem name.
308    pub subsystem: &'static str,
309    /// Metric name without backend-specific namespace decoration.
310    pub name: &'static str,
311    /// Human-readable help text.
312    pub help: &'static str,
313    /// Metric kind.
314    pub kind: MetricKind,
315    /// Ordered label names used by the metric.
316    pub labels: &'static [&'static str],
317    /// Optional histogram buckets.
318    pub buckets: &'static [f64],
319    /// UCUM unit, independent of the exporter name.
320    pub unit: &'static str,
321}
322
323impl MetricDescriptor {
324    /// Sets the stable instrument unit without changing the exported name.
325    #[must_use]
326    pub const fn with_unit(mut self, unit: &'static str) -> Self {
327        self.unit = unit;
328        self
329    }
330
331    /// Creates a descriptor for a counter metric.
332    #[must_use]
333    pub const fn counter(
334        subsystem: &'static str,
335        name: &'static str,
336        help: &'static str,
337        labels: &'static [&'static str],
338    ) -> Self {
339        Self {
340            subsystem,
341            name,
342            help,
343            kind: MetricKind::Counter,
344            labels,
345            buckets: &[],
346            unit: "1",
347        }
348    }
349
350    /// Creates a descriptor for a gauge metric.
351    #[must_use]
352    pub const fn gauge(
353        subsystem: &'static str,
354        name: &'static str,
355        help: &'static str,
356        labels: &'static [&'static str],
357    ) -> Self {
358        Self {
359            subsystem,
360            name,
361            help,
362            kind: MetricKind::Gauge,
363            labels,
364            buckets: &[],
365            unit: "1",
366        }
367    }
368
369    /// Creates a descriptor for a histogram metric.
370    #[must_use]
371    pub const fn histogram(
372        subsystem: &'static str,
373        name: &'static str,
374        help: &'static str,
375        labels: &'static [&'static str],
376    ) -> Self {
377        Self {
378            subsystem,
379            name,
380            help,
381            kind: MetricKind::Histogram,
382            labels,
383            buckets: &[],
384            unit: "1",
385        }
386    }
387
388    /// Creates a descriptor for a histogram metric with explicit buckets.
389    #[must_use]
390    pub const fn histogram_with_buckets(
391        subsystem: &'static str,
392        name: &'static str,
393        help: &'static str,
394        labels: &'static [&'static str],
395        buckets: &'static [f64],
396    ) -> Self {
397        Self {
398            subsystem,
399            name,
400            help,
401            kind: MetricKind::Histogram,
402            labels,
403            buckets,
404            unit: "1",
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn noop_metrics_reports_disabled() {
415        let recorder = NoopMetrics::new();
416
417        assert!(!recorder.enabled());
418        recorder.record_http_request("GET", "/health", 200, 0.01);
419        recorder.record_auth_event("login", "ok", "password");
420        recorder.record_application_event("config", "updated", "ok");
421        recorder.record_config_reload("api", "reloaded", "ok", 2, 0.01);
422        recorder.record_config_mutation("api", "upsert", "ok", 1);
423        recorder.record_background_task_transition("cleanup", "completed");
424        recorder.set_background_tasks_pending(2);
425        recorder.record_external_operation("oidc", "token", "ok", 0.02);
426        assert!(
427            recorder
428                .system_metrics_updater_task(CancellationToken::new())
429                .is_none()
430        );
431    }
432
433    #[test]
434    fn database_metric_labels_are_stable() {
435        assert_eq!(DbMetricBackend::Sqlite.as_label(), "sqlite");
436        assert_eq!(DbMetricBackend::MySql.as_label(), "mysql");
437        assert_eq!(DbMetricBackend::Postgres.as_label(), "postgres");
438        assert_eq!(DbMetricBackend::Other.as_label(), "other");
439
440        assert_eq!(DbQueryKind::Select.as_label(), "select");
441        assert_eq!(DbQueryKind::Insert.as_label(), "insert");
442        assert_eq!(DbQueryKind::Update.as_label(), "update");
443        assert_eq!(DbQueryKind::Delete.as_label(), "delete");
444        assert_eq!(DbQueryKind::With.as_label(), "with");
445        assert_eq!(DbQueryKind::Transaction.as_label(), "transaction");
446        assert_eq!(DbQueryKind::Ddl.as_label(), "ddl");
447        assert_eq!(DbQueryKind::Pragma.as_label(), "pragma");
448        assert_eq!(DbQueryKind::Other.as_label(), "other");
449
450        let ok = DbQueryMetric::new(
451            DbMetricBackend::Sqlite,
452            DbQueryKind::Select,
453            false,
454            std::time::Duration::from_millis(3),
455        );
456        assert_eq!(ok.status_label(), "ok");
457
458        let failed = DbQueryMetric::new(
459            DbMetricBackend::Sqlite,
460            DbQueryKind::Select,
461            true,
462            std::time::Duration::from_millis(3),
463        );
464        assert_eq!(failed.status_label(), "error");
465    }
466
467    #[test]
468    fn histogram_descriptor_preserves_explicit_buckets() {
469        let descriptor = MetricDescriptor::histogram_with_buckets(
470            "worker",
471            "duration_seconds",
472            "Worker duration.",
473            &["kind"],
474            &[0.1, 1.0],
475        );
476
477        assert_eq!(descriptor.kind, MetricKind::Histogram);
478        assert_eq!(descriptor.labels, &["kind"]);
479        assert_eq!(descriptor.buckets, &[0.1, 1.0]);
480    }
481}