1use std::future::Future;
8use std::panic::AssertUnwindSafe;
9use std::pin::Pin;
10use std::time::{Duration, Instant};
11
12use futures::FutureExt;
13use futures::future::join_all;
14
15type HealthCheckFuture = Pin<Box<dyn Future<Output = HealthComponentReport> + Send>>;
16type HealthCheckFn = dyn Fn() -> HealthCheckFuture + Send + Sync;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum HealthStatus {
21 Healthy,
23 Degraded,
25 Unhealthy,
27}
28
29impl HealthStatus {
30 #[must_use]
32 pub const fn as_str(self) -> &'static str {
33 match self {
34 Self::Healthy => "healthy",
35 Self::Degraded => "degraded",
36 Self::Unhealthy => "unhealthy",
37 }
38 }
39
40 #[must_use]
42 pub const fn is_issue(self) -> bool {
43 !matches!(self, Self::Healthy)
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum HealthCheckScope {
50 Liveness,
52 Readiness,
54 Diagnostics,
56}
57
58impl HealthCheckScope {
59 #[must_use]
61 pub const fn as_str(self) -> &'static str {
62 match self {
63 Self::Liveness => "liveness",
64 Self::Readiness => "readiness",
65 Self::Diagnostics => "diagnostics",
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct HealthCheckScopes {
73 liveness: bool,
74 readiness: bool,
75 diagnostics: bool,
76}
77
78impl HealthCheckScopes {
79 #[must_use]
81 pub const fn all() -> Self {
82 Self {
83 liveness: true,
84 readiness: true,
85 diagnostics: true,
86 }
87 }
88
89 #[must_use]
91 pub const fn liveness() -> Self {
92 Self {
93 liveness: true,
94 readiness: false,
95 diagnostics: false,
96 }
97 }
98
99 #[must_use]
101 pub const fn readiness() -> Self {
102 Self {
103 liveness: false,
104 readiness: true,
105 diagnostics: false,
106 }
107 }
108
109 #[must_use]
111 pub const fn diagnostics() -> Self {
112 Self {
113 liveness: false,
114 readiness: false,
115 diagnostics: true,
116 }
117 }
118
119 #[must_use]
121 pub const fn readiness_and_diagnostics() -> Self {
122 Self {
123 liveness: false,
124 readiness: true,
125 diagnostics: true,
126 }
127 }
128
129 #[must_use]
131 pub const fn contains(self, scope: HealthCheckScope) -> bool {
132 match scope {
133 HealthCheckScope::Liveness => self.liveness,
134 HealthCheckScope::Readiness => self.readiness,
135 HealthCheckScope::Diagnostics => self.diagnostics,
136 }
137 }
138}
139
140impl Default for HealthCheckScopes {
141 fn default() -> Self {
142 Self::all()
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum HealthCheckRequirement {
149 Required,
151 Optional,
153}
154
155impl HealthCheckRequirement {
156 const fn runtime_failure_status(self) -> HealthStatus {
157 match self {
158 Self::Required => HealthStatus::Unhealthy,
159 Self::Optional => HealthStatus::Degraded,
160 }
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub struct HealthCheckOptions {
167 pub requirement: HealthCheckRequirement,
169 pub timeout: Option<Duration>,
171 pub scopes: HealthCheckScopes,
173}
174
175impl HealthCheckOptions {
176 #[must_use]
178 pub const fn required(timeout: Option<Duration>) -> Self {
179 Self {
180 requirement: HealthCheckRequirement::Required,
181 timeout,
182 scopes: HealthCheckScopes::all(),
183 }
184 }
185
186 #[must_use]
188 pub const fn optional(timeout: Option<Duration>) -> Self {
189 Self {
190 requirement: HealthCheckRequirement::Optional,
191 timeout,
192 scopes: HealthCheckScopes::all(),
193 }
194 }
195
196 #[must_use]
198 pub const fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
199 self.timeout = timeout;
200 self
201 }
202
203 #[must_use]
205 pub const fn with_scopes(mut self, scopes: HealthCheckScopes) -> Self {
206 self.scopes = scopes;
207 self
208 }
209}
210
211impl Default for HealthCheckOptions {
212 fn default() -> Self {
213 Self::required(None)
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub struct HealthCheckDescriptor {
220 pub name: &'static str,
222 pub requirement: HealthCheckRequirement,
224 pub timeout: Option<Duration>,
226 pub scopes: HealthCheckScopes,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
232#[serde(tag = "type", content = "value", rename_all = "snake_case")]
233#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
234pub enum HealthComponentDetailValue {
235 Text(String),
237 Integer(i64),
239 Unsigned(u64),
241 Boolean(bool),
243 DurationMillis(u64),
245}
246
247impl HealthComponentDetailValue {
248 #[must_use]
250 pub const fn value_type(&self) -> &'static str {
251 match self {
252 Self::Text(_) => "text",
253 Self::Integer(_) => "integer",
254 Self::Unsigned(_) => "unsigned",
255 Self::Boolean(_) => "boolean",
256 Self::DurationMillis(_) => "duration_millis",
257 }
258 }
259
260 #[must_use]
262 pub fn as_text(&self) -> Option<&str> {
263 match self {
264 Self::Text(value) => Some(value),
265 _ => None,
266 }
267 }
268
269 #[must_use]
271 pub const fn as_integer(&self) -> Option<i64> {
272 match self {
273 Self::Integer(value) => Some(*value),
274 _ => None,
275 }
276 }
277
278 #[must_use]
280 pub const fn as_unsigned(&self) -> Option<u64> {
281 match self {
282 Self::Unsigned(value) => Some(*value),
283 _ => None,
284 }
285 }
286
287 #[must_use]
289 pub const fn as_boolean(&self) -> Option<bool> {
290 match self {
291 Self::Boolean(value) => Some(*value),
292 _ => None,
293 }
294 }
295
296 #[must_use]
298 pub const fn as_duration_millis(&self) -> Option<u64> {
299 match self {
300 Self::DurationMillis(value) => Some(*value),
301 _ => None,
302 }
303 }
304
305 #[must_use]
307 pub fn display_value(&self) -> String {
308 match self {
309 Self::Text(value) => value.clone(),
310 Self::Integer(value) => value.to_string(),
311 Self::Unsigned(value) => value.to_string(),
312 Self::Boolean(value) => value.to_string(),
313 Self::DurationMillis(value) => duration_millis_display_value(*value),
314 }
315 }
316}
317
318impl From<String> for HealthComponentDetailValue {
319 fn from(value: String) -> Self {
320 Self::Text(value)
321 }
322}
323
324impl From<&str> for HealthComponentDetailValue {
325 fn from(value: &str) -> Self {
326 Self::Text(value.to_string())
327 }
328}
329
330impl From<i64> for HealthComponentDetailValue {
331 fn from(value: i64) -> Self {
332 Self::Integer(value)
333 }
334}
335
336impl From<u64> for HealthComponentDetailValue {
337 fn from(value: u64) -> Self {
338 Self::Unsigned(value)
339 }
340}
341
342impl From<bool> for HealthComponentDetailValue {
343 fn from(value: bool) -> Self {
344 Self::Boolean(value)
345 }
346}
347
348impl From<Duration> for HealthComponentDetailValue {
349 fn from(value: Duration) -> Self {
350 Self::DurationMillis(saturating_duration_millis(value))
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
356#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
357pub struct HealthComponentDetail {
358 pub key: String,
360 pub value: HealthComponentDetailValue,
362}
363
364impl HealthComponentDetail {
365 pub fn new(key: impl Into<String>, value: impl Into<HealthComponentDetailValue>) -> Self {
367 Self {
368 key: key.into(),
369 value: value.into(),
370 }
371 }
372}
373
374#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct HealthComponentReport {
377 pub name: &'static str,
379 pub status: HealthStatus,
381 pub message: String,
383 pub duration: Option<Duration>,
385 pub details: Vec<HealthComponentDetail>,
387}
388
389impl HealthComponentReport {
390 pub fn healthy(name: &'static str, message: impl Into<String>) -> Self {
392 Self {
393 name,
394 status: HealthStatus::Healthy,
395 message: message.into(),
396 duration: None,
397 details: Vec::new(),
398 }
399 }
400
401 pub fn degraded(name: &'static str, message: impl Into<String>) -> Self {
403 Self {
404 name,
405 status: HealthStatus::Degraded,
406 message: message.into(),
407 duration: None,
408 details: Vec::new(),
409 }
410 }
411
412 pub fn unhealthy(name: &'static str, message: impl Into<String>) -> Self {
414 Self {
415 name,
416 status: HealthStatus::Unhealthy,
417 message: message.into(),
418 duration: None,
419 details: Vec::new(),
420 }
421 }
422
423 #[must_use]
425 pub fn with_duration(mut self, duration: Duration) -> Self {
426 self.duration = Some(duration);
427 self
428 }
429
430 #[must_use]
432 pub fn with_detail(
433 mut self,
434 key: impl Into<String>,
435 value: impl Into<HealthComponentDetailValue>,
436 ) -> Self {
437 self.details.push(HealthComponentDetail::new(key, value));
438 self
439 }
440
441 #[must_use]
443 pub fn detail(&self, key: &str) -> Option<&HealthComponentDetailValue> {
444 self.details
445 .iter()
446 .find(|detail| detail.key == key)
447 .map(|detail| &detail.value)
448 }
449
450 pub fn duration_seconds(&self) -> Option<f64> {
452 self.duration.map(duration_seconds)
453 }
454}
455
456struct RegisteredHealthCheck {
457 name: &'static str,
458 options: HealthCheckOptions,
459 check: Box<HealthCheckFn>,
460}
461
462impl RegisteredHealthCheck {
463 fn descriptor(&self) -> HealthCheckDescriptor {
464 HealthCheckDescriptor {
465 name: self.name,
466 requirement: self.options.requirement,
467 timeout: self.options.timeout,
468 scopes: self.options.scopes,
469 }
470 }
471}
472
473#[derive(Default)]
475pub struct HealthCheckRegistryBuilder {
476 default_timeout: Option<Duration>,
477 default_scopes: HealthCheckScopes,
478 registry: HealthCheckRegistry,
479}
480
481impl HealthCheckRegistryBuilder {
482 #[must_use]
484 pub fn new() -> Self {
485 Self::default()
486 }
487
488 #[must_use]
491 pub const fn default_timeout(mut self, timeout: Option<Duration>) -> Self {
492 self.default_timeout = timeout;
493 self
494 }
495
496 #[must_use]
499 pub const fn default_scopes(mut self, scopes: HealthCheckScopes) -> Self {
500 self.default_scopes = scopes;
501 self
502 }
503
504 pub fn register_required<F, Fut>(&mut self, name: &'static str, check: F) -> &mut Self
506 where
507 F: Fn() -> Fut + Send + Sync + 'static,
508 Fut: Future<Output = HealthComponentReport> + Send + 'static,
509 {
510 self.registry.register_with_options(
511 name,
512 HealthCheckOptions::required(self.default_timeout).with_scopes(self.default_scopes),
513 check,
514 );
515 self
516 }
517
518 pub fn register_optional<F, Fut>(&mut self, name: &'static str, check: F) -> &mut Self
520 where
521 F: Fn() -> Fut + Send + Sync + 'static,
522 Fut: Future<Output = HealthComponentReport> + Send + 'static,
523 {
524 self.registry.register_with_options(
525 name,
526 HealthCheckOptions::optional(self.default_timeout).with_scopes(self.default_scopes),
527 check,
528 );
529 self
530 }
531
532 pub fn register_with_options<F, Fut>(
534 &mut self,
535 name: &'static str,
536 options: HealthCheckOptions,
537 check: F,
538 ) -> &mut Self
539 where
540 F: Fn() -> Fut + Send + Sync + 'static,
541 Fut: Future<Output = HealthComponentReport> + Send + 'static,
542 {
543 self.registry.register_with_options(name, options, check);
544 self
545 }
546
547 #[must_use]
549 pub fn build(self) -> HealthCheckRegistry {
550 self.registry
551 }
552}
553
554#[derive(Default)]
561pub struct HealthCheckRegistry {
562 checks: Vec<RegisteredHealthCheck>,
563}
564
565impl HealthCheckRegistry {
566 #[must_use]
568 pub fn new() -> Self {
569 Self::default()
570 }
571
572 pub fn configured<F>(configure: F) -> Self
578 where
579 F: FnOnce(&mut Self),
580 {
581 let mut registry = Self::new();
582 registry.configure(configure);
583 registry
584 }
585
586 pub fn configure<F>(&mut self, configure: F) -> &mut Self
592 where
593 F: FnOnce(&mut Self),
594 {
595 configure(self);
596 self
597 }
598
599 pub fn register_with_options<F, Fut>(
604 &mut self,
605 name: &'static str,
606 options: HealthCheckOptions,
607 check: F,
608 ) -> &mut Self
609 where
610 F: Fn() -> Fut + Send + Sync + 'static,
611 Fut: Future<Output = HealthComponentReport> + Send + 'static,
612 {
613 self.checks.push(RegisteredHealthCheck {
614 name,
615 options,
616 check: Box::new(move || Box::pin(check())),
617 });
618 self
619 }
620
621 pub fn register_required<F, Fut>(
623 &mut self,
624 name: &'static str,
625 timeout: Option<Duration>,
626 check: F,
627 ) -> &mut Self
628 where
629 F: Fn() -> Fut + Send + Sync + 'static,
630 Fut: Future<Output = HealthComponentReport> + Send + 'static,
631 {
632 self.register_with_options(name, HealthCheckOptions::required(timeout), check)
633 }
634
635 pub fn register_optional<F, Fut>(
637 &mut self,
638 name: &'static str,
639 timeout: Option<Duration>,
640 check: F,
641 ) -> &mut Self
642 where
643 F: Fn() -> Fut + Send + Sync + 'static,
644 Fut: Future<Output = HealthComponentReport> + Send + 'static,
645 {
646 self.register_with_options(name, HealthCheckOptions::optional(timeout), check)
647 }
648
649 pub async fn run(&self) -> SystemHealthReport {
654 self.run_selected(|_| true).await
655 }
656
657 pub async fn run_scope(&self, scope: HealthCheckScope) -> SystemHealthReport {
659 self.run_selected(|check| check.options.scopes.contains(scope))
660 .await
661 }
662
663 async fn run_selected<F>(&self, include: F) -> SystemHealthReport
664 where
665 F: Fn(&RegisteredHealthCheck) -> bool,
666 {
667 let started = Instant::now();
668 let futures = self
669 .checks
670 .iter()
671 .filter(|check| include(check))
672 .map(run_registered_check);
673 let components = join_all(futures).await;
674
675 SystemHealthReport::with_duration(components, started.elapsed())
676 }
677
678 #[must_use]
680 pub fn len(&self) -> usize {
681 self.checks.len()
682 }
683
684 #[must_use]
686 pub fn is_empty(&self) -> bool {
687 self.checks.is_empty()
688 }
689
690 pub fn descriptors(&self) -> Vec<HealthCheckDescriptor> {
692 self.checks
693 .iter()
694 .map(RegisteredHealthCheck::descriptor)
695 .collect()
696 }
697
698 pub fn descriptors_for_scope(&self, scope: HealthCheckScope) -> Vec<HealthCheckDescriptor> {
700 self.checks
701 .iter()
702 .filter(|check| check.options.scopes.contains(scope))
703 .map(RegisteredHealthCheck::descriptor)
704 .collect()
705 }
706}
707
708async fn run_registered_check(check: &RegisteredHealthCheck) -> HealthComponentReport {
709 let started = Instant::now();
710 let outcome = AssertUnwindSafe(async {
711 let future = (check.check)();
712 match check.options.timeout {
713 Some(timeout) => match tokio::time::timeout(timeout, future).await {
714 Ok(component) => component,
715 Err(_) => timeout_component(check.name, check.options.requirement, timeout),
716 },
717 None => future.await,
718 }
719 })
720 .catch_unwind()
721 .await;
722 let duration = started.elapsed();
723
724 match outcome {
725 Ok(component) => {
726 if component.duration.is_some() {
727 component
728 } else {
729 component.with_duration(duration)
730 }
731 }
732 Err(_) => runtime_failure_component(
733 check.name,
734 check.options.requirement,
735 "health check panicked",
736 )
737 .with_duration(duration),
738 }
739}
740
741fn timeout_component(
742 name: &'static str,
743 requirement: HealthCheckRequirement,
744 timeout: Duration,
745) -> HealthComponentReport {
746 let message = format!("health check timed out after {}ms", timeout.as_millis());
747 runtime_failure_component(name, requirement, message)
748}
749
750fn runtime_failure_component(
751 name: &'static str,
752 requirement: HealthCheckRequirement,
753 message: impl Into<String>,
754) -> HealthComponentReport {
755 match requirement.runtime_failure_status() {
756 HealthStatus::Healthy => HealthComponentReport::healthy(name, message),
757 HealthStatus::Degraded => HealthComponentReport::degraded(name, message),
758 HealthStatus::Unhealthy => HealthComponentReport::unhealthy(name, message),
759 }
760}
761
762fn duration_seconds(duration: Duration) -> f64 {
763 duration.as_secs_f64()
764}
765
766fn saturating_duration_millis(duration: Duration) -> u64 {
767 aster_forge_utils::numbers::u128_to_u64_saturating(duration.as_millis())
768}
769
770fn duration_millis_display_value(duration_millis: u64) -> String {
771 if duration_millis < 1_000 {
772 format!("{duration_millis}ms")
773 } else {
774 let seconds = duration_millis / 1_000;
775 let fractional_millis = duration_millis % 1_000;
776 format!("{seconds}.{fractional_millis:03}s")
777 }
778}
779
780pub trait HealthMetricsRecorder {
786 fn record_health_report(
788 &self,
789 scope: &'static str,
790 status: HealthStatus,
791 duration_seconds: f64,
792 );
793
794 fn record_health_component(
796 &self,
797 scope: &'static str,
798 component: &HealthComponentReport,
799 duration_seconds: f64,
800 );
801}
802
803#[derive(Debug, Clone, PartialEq, Eq)]
805pub struct SystemHealthReport {
806 pub components: Vec<HealthComponentReport>,
808 pub duration: Option<Duration>,
810}
811
812impl SystemHealthReport {
813 #[must_use]
815 pub fn new(components: Vec<HealthComponentReport>) -> Self {
816 Self {
817 components,
818 duration: None,
819 }
820 }
821
822 #[must_use]
824 pub fn with_duration(components: Vec<HealthComponentReport>, duration: Duration) -> Self {
825 Self {
826 components,
827 duration: Some(duration),
828 }
829 }
830
831 pub fn duration_seconds(&self) -> Option<f64> {
833 self.duration.map(duration_seconds)
834 }
835
836 pub fn record_metrics<R>(&self, scope: &'static str, recorder: &R)
838 where
839 R: HealthMetricsRecorder + ?Sized,
840 {
841 recorder.record_health_report(
842 scope,
843 self.status(),
844 self.duration_seconds().unwrap_or_default(),
845 );
846
847 for component in &self.components {
848 recorder.record_health_component(
849 scope,
850 component,
851 component.duration_seconds().unwrap_or_default(),
852 );
853 }
854 }
855
856 #[must_use]
858 pub fn has_issues(&self) -> bool {
859 self.components
860 .iter()
861 .any(|component| component.status.is_issue())
862 }
863
864 #[must_use]
869 pub fn status(&self) -> HealthStatus {
870 if self
871 .components
872 .iter()
873 .any(|component| matches!(component.status, HealthStatus::Unhealthy))
874 {
875 HealthStatus::Unhealthy
876 } else if self
877 .components
878 .iter()
879 .any(|component| matches!(component.status, HealthStatus::Degraded))
880 {
881 HealthStatus::Degraded
882 } else {
883 HealthStatus::Healthy
884 }
885 }
886
887 #[must_use]
889 pub fn summary(&self) -> String {
890 if self.components.is_empty() {
891 return "system health check did not run any components".to_string();
892 }
893
894 self.components
895 .iter()
896 .map(|component| format!("{} {}", component.name, component.status.as_str()))
897 .collect::<Vec<_>>()
898 .join(", ")
899 }
900
901 #[must_use]
903 pub fn details(&self) -> String {
904 self.components
905 .iter()
906 .map(|component| {
907 format!(
908 "{}={}: {}",
909 component.name,
910 component.status.as_str(),
911 component.message
912 )
913 })
914 .collect::<Vec<_>>()
915 .join("; ")
916 }
917
918 #[must_use]
922 pub fn issue_summary(&self) -> String {
923 let summary = self
924 .components
925 .iter()
926 .filter(|component| component.status.is_issue())
927 .map(|component| format!("{} {}", component.name, component.status.as_str()))
928 .collect::<Vec<_>>()
929 .join(", ");
930
931 if summary.is_empty() {
932 self.summary()
933 } else {
934 summary
935 }
936 }
937
938 #[must_use]
942 pub fn issue_details(&self) -> String {
943 let details = self
944 .components
945 .iter()
946 .filter(|component| component.status.is_issue())
947 .map(|component| {
948 format!(
949 "{}={}: {}",
950 component.name,
951 component.status.as_str(),
952 component.message
953 )
954 })
955 .collect::<Vec<_>>()
956 .join("; ");
957
958 if details.is_empty() {
959 self.details()
960 } else {
961 details
962 }
963 }
964}
965
966#[cfg(test)]
967mod tests {
968 use super::{
969 HealthCheckOptions, HealthCheckRegistry, HealthCheckRegistryBuilder,
970 HealthCheckRequirement, HealthCheckScope, HealthCheckScopes, HealthComponentDetail,
971 HealthComponentDetailValue, HealthComponentReport, HealthMetricsRecorder, HealthStatus,
972 SystemHealthReport,
973 };
974 use std::sync::{Arc, Mutex};
975 use std::time::Duration;
976
977 #[test]
978 fn health_status_reports_wire_values_and_issues() {
979 assert_eq!(HealthStatus::Healthy.as_str(), "healthy");
980 assert_eq!(HealthStatus::Degraded.as_str(), "degraded");
981 assert_eq!(HealthStatus::Unhealthy.as_str(), "unhealthy");
982 assert!(!HealthStatus::Healthy.is_issue());
983 assert!(HealthStatus::Degraded.is_issue());
984 assert!(HealthStatus::Unhealthy.is_issue());
985 }
986
987 #[test]
988 fn component_constructors_preserve_name_status_and_message() {
989 assert_eq!(
990 HealthComponentReport::healthy("database", "ok"),
991 HealthComponentReport {
992 name: "database",
993 status: HealthStatus::Healthy,
994 message: "ok".to_string(),
995 duration: None,
996 details: Vec::new(),
997 }
998 );
999 assert_eq!(
1000 HealthComponentReport::degraded("cache", "fallback").status,
1001 HealthStatus::Degraded
1002 );
1003 assert_eq!(
1004 HealthComponentReport::unhealthy("database", "down").status,
1005 HealthStatus::Unhealthy
1006 );
1007 assert_eq!(
1008 HealthComponentReport::healthy("cache", "ok")
1009 .with_duration(Duration::from_millis(3))
1010 .with_detail("backend", "memory"),
1011 HealthComponentReport {
1012 name: "cache",
1013 status: HealthStatus::Healthy,
1014 message: "ok".to_string(),
1015 duration: Some(Duration::from_millis(3)),
1016 details: vec![HealthComponentDetail {
1017 key: "backend".to_string(),
1018 value: HealthComponentDetailValue::Text("memory".to_string()),
1019 }],
1020 }
1021 );
1022 let report = HealthComponentReport::healthy("cache", "ok")
1023 .with_detail("backend", "memory")
1024 .with_detail("queue_depth", 7_u64)
1025 .with_detail("healthy", true)
1026 .with_detail("latency", Duration::from_millis(42));
1027 assert_eq!(
1028 report
1029 .detail("backend")
1030 .and_then(HealthComponentDetailValue::as_text),
1031 Some("memory")
1032 );
1033 assert_eq!(
1034 report
1035 .detail("queue_depth")
1036 .and_then(HealthComponentDetailValue::as_unsigned),
1037 Some(7)
1038 );
1039 assert_eq!(
1040 report
1041 .detail("healthy")
1042 .and_then(HealthComponentDetailValue::as_boolean),
1043 Some(true)
1044 );
1045 assert_eq!(
1046 report
1047 .detail("latency")
1048 .and_then(HealthComponentDetailValue::as_duration_millis),
1049 Some(42)
1050 );
1051 assert_eq!(report.detail("missing"), None);
1052 }
1053
1054 #[test]
1055 fn component_details_serialize_as_typed_schema() {
1056 let details = vec![
1057 HealthComponentDetail::new("backend", "redis"),
1058 HealthComponentDetail::new("queue_depth", 12_u64),
1059 HealthComponentDetail::new("healthy", true),
1060 HealthComponentDetail::new("latency", Duration::from_millis(42)),
1061 ];
1062
1063 let encoded = serde_json::to_value(&details).unwrap();
1064
1065 assert_eq!(
1066 encoded,
1067 serde_json::json!([
1068 { "key": "backend", "value": { "type": "text", "value": "redis" } },
1069 { "key": "queue_depth", "value": { "type": "unsigned", "value": 12 } },
1070 { "key": "healthy", "value": { "type": "boolean", "value": true } },
1071 { "key": "latency", "value": { "type": "duration_millis", "value": 42 } }
1072 ])
1073 );
1074 }
1075
1076 #[test]
1077 fn health_check_scopes_select_expected_views() {
1078 assert_eq!(HealthCheckScope::Readiness.as_str(), "readiness");
1079 assert!(HealthCheckScopes::all().contains(HealthCheckScope::Liveness));
1080 assert!(HealthCheckScopes::all().contains(HealthCheckScope::Readiness));
1081 assert!(HealthCheckScopes::all().contains(HealthCheckScope::Diagnostics));
1082 assert!(
1083 HealthCheckScopes::readiness_and_diagnostics().contains(HealthCheckScope::Readiness)
1084 );
1085 assert!(
1086 HealthCheckScopes::readiness_and_diagnostics().contains(HealthCheckScope::Diagnostics)
1087 );
1088 assert!(
1089 !HealthCheckScopes::readiness_and_diagnostics().contains(HealthCheckScope::Liveness)
1090 );
1091 }
1092
1093 #[test]
1094 fn system_health_report_status_and_summary_follow_worst_component() {
1095 let healthy = SystemHealthReport::new(vec![
1096 HealthComponentReport::healthy("database", "ok"),
1097 HealthComponentReport::healthy("cache", "ok"),
1098 ]);
1099 assert!(!healthy.has_issues());
1100 assert_eq!(healthy.status(), HealthStatus::Healthy);
1101 assert_eq!(healthy.summary(), "database healthy, cache healthy");
1102
1103 let degraded = SystemHealthReport::new(vec![
1104 HealthComponentReport::healthy("database", "ok"),
1105 HealthComponentReport::degraded("cache", "fallback"),
1106 ]);
1107 assert!(degraded.has_issues());
1108 assert_eq!(degraded.status(), HealthStatus::Degraded);
1109 assert_eq!(degraded.summary(), "database healthy, cache degraded");
1110 assert_eq!(
1111 degraded.details(),
1112 "database=healthy: ok; cache=degraded: fallback"
1113 );
1114 assert_eq!(degraded.issue_summary(), "cache degraded");
1115 assert_eq!(degraded.issue_details(), "cache=degraded: fallback");
1116
1117 let unhealthy = SystemHealthReport::new(vec![
1118 HealthComponentReport::degraded("cache", "fallback"),
1119 HealthComponentReport::unhealthy("database", "down"),
1120 ]);
1121 assert!(unhealthy.has_issues());
1122 assert_eq!(unhealthy.status(), HealthStatus::Unhealthy);
1123 assert_eq!(unhealthy.summary(), "cache degraded, database unhealthy");
1124 assert_eq!(
1125 unhealthy.issue_summary(),
1126 "cache degraded, database unhealthy"
1127 );
1128 assert_eq!(
1129 unhealthy.issue_details(),
1130 "cache=degraded: fallback; database=unhealthy: down"
1131 );
1132 }
1133
1134 #[test]
1135 fn empty_system_health_report_has_explicit_summary() {
1136 let report = SystemHealthReport::new(Vec::new());
1137
1138 assert!(!report.has_issues());
1139 assert_eq!(report.status(), HealthStatus::Healthy);
1140 assert_eq!(
1141 report.summary(),
1142 "system health check did not run any components"
1143 );
1144 assert_eq!(report.details(), "");
1145 assert_eq!(
1146 report.issue_summary(),
1147 "system health check did not run any components"
1148 );
1149 assert_eq!(report.issue_details(), "");
1150 }
1151
1152 #[tokio::test]
1153 async fn health_check_registry_applies_configure_function() {
1154 let registry = HealthCheckRegistry::configured(|registry| {
1155 registry
1156 .register_with_options(
1157 "database",
1158 HealthCheckOptions::required(None)
1159 .with_scopes(HealthCheckScopes::readiness_and_diagnostics()),
1160 || async { HealthComponentReport::healthy("database", "ok") },
1161 )
1162 .configure(|registry| {
1163 registry.register_with_options(
1164 "cache",
1165 HealthCheckOptions::optional(None)
1166 .with_scopes(HealthCheckScopes::diagnostics()),
1167 || async { HealthComponentReport::healthy("cache", "ok") },
1168 );
1169 });
1170 });
1171
1172 let readiness = registry.run_scope(HealthCheckScope::Readiness).await;
1173 let diagnostics = registry.run_scope(HealthCheckScope::Diagnostics).await;
1174
1175 assert_eq!(readiness.components.len(), 1);
1176 assert_eq!(readiness.components[0].name, "database");
1177 assert_eq!(diagnostics.components.len(), 2);
1178 assert_eq!(
1179 registry
1180 .descriptors_for_scope(HealthCheckScope::Readiness)
1181 .len(),
1182 1
1183 );
1184 }
1185
1186 #[tokio::test]
1187 async fn health_check_registry_runs_registered_checks_concurrently_in_registration_order() {
1188 let mut registry = HealthCheckRegistry::new();
1189 registry
1190 .register_required("database", None, || async {
1191 tokio::time::sleep(Duration::from_millis(40)).await;
1192 HealthComponentReport::healthy("database", "ok")
1193 })
1194 .register_optional("cache", None, || async {
1195 HealthComponentReport::degraded("cache", "fallback")
1196 });
1197
1198 let started = std::time::Instant::now();
1199 let report = registry.run().await;
1200
1201 assert_eq!(registry.len(), 2);
1202 assert_eq!(report.status(), HealthStatus::Degraded);
1203 assert_eq!(report.summary(), "database healthy, cache degraded");
1204 assert!(started.elapsed() < Duration::from_millis(80));
1205 assert_eq!(report.components[0].name, "database");
1206 assert_eq!(report.components[1].name, "cache");
1207 assert!(report.duration.is_some());
1208 assert!(
1209 report
1210 .components
1211 .iter()
1212 .all(|component| component.duration.is_some())
1213 );
1214 }
1215
1216 #[tokio::test]
1217 async fn health_check_registry_runs_selected_scope_only() {
1218 let mut registry = HealthCheckRegistry::new();
1219 registry
1220 .register_with_options(
1221 "database",
1222 HealthCheckOptions::required(None)
1223 .with_scopes(HealthCheckScopes::readiness_and_diagnostics()),
1224 || async { HealthComponentReport::healthy("database", "ok") },
1225 )
1226 .register_with_options(
1227 "cache",
1228 HealthCheckOptions::optional(None).with_scopes(HealthCheckScopes::diagnostics()),
1229 || async { HealthComponentReport::healthy("cache", "ok") },
1230 );
1231
1232 let readiness = registry.run_scope(HealthCheckScope::Readiness).await;
1233 let diagnostics = registry.run_scope(HealthCheckScope::Diagnostics).await;
1234
1235 assert_eq!(readiness.components.len(), 1);
1236 assert_eq!(readiness.components[0].name, "database");
1237 assert_eq!(diagnostics.components.len(), 2);
1238 }
1239
1240 #[tokio::test]
1241 async fn health_check_registry_exposes_descriptors_by_scope() {
1242 let mut registry = HealthCheckRegistry::new();
1243 registry
1244 .register_with_options(
1245 "database",
1246 HealthCheckOptions::required(Some(Duration::from_secs(5)))
1247 .with_scopes(HealthCheckScopes::readiness_and_diagnostics()),
1248 || async { HealthComponentReport::healthy("database", "ok") },
1249 )
1250 .register_with_options(
1251 "cache",
1252 HealthCheckOptions::optional(None).with_scopes(HealthCheckScopes::diagnostics()),
1253 || async { HealthComponentReport::healthy("cache", "ok") },
1254 );
1255
1256 let all = registry.descriptors();
1257 let readiness = registry.descriptors_for_scope(HealthCheckScope::Readiness);
1258
1259 assert_eq!(all.len(), 2);
1260 assert_eq!(all[0].name, "database");
1261 assert_eq!(all[0].timeout, Some(Duration::from_secs(5)));
1262 assert_eq!(all[1].requirement, HealthCheckRequirement::Optional);
1263 assert_eq!(readiness.len(), 1);
1264 assert_eq!(readiness[0].name, "database");
1265 }
1266
1267 #[tokio::test]
1268 async fn health_check_registry_builder_applies_defaults() {
1269 let mut builder = HealthCheckRegistryBuilder::new()
1270 .default_timeout(Some(Duration::from_secs(2)))
1271 .default_scopes(HealthCheckScopes::diagnostics());
1272 builder
1273 .register_required("database", || async {
1274 HealthComponentReport::healthy("database", "ok")
1275 })
1276 .register_optional("cache", || async {
1277 HealthComponentReport::healthy("cache", "ok")
1278 });
1279 let registry = builder.build();
1280
1281 let descriptors = registry.descriptors();
1282 assert_eq!(descriptors.len(), 2);
1283 assert_eq!(descriptors[0].timeout, Some(Duration::from_secs(2)));
1284 assert!(
1285 descriptors[0]
1286 .scopes
1287 .contains(HealthCheckScope::Diagnostics)
1288 );
1289 assert!(!descriptors[0].scopes.contains(HealthCheckScope::Readiness));
1290 assert_eq!(descriptors[1].requirement, HealthCheckRequirement::Optional);
1291 }
1292
1293 #[tokio::test]
1294 async fn health_check_registry_maps_timeouts_by_requirement() {
1295 let mut registry = HealthCheckRegistry::new();
1296 registry
1297 .register_required("critical", Some(Duration::from_millis(1)), || async {
1298 tokio::time::sleep(Duration::from_millis(50)).await;
1299 HealthComponentReport::healthy("critical", "late")
1300 })
1301 .register_optional("optional", Some(Duration::from_millis(1)), || async {
1302 tokio::time::sleep(Duration::from_millis(50)).await;
1303 HealthComponentReport::healthy("optional", "late")
1304 });
1305
1306 let report = registry.run().await;
1307
1308 assert_eq!(report.components[0].status, HealthStatus::Unhealthy);
1309 assert_eq!(report.components[1].status, HealthStatus::Degraded);
1310 assert!(
1311 report.components[0]
1312 .message
1313 .contains("health check timed out")
1314 );
1315 }
1316
1317 #[tokio::test]
1318 async fn health_check_registry_maps_panics_by_requirement() {
1319 let mut registry = HealthCheckRegistry::new();
1320 registry
1321 .register_required("critical", None, || async {
1322 panic!("critical health check panic")
1323 })
1324 .register_optional("optional", None, || async {
1325 panic!("optional health check panic")
1326 });
1327
1328 let report = registry.run().await;
1329
1330 assert_eq!(report.components[0].status, HealthStatus::Unhealthy);
1331 assert_eq!(report.components[0].message, "health check panicked");
1332 assert_eq!(report.components[1].status, HealthStatus::Degraded);
1333 assert_eq!(report.components[1].message, "health check panicked");
1334 }
1335
1336 #[test]
1337 fn system_health_report_records_metrics_through_bridge() {
1338 #[derive(Default)]
1339 struct Recorder {
1340 events: Arc<Mutex<Vec<String>>>,
1341 }
1342
1343 impl HealthMetricsRecorder for Recorder {
1344 fn record_health_report(
1345 &self,
1346 scope: &'static str,
1347 status: HealthStatus,
1348 duration_seconds: f64,
1349 ) {
1350 self.events.lock().unwrap().push(format!(
1351 "report:{scope}:{}:{duration_seconds:.3}",
1352 status.as_str()
1353 ));
1354 }
1355
1356 fn record_health_component(
1357 &self,
1358 scope: &'static str,
1359 component: &HealthComponentReport,
1360 duration_seconds: f64,
1361 ) {
1362 self.events.lock().unwrap().push(format!(
1363 "component:{scope}:{}:{}:{duration_seconds:.3}",
1364 component.name,
1365 component.status.as_str()
1366 ));
1367 }
1368 }
1369
1370 let recorder = Recorder::default();
1371 let report = SystemHealthReport::with_duration(
1372 vec![
1373 HealthComponentReport::healthy("database", "ok")
1374 .with_duration(Duration::from_millis(10)),
1375 HealthComponentReport::degraded("cache", "fallback")
1376 .with_duration(Duration::from_millis(20)),
1377 ],
1378 Duration::from_millis(25),
1379 );
1380
1381 report.record_metrics("diagnostics", &recorder);
1382
1383 let events = recorder.events.lock().unwrap();
1384 assert_eq!(
1385 events.as_slice(),
1386 [
1387 "report:diagnostics:degraded:0.025",
1388 "component:diagnostics:database:healthy:0.010",
1389 "component:diagnostics:cache:degraded:0.020",
1390 ]
1391 );
1392 }
1393}