1#![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
31const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum DbMetricBackend {
45 Sqlite,
47 MySql,
49 Postgres,
51 Other,
53}
54
55impl DbMetricBackend {
56 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum DbQueryKind {
71 Select,
73 Insert,
75 Update,
77 Delete,
79 With,
81 Transaction,
83 Ddl,
85 Pragma,
87 Other,
89}
90
91impl DbQueryKind {
92 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct DbQueryMetric {
115 pub backend: DbMetricBackend,
117 pub kind: DbQueryKind,
119 pub failed: bool,
121 pub elapsed: Duration,
123}
124
125impl DbQueryMetric {
126 #[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 #[must_use]
144 pub const fn status_label(&self) -> &'static str {
145 if self.failed { "error" } else { "ok" }
146 }
147}
148
149pub trait DbMetricsRecorder: Send + Sync {
151 fn enabled(&self) -> bool;
153
154 fn record_db_query(&self, metric: &DbQueryMetric);
156}
157
158#[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
170pub type SharedDbMetricsRecorder = Arc<dyn DbMetricsRecorder>;
172
173#[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 fn record_http_request(&self, method: &str, route: &str, status: u16, duration_seconds: f64) {}
190
191 fn record_auth_event(&self, action: &'static str, status: &'static str, reason: &'static str) {}
193
194 fn record_application_event(
196 &self,
197 category: &'static str,
198 event: &'static str,
199 status: &'static str,
200 ) {
201 }
202
203 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 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 fn record_background_task_transition(&self, kind: &'static str, status: &'static str) {}
226
227 fn set_background_tasks_pending(&self, pending: u64) {}
229
230 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 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
249pub type SharedMetricsRecorder = Arc<dyn MetricsRecorder>;
251
252#[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 #[must_use]
269 pub fn new() -> Self {
270 Self
271 }
272
273 #[must_use]
275 pub fn arc() -> SharedMetricsRecorder {
276 Arc::new(Self::new())
277 }
278}
279
280pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub enum MetricKind {
329 Counter,
331 Gauge,
333 Histogram,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq)]
339pub struct MetricDescriptor {
340 pub subsystem: &'static str,
342 pub name: &'static str,
344 pub help: &'static str,
346 pub kind: MetricKind,
348 pub labels: &'static [&'static str],
350 pub buckets: &'static [f64],
352}
353
354impl MetricDescriptor {
355 #[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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
431pub enum MetricRegistrationError {
432 #[error("duplicate metric registration: {subsystem}.{name}")]
434 DuplicateMetric {
435 subsystem: &'static str,
437 name: &'static str,
439 },
440}
441
442pub type Result<T> = std::result::Result<T, MetricRegistrationError>;
444
445#[derive(Debug, Default)]
447pub struct MetricCatalog {
448 descriptors: Vec<MetricDescriptor>,
449}
450
451impl MetricCatalog {
452 #[must_use]
454 pub fn new() -> Self {
455 Self::default()
456 }
457
458 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 #[must_use]
479 pub fn descriptors(&self) -> &[MetricDescriptor] {
480 &self.descriptors
481 }
482
483 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
494pub trait MetricsSubsystem {
496 fn name(&self) -> &'static str;
498
499 fn register_metrics(&self, catalog: &mut MetricCatalog) -> Result<()>;
505}
506
507pub 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}