Skip to main content

aster_forge_runtime/
health.rs

1//! Product-neutral health report models and runner.
2//!
3//! The types in this module describe component health and aggregate status.
4//! Product crates decide which components to probe and how to map the report
5//! into HTTP responses, task results, metrics, or admin UI payloads.
6
7use 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/// Coarse status for a health component or an aggregate system report.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum HealthStatus {
21    /// The component is operating normally.
22    Healthy,
23    /// The component works with reduced capability or a fallback.
24    Degraded,
25    /// The component is unavailable or failed its probe.
26    Unhealthy,
27}
28
29impl HealthStatus {
30    /// Returns the stable lowercase wire value.
31    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    /// Returns whether this status should be treated as an operational issue.
40    pub const fn is_issue(self) -> bool {
41        !matches!(self, Self::Healthy)
42    }
43}
44
45/// Runtime view used when selecting which registered checks to run.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum HealthCheckScope {
48    /// Minimal process liveness checks.
49    Liveness,
50    /// Readiness checks used by load balancers and orchestrators.
51    Readiness,
52    /// Full diagnostic checks used by admin pages and runtime tasks.
53    Diagnostics,
54}
55
56impl HealthCheckScope {
57    /// Returns the stable lowercase wire value.
58    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/// Scope membership for a registered health check.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct HealthCheckScopes {
70    liveness: bool,
71    readiness: bool,
72    diagnostics: bool,
73}
74
75impl HealthCheckScopes {
76    /// Includes the check in every health scope.
77    pub const fn all() -> Self {
78        Self {
79            liveness: true,
80            readiness: true,
81            diagnostics: true,
82        }
83    }
84
85    /// Includes the check only in liveness runs.
86    pub const fn liveness() -> Self {
87        Self {
88            liveness: true,
89            readiness: false,
90            diagnostics: false,
91        }
92    }
93
94    /// Includes the check only in readiness runs.
95    pub const fn readiness() -> Self {
96        Self {
97            liveness: false,
98            readiness: true,
99            diagnostics: false,
100        }
101    }
102
103    /// Includes the check only in diagnostics runs.
104    pub const fn diagnostics() -> Self {
105        Self {
106            liveness: false,
107            readiness: false,
108            diagnostics: true,
109        }
110    }
111
112    /// Includes the check in readiness and diagnostics runs.
113    pub const fn readiness_and_diagnostics() -> Self {
114        Self {
115            liveness: false,
116            readiness: true,
117            diagnostics: true,
118        }
119    }
120
121    /// Returns whether this set includes `scope`.
122    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/// Requirement level for a registered health check.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum HealthCheckRequirement {
140    /// Framework-level failures should make the component unhealthy.
141    Required,
142    /// Framework-level failures should make the component degraded.
143    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/// Options applied to a registered health check.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct HealthCheckOptions {
158    /// Whether framework-level failures make the component unhealthy or degraded.
159    pub requirement: HealthCheckRequirement,
160    /// Optional per-component timeout.
161    pub timeout: Option<Duration>,
162    /// Health scopes that should include this check.
163    pub scopes: HealthCheckScopes,
164}
165
166impl HealthCheckOptions {
167    /// Creates required-check options.
168    pub const fn required(timeout: Option<Duration>) -> Self {
169        Self {
170            requirement: HealthCheckRequirement::Required,
171            timeout,
172            scopes: HealthCheckScopes::all(),
173        }
174    }
175
176    /// Creates optional-check options.
177    pub const fn optional(timeout: Option<Duration>) -> Self {
178        Self {
179            requirement: HealthCheckRequirement::Optional,
180            timeout,
181            scopes: HealthCheckScopes::all(),
182        }
183    }
184
185    /// Returns options with a different timeout.
186    pub const fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
187        self.timeout = timeout;
188        self
189    }
190
191    /// Returns options with different scope membership.
192    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/// Static description of a registered health check.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub struct HealthCheckDescriptor {
207    /// Stable component name, such as `database`, `cache`, or `storage`.
208    pub name: &'static str,
209    /// Whether framework-level failures make the component unhealthy or degraded.
210    pub requirement: HealthCheckRequirement,
211    /// Optional per-component timeout.
212    pub timeout: Option<Duration>,
213    /// Health scopes that include this check.
214    pub scopes: HealthCheckScopes,
215}
216
217/// Typed diagnostic value attached to a component detail.
218#[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    /// Human-facing text such as a backend, driver, region, or mode.
223    Text(String),
224    /// Signed integer value.
225    Integer(i64),
226    /// Unsigned counter or depth value.
227    Unsigned(u64),
228    /// Boolean flag.
229    Boolean(bool),
230    /// Duration value in milliseconds for latency, age, lag, or timeout diagnostics.
231    DurationMillis(u64),
232}
233
234impl HealthComponentDetailValue {
235    /// Returns the stable lowercase type name for product DTOs.
236    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    /// Returns the text value when this detail stores text.
247    pub fn as_text(&self) -> Option<&str> {
248        match self {
249            Self::Text(value) => Some(value),
250            _ => None,
251        }
252    }
253
254    /// Returns the signed integer value when this detail stores one.
255    pub const fn as_integer(&self) -> Option<i64> {
256        match self {
257            Self::Integer(value) => Some(*value),
258            _ => None,
259        }
260    }
261
262    /// Returns the unsigned integer value when this detail stores one.
263    pub const fn as_unsigned(&self) -> Option<u64> {
264        match self {
265            Self::Unsigned(value) => Some(*value),
266            _ => None,
267        }
268    }
269
270    /// Returns the boolean value when this detail stores one.
271    pub const fn as_boolean(&self) -> Option<bool> {
272        match self {
273            Self::Boolean(value) => Some(*value),
274            _ => None,
275        }
276    }
277
278    /// Returns the duration value in milliseconds when this detail stores one.
279    pub const fn as_duration_millis(&self) -> Option<u64> {
280        match self {
281            Self::DurationMillis(value) => Some(*value),
282            _ => None,
283        }
284    }
285
286    /// Returns a stable human-facing display value.
287    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/// Structured diagnostic detail attached to a component report.
335#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
336#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
337pub struct HealthComponentDetail {
338    /// Stable detail key.
339    pub key: String,
340    /// Typed detail value.
341    pub value: HealthComponentDetailValue,
342}
343
344impl HealthComponentDetail {
345    /// Builds a typed component detail.
346    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/// Health status for one named component.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct HealthComponentReport {
357    /// Stable component name, such as `database`, `cache`, or `storage`.
358    pub name: &'static str,
359    /// Component status.
360    pub status: HealthStatus,
361    /// Human-facing diagnostic message.
362    pub message: String,
363    /// Duration spent running this component check.
364    pub duration: Option<Duration>,
365    /// Optional structured diagnostics.
366    pub details: Vec<HealthComponentDetail>,
367}
368
369impl HealthComponentReport {
370    /// Builds a healthy component report.
371    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    /// Builds a degraded component report.
382    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    /// Builds an unhealthy component report.
393    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    /// Returns this report with runtime duration attached.
404    pub fn with_duration(mut self, duration: Duration) -> Self {
405        self.duration = Some(duration);
406        self
407    }
408
409    /// Returns this report with a structured diagnostic detail appended.
410    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    /// Returns the first structured detail value for `key`.
420    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    /// Returns component duration in seconds, if present.
428    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/// Builder for health check registries with shared defaults.
451#[derive(Default)]
452pub struct HealthCheckRegistryBuilder {
453    default_timeout: Option<Duration>,
454    default_scopes: HealthCheckScopes,
455    registry: HealthCheckRegistry,
456}
457
458impl HealthCheckRegistryBuilder {
459    /// Creates an empty registry builder.
460    pub fn new() -> Self {
461        Self::default()
462    }
463
464    /// Sets the default timeout used by [`Self::register_required`] and
465    /// [`Self::register_optional`].
466    pub const fn default_timeout(mut self, timeout: Option<Duration>) -> Self {
467        self.default_timeout = timeout;
468        self
469    }
470
471    /// Sets the default scope membership used by [`Self::register_required`]
472    /// and [`Self::register_optional`].
473    pub const fn default_scopes(mut self, scopes: HealthCheckScopes) -> Self {
474        self.default_scopes = scopes;
475        self
476    }
477
478    /// Registers a required check using builder defaults.
479    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    /// Registers an optional check using builder defaults.
493    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    /// Registers a check with explicit options.
507    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    /// Consumes the builder and returns the registry.
522    pub fn build(self) -> HealthCheckRegistry {
523        self.registry
524    }
525}
526
527/// Registry and concurrent runner for product-provided health checks.
528///
529/// The registry owns scope selection, timeout handling, panic-to-report
530/// conversion, concurrent execution, registration-order output, and aggregate
531/// status calculation. Product code owns the actual probe logic and should
532/// return a `HealthComponentReport` with product-specific diagnostics.
533#[derive(Default)]
534pub struct HealthCheckRegistry {
535    checks: Vec<RegisteredHealthCheck>,
536}
537
538impl HealthCheckRegistry {
539    /// Creates an empty health check registry.
540    pub fn new() -> Self {
541        Self::default()
542    }
543
544    /// Creates a registry and applies one registration function.
545    ///
546    /// This is the lightweight path for product code that only needs to run
547    /// health probes. Use [`RuntimeComponentRegistry`](crate::RuntimeComponentRegistry)
548    /// only when the caller also needs component metadata or shutdown phases.
549    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    /// Applies one registration function and returns the registry.
559    ///
560    /// The shape intentionally mirrors Actix Web's `configure` pattern, so
561    /// subsystem modules can expose small registration functions without owning
562    /// the root registry.
563    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    /// Registers a health check with full options.
572    ///
573    /// `name` is also used for timeout and panic reports. The check future
574    /// should return a component report with the same stable name.
575    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    /// Registers a required health check.
594    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    /// Registers an optional health check.
608    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    /// Runs registered checks concurrently and returns an aggregate report.
622    ///
623    /// Component reports are returned in registration order even though checks
624    /// run concurrently.
625    pub async fn run(&self) -> SystemHealthReport {
626        self.run_selected(|_| true).await
627    }
628
629    /// Runs only checks registered for `scope`.
630    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    /// Returns how many health checks are registered.
651    pub fn len(&self) -> usize {
652        self.checks.len()
653    }
654
655    /// Returns whether no health checks are registered.
656    pub fn is_empty(&self) -> bool {
657        self.checks.is_empty()
658    }
659
660    /// Returns registered check descriptors in registration order.
661    pub fn descriptors(&self) -> Vec<HealthCheckDescriptor> {
662        self.checks
663            .iter()
664            .map(RegisteredHealthCheck::descriptor)
665            .collect()
666    }
667
668    /// Returns registered descriptors that belong to `scope`.
669    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
748/// Product-side bridge for recording health reports into a metrics backend.
749///
750/// Forge does not depend on a concrete metrics exporter here. Product crates
751/// implement this trait for their own recorder or a small adapter, then call
752/// [`SystemHealthReport::record_metrics`] after a health run.
753pub trait HealthMetricsRecorder {
754    /// Records the aggregate health result for `scope`.
755    fn record_health_report(
756        &self,
757        scope: &'static str,
758        status: HealthStatus,
759        duration_seconds: f64,
760    );
761
762    /// Records one component result for `scope`.
763    fn record_health_component(
764        &self,
765        scope: &'static str,
766        component: &HealthComponentReport,
767        duration_seconds: f64,
768    );
769}
770
771/// Aggregate health report for a service instance.
772#[derive(Debug, Clone, PartialEq, Eq)]
773pub struct SystemHealthReport {
774    /// Component reports included in this health check run.
775    pub components: Vec<HealthComponentReport>,
776    /// Total duration of the aggregate health run.
777    pub duration: Option<Duration>,
778}
779
780impl SystemHealthReport {
781    /// Returns a report from component entries.
782    pub fn new(components: Vec<HealthComponentReport>) -> Self {
783        Self {
784            components,
785            duration: None,
786        }
787    }
788
789    /// Returns a report from component entries and aggregate duration.
790    pub fn with_duration(components: Vec<HealthComponentReport>, duration: Duration) -> Self {
791        Self {
792            components,
793            duration: Some(duration),
794        }
795    }
796
797    /// Returns aggregate duration in seconds, if present.
798    pub fn duration_seconds(&self) -> Option<f64> {
799        self.duration.map(duration_seconds)
800    }
801
802    /// Records this report through a product-provided metrics bridge.
803    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    /// Returns whether any component is degraded or unhealthy.
823    pub fn has_issues(&self) -> bool {
824        self.components
825            .iter()
826            .any(|component| component.status.is_issue())
827    }
828
829    /// Returns the worst status across all components.
830    ///
831    /// `Unhealthy` dominates `Degraded`, and an empty report is considered
832    /// healthy because no product probe reported an issue.
833    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    /// Returns a compact operator-facing summary.
852    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    /// Returns component status and diagnostic messages for every component.
865    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    /// Returns a compact summary of only degraded or unhealthy components.
881    ///
882    /// When no component reports an issue, this falls back to [`Self::summary`].
883    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    /// Returns diagnostic details for only degraded or unhealthy components.
900    ///
901    /// When no component reports an issue, this falls back to [`Self::details`].
902    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}