1#![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
32const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum DbMetricBackend {
46 Sqlite,
48 MySql,
50 Postgres,
52 Other,
54}
55
56impl DbMetricBackend {
57 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct DbQueryMetric {
114 pub backend: DbMetricBackend,
116 pub kind: DbQueryKind,
118 pub failed: bool,
120 pub elapsed: Duration,
122}
123
124impl DbQueryMetric {
125 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 pub const fn status_label(&self) -> &'static str {
142 if self.failed { "error" } else { "ok" }
143 }
144}
145
146pub trait DbMetricsRecorder: Send + Sync {
148 fn enabled(&self) -> bool;
150
151 fn record_db_query(&self, metric: &DbQueryMetric);
153}
154
155#[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
167pub type SharedDbMetricsRecorder = Arc<dyn DbMetricsRecorder>;
169
170#[allow(unused_variables)]
175pub trait MetricsRecorder: DbMetricsRecorder + Send + Sync {
176 fn record_http_request(&self, method: &str, route: &str, status: u16, duration_seconds: f64) {}
184
185 fn record_auth_event(&self, action: &'static str, status: &'static str, reason: &'static str) {}
187
188 fn record_application_event(
190 &self,
191 category: &'static str,
192 event: &'static str,
193 status: &'static str,
194 ) {
195 }
196
197 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 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 fn record_background_task_transition(&self, kind: &'static str, status: &'static str) {}
220
221 fn set_background_tasks_pending(&self, pending: u64) {}
223
224 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 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
243pub type SharedMetricsRecorder = Arc<dyn MetricsRecorder>;
245
246#[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 pub fn new() -> Self {
263 Self
264 }
265
266 pub fn arc() -> SharedMetricsRecorder {
268 Arc::new(Self::new())
269 }
270}
271
272pub 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
300pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum MetricKind {
320 Counter,
322 Gauge,
324 Histogram,
326}
327
328#[derive(Debug, Clone, PartialEq)]
330pub struct MetricDescriptor {
331 pub subsystem: &'static str,
333 pub name: &'static str,
335 pub help: &'static str,
337 pub kind: MetricKind,
339 pub labels: &'static [&'static str],
341 pub buckets: &'static [f64],
343}
344
345impl MetricDescriptor {
346 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
418pub enum MetricRegistrationError {
419 #[error("duplicate metric registration: {subsystem}.{name}")]
421 DuplicateMetric {
422 subsystem: &'static str,
424 name: &'static str,
426 },
427}
428
429pub type Result<T> = std::result::Result<T, MetricRegistrationError>;
431
432#[derive(Debug, Default)]
434pub struct MetricCatalog {
435 descriptors: Vec<MetricDescriptor>,
436}
437
438impl MetricCatalog {
439 pub fn new() -> Self {
441 Self::default()
442 }
443
444 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 pub fn descriptors(&self) -> &[MetricDescriptor] {
461 &self.descriptors
462 }
463
464 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
475pub trait MetricsSubsystem {
477 fn name(&self) -> &'static str;
479
480 fn register_metrics(&self, catalog: &mut MetricCatalog) -> Result<()>;
482}
483
484pub 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}