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