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    #[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    /// Returns whether this status should be treated as an operational issue.
41    #[must_use]
42    pub const fn is_issue(self) -> bool {
43        !matches!(self, Self::Healthy)
44    }
45}
46
47/// Runtime view used when selecting which registered checks to run.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum HealthCheckScope {
50    /// Minimal process liveness checks.
51    Liveness,
52    /// Readiness checks used by load balancers and orchestrators.
53    Readiness,
54    /// Full diagnostic checks used by admin pages and runtime tasks.
55    Diagnostics,
56}
57
58impl HealthCheckScope {
59    /// Returns the stable lowercase wire value.
60    #[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/// Scope membership for a registered health check.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct HealthCheckScopes {
73    liveness: bool,
74    readiness: bool,
75    diagnostics: bool,
76}
77
78impl HealthCheckScopes {
79    /// Includes the check in every health scope.
80    #[must_use]
81    pub const fn all() -> Self {
82        Self {
83            liveness: true,
84            readiness: true,
85            diagnostics: true,
86        }
87    }
88
89    /// Includes the check only in liveness runs.
90    #[must_use]
91    pub const fn liveness() -> Self {
92        Self {
93            liveness: true,
94            readiness: false,
95            diagnostics: false,
96        }
97    }
98
99    /// Includes the check only in readiness runs.
100    #[must_use]
101    pub const fn readiness() -> Self {
102        Self {
103            liveness: false,
104            readiness: true,
105            diagnostics: false,
106        }
107    }
108
109    /// Includes the check only in diagnostics runs.
110    #[must_use]
111    pub const fn diagnostics() -> Self {
112        Self {
113            liveness: false,
114            readiness: false,
115            diagnostics: true,
116        }
117    }
118
119    /// Includes the check in readiness and diagnostics runs.
120    #[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    /// Returns whether this set includes `scope`.
130    #[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/// Requirement level for a registered health check.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum HealthCheckRequirement {
149    /// Framework-level failures should make the component unhealthy.
150    Required,
151    /// Framework-level failures should make the component degraded.
152    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/// Options applied to a registered health check.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub struct HealthCheckOptions {
167    /// Whether framework-level failures make the component unhealthy or degraded.
168    pub requirement: HealthCheckRequirement,
169    /// Optional per-component timeout.
170    pub timeout: Option<Duration>,
171    /// Health scopes that should include this check.
172    pub scopes: HealthCheckScopes,
173}
174
175impl HealthCheckOptions {
176    /// Creates required-check options.
177    #[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    /// Creates optional-check options.
187    #[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    /// Returns options with a different timeout.
197    #[must_use]
198    pub const fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
199        self.timeout = timeout;
200        self
201    }
202
203    /// Returns options with different scope membership.
204    #[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/// Static description of a registered health check.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub struct HealthCheckDescriptor {
220    /// Stable component name, such as `database`, `cache`, or `storage`.
221    pub name: &'static str,
222    /// Whether framework-level failures make the component unhealthy or degraded.
223    pub requirement: HealthCheckRequirement,
224    /// Optional per-component timeout.
225    pub timeout: Option<Duration>,
226    /// Health scopes that include this check.
227    pub scopes: HealthCheckScopes,
228}
229
230/// Typed diagnostic value attached to a component detail.
231#[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    /// Human-facing text such as a backend, driver, region, or mode.
236    Text(String),
237    /// Signed integer value.
238    Integer(i64),
239    /// Unsigned counter or depth value.
240    Unsigned(u64),
241    /// Boolean flag.
242    Boolean(bool),
243    /// Duration value in milliseconds for latency, age, lag, or timeout diagnostics.
244    DurationMillis(u64),
245}
246
247impl HealthComponentDetailValue {
248    /// Returns the stable lowercase type name for product DTOs.
249    #[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    /// Returns the text value when this detail stores text.
261    #[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    /// Returns the signed integer value when this detail stores one.
270    #[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    /// Returns the unsigned integer value when this detail stores one.
279    #[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    /// Returns the boolean value when this detail stores one.
288    #[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    /// Returns the duration value in milliseconds when this detail stores one.
297    #[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    /// Returns a stable human-facing display value.
306    #[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/// Structured diagnostic detail attached to a component report.
355#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
356#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
357pub struct HealthComponentDetail {
358    /// Stable detail key.
359    pub key: String,
360    /// Typed detail value.
361    pub value: HealthComponentDetailValue,
362}
363
364impl HealthComponentDetail {
365    /// Builds a typed component detail.
366    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/// Health status for one named component.
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct HealthComponentReport {
377    /// Stable component name, such as `database`, `cache`, or `storage`.
378    pub name: &'static str,
379    /// Component status.
380    pub status: HealthStatus,
381    /// Human-facing diagnostic message.
382    pub message: String,
383    /// Duration spent running this component check.
384    pub duration: Option<Duration>,
385    /// Optional structured diagnostics.
386    pub details: Vec<HealthComponentDetail>,
387}
388
389impl HealthComponentReport {
390    /// Builds a healthy component report.
391    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    /// Builds a degraded component report.
402    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    /// Builds an unhealthy component report.
413    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    /// Returns this report with runtime duration attached.
424    #[must_use]
425    pub fn with_duration(mut self, duration: Duration) -> Self {
426        self.duration = Some(duration);
427        self
428    }
429
430    /// Returns this report with a structured diagnostic detail appended.
431    #[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    /// Returns the first structured detail value for `key`.
442    #[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    /// Returns component duration in seconds, if present.
451    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/// Builder for health check registries with shared defaults.
474#[derive(Default)]
475pub struct HealthCheckRegistryBuilder {
476    default_timeout: Option<Duration>,
477    default_scopes: HealthCheckScopes,
478    registry: HealthCheckRegistry,
479}
480
481impl HealthCheckRegistryBuilder {
482    /// Creates an empty registry builder.
483    #[must_use]
484    pub fn new() -> Self {
485        Self::default()
486    }
487
488    /// Sets the default timeout used by [`Self::register_required`] and
489    /// [`Self::register_optional`].
490    #[must_use]
491    pub const fn default_timeout(mut self, timeout: Option<Duration>) -> Self {
492        self.default_timeout = timeout;
493        self
494    }
495
496    /// Sets the default scope membership used by [`Self::register_required`]
497    /// and [`Self::register_optional`].
498    #[must_use]
499    pub const fn default_scopes(mut self, scopes: HealthCheckScopes) -> Self {
500        self.default_scopes = scopes;
501        self
502    }
503
504    /// Registers a required check using builder defaults.
505    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    /// Registers an optional check using builder defaults.
519    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    /// Registers a check with explicit options.
533    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    /// Consumes the builder and returns the registry.
548    #[must_use]
549    pub fn build(self) -> HealthCheckRegistry {
550        self.registry
551    }
552}
553
554/// Registry and concurrent runner for product-provided health checks.
555///
556/// The registry owns scope selection, timeout handling, panic-to-report
557/// conversion, concurrent execution, registration-order output, and aggregate
558/// status calculation. Product code owns the actual probe logic and should
559/// return a `HealthComponentReport` with product-specific diagnostics.
560#[derive(Default)]
561pub struct HealthCheckRegistry {
562    checks: Vec<RegisteredHealthCheck>,
563}
564
565impl HealthCheckRegistry {
566    /// Creates an empty health check registry.
567    #[must_use]
568    pub fn new() -> Self {
569        Self::default()
570    }
571
572    /// Creates a registry and applies one registration function.
573    ///
574    /// This is the lightweight path for product code that only needs to run
575    /// health probes. Use [`RuntimeComponentRegistry`](crate::RuntimeComponentRegistry)
576    /// only when the caller also needs component metadata or shutdown phases.
577    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    /// Applies one registration function and returns the registry.
587    ///
588    /// The shape intentionally mirrors Actix Web's `configure` pattern, so
589    /// subsystem modules can expose small registration functions without owning
590    /// the root registry.
591    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    /// Registers a health check with full options.
600    ///
601    /// `name` is also used for timeout and panic reports. The check future
602    /// should return a component report with the same stable name.
603    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    /// Registers a required health check.
622    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    /// Registers an optional health check.
636    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    /// Runs registered checks concurrently and returns an aggregate report.
650    ///
651    /// Component reports are returned in registration order even though checks
652    /// run concurrently.
653    pub async fn run(&self) -> SystemHealthReport {
654        self.run_selected(|_| true).await
655    }
656
657    /// Runs only checks registered for `scope`.
658    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    /// Returns how many health checks are registered.
679    #[must_use]
680    pub fn len(&self) -> usize {
681        self.checks.len()
682    }
683
684    /// Returns whether no health checks are registered.
685    #[must_use]
686    pub fn is_empty(&self) -> bool {
687        self.checks.is_empty()
688    }
689
690    /// Returns registered check descriptors in registration order.
691    pub fn descriptors(&self) -> Vec<HealthCheckDescriptor> {
692        self.checks
693            .iter()
694            .map(RegisteredHealthCheck::descriptor)
695            .collect()
696    }
697
698    /// Returns registered descriptors that belong to `scope`.
699    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
780/// Product-side bridge for recording health reports into a metrics backend.
781///
782/// Forge does not depend on a concrete metrics exporter here. Product crates
783/// implement this trait for their own recorder or a small adapter, then call
784/// [`SystemHealthReport::record_metrics`] after a health run.
785pub trait HealthMetricsRecorder {
786    /// Records the aggregate health result for `scope`.
787    fn record_health_report(
788        &self,
789        scope: &'static str,
790        status: HealthStatus,
791        duration_seconds: f64,
792    );
793
794    /// Records one component result for `scope`.
795    fn record_health_component(
796        &self,
797        scope: &'static str,
798        component: &HealthComponentReport,
799        duration_seconds: f64,
800    );
801}
802
803/// Aggregate health report for a service instance.
804#[derive(Debug, Clone, PartialEq, Eq)]
805pub struct SystemHealthReport {
806    /// Component reports included in this health check run.
807    pub components: Vec<HealthComponentReport>,
808    /// Total duration of the aggregate health run.
809    pub duration: Option<Duration>,
810}
811
812impl SystemHealthReport {
813    /// Returns a report from component entries.
814    #[must_use]
815    pub fn new(components: Vec<HealthComponentReport>) -> Self {
816        Self {
817            components,
818            duration: None,
819        }
820    }
821
822    /// Returns a report from component entries and aggregate duration.
823    #[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    /// Returns aggregate duration in seconds, if present.
832    pub fn duration_seconds(&self) -> Option<f64> {
833        self.duration.map(duration_seconds)
834    }
835
836    /// Records this report through a product-provided metrics bridge.
837    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    /// Returns whether any component is degraded or unhealthy.
857    #[must_use]
858    pub fn has_issues(&self) -> bool {
859        self.components
860            .iter()
861            .any(|component| component.status.is_issue())
862    }
863
864    /// Returns the worst status across all components.
865    ///
866    /// `Unhealthy` dominates `Degraded`, and an empty report is considered
867    /// healthy because no product probe reported an issue.
868    #[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    /// Returns a compact operator-facing summary.
888    #[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    /// Returns component status and diagnostic messages for every component.
902    #[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    /// Returns a compact summary of only degraded or unhealthy components.
919    ///
920    /// When no component reports an issue, this falls back to [`Self::summary`].
921    #[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    /// Returns diagnostic details for only degraded or unhealthy components.
939    ///
940    /// When no component reports an issue, this falls back to [`Self::details`].
941    #[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}