Skip to main content

lean_ctx/core/ocla/
health.rs

1//! Aggregated health reporting for the OCLA wire surface.
2
3use std::sync::OnceLock;
4use std::time::Instant;
5
6use serde::Serialize;
7
8use crate::core::a2a::dlq::DeadLetterQueue;
9
10use super::capsule::global_capsule_store;
11use super::registry::OclaRegistry;
12use super::response_cache::global_response_cache;
13use super::tracing::initialized_collector;
14use super::types::{OCLA_API_VERSION, OclaCapability, OclaCapabilityKind, OclaCapabilityStatus};
15use super::unified_ledger::{FileUnifiedLedger, UnifiedLedger};
16
17static STARTED_AT: OnceLock<Instant> = OnceLock::new();
18static DLQ: OnceLock<DeadLetterQueue> = OnceLock::new();
19
20pub(crate) fn dead_letter_queue() -> &'static DeadLetterQueue {
21    DLQ.get_or_init(DeadLetterQueue::new)
22}
23
24#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
25pub struct DlqHealthDetails {
26    pub total: usize,
27    pub oldest_age_secs: u64,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub total_entries: Option<usize>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub total_bytes: Option<usize>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub max_depth: Option<usize>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub hits: Option<u64>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub misses: Option<u64>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub evictions: Option<u64>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub span_count: Option<usize>,
42}
43
44#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
45pub struct ComponentHealth {
46    pub name: String,
47    pub status: HealthStatus,
48    pub latency_ms: Option<u64>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub details: Option<DlqHealthDetails>,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum HealthStatus {
56    Healthy,
57    Degraded(String),
58    Unhealthy(String),
59}
60
61#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
62pub struct SystemHealth {
63    pub overall: HealthStatus,
64    pub components: Vec<ComponentHealth>,
65    pub uptime_seconds: u64,
66    pub version: String,
67}
68
69/// Collects health for every OCLA capability and its supporting services.
70pub fn check_system_health() -> SystemHealth {
71    let started_at = STARTED_AT.get_or_init(Instant::now);
72    let registry = OclaRegistry::global();
73    let mut components = Vec::with_capacity(OclaCapabilityKind::ALL.len() + 7);
74
75    components.push(poll_capability("observation_hook", || {
76        registry.observation_hook.capability()
77    }));
78    components.push(poll_capability("usage_sink", || {
79        registry.usage_sink.capability()
80    }));
81    components.push(poll_capability("metrics_exporter", || {
82        registry.metrics_exporter.capability()
83    }));
84    components.push(poll_capability("savings_ledger", || {
85        registry.savings_ledger.capability()
86    }));
87    components.push(poll_capability("intent_classifier", || {
88        registry.intent_classifier.capability()
89    }));
90    components.push(poll_capability("outcome_tracker", || {
91        registry.outcome_tracker.capability()
92    }));
93    components.push(poll_capability("compression_provider", || {
94        registry.compression_provider.capability()
95    }));
96    components.push(poll_capability("response_optimizer", || {
97        registry.response_optimizer.capability()
98    }));
99    components.push(poll_capability("model_router", || {
100        registry.model_router.capability()
101    }));
102    components.push(poll_capability("efficiency_analyzer", || {
103        registry.efficiency_analyzer.capability()
104    }));
105    components.push(poll_capability("config_tuner", || {
106        registry.config_tuner.capability()
107    }));
108    components.push(poll_capability("experiment_runner", || {
109        registry.experiment_runner.capability()
110    }));
111    components.push(poll_capability("connector_scheduler", || {
112        registry.connector_scheduler.capability()
113    }));
114    components.push(poll_capability("agent_gateway", || {
115        registry.agent_gateway.capability()
116    }));
117
118    components.push(check_a2a_bus());
119    components.push(check_ledger());
120    components.push(check_budget());
121    components.push(check_dlq(dead_letter_queue()));
122    components.push(check_capsule_store());
123    components.push(check_response_cache());
124    components.push(check_tracing());
125
126    let overall = aggregate_statuses(&components);
127    SystemHealth {
128        overall,
129        components,
130        uptime_seconds: started_at.elapsed().as_secs(),
131        version: OCLA_API_VERSION.to_string(),
132    }
133}
134
135fn poll_capability<F>(name: &str, poll: F) -> ComponentHealth
136where
137    F: FnOnce() -> OclaCapability,
138{
139    let started_at = Instant::now();
140    let capability = poll();
141    let status = match capability.status {
142        OclaCapabilityStatus::Available => HealthStatus::Healthy,
143        OclaCapabilityStatus::Degraded => {
144            HealthStatus::Degraded("capability reports degraded".into())
145        }
146        OclaCapabilityStatus::Unavailable => {
147            HealthStatus::Unhealthy("capability unavailable".into())
148        }
149    };
150    ComponentHealth {
151        name: name.to_string(),
152        status,
153        latency_ms: Some(started_at.elapsed().as_millis() as u64),
154        details: None,
155    }
156}
157
158fn check_a2a_bus() -> ComponentHealth {
159    let started_at = Instant::now();
160    let status = if crate::core::agents::AgentRegistry::load().is_some() {
161        HealthStatus::Healthy
162    } else {
163        HealthStatus::Degraded("A2A agent registry is unavailable".into())
164    };
165    ComponentHealth {
166        name: "a2a_bus".into(),
167        status,
168        latency_ms: Some(started_at.elapsed().as_millis() as u64),
169        details: None,
170    }
171}
172
173fn check_ledger() -> ComponentHealth {
174    let started_at = Instant::now();
175    let status = match FileUnifiedLedger::from_data_dir().and_then(|ledger| ledger.verify_chain()) {
176        Ok(true) => HealthStatus::Healthy,
177        Ok(false) => HealthStatus::Unhealthy("ledger chain integrity check failed".into()),
178        Err(error) => HealthStatus::Unhealthy(format!("ledger is inaccessible: {error}")),
179    };
180    ComponentHealth {
181        name: "ledger".into(),
182        status,
183        latency_ms: Some(started_at.elapsed().as_millis() as u64),
184        details: None,
185    }
186}
187
188fn check_budget() -> ComponentHealth {
189    let started_at = Instant::now();
190    let snapshot = crate::core::budget_tracker::BudgetTracker::global().check();
191    let status = match snapshot.worst_level() {
192        crate::core::budget_tracker::BudgetLevel::Ok => HealthStatus::Healthy,
193        crate::core::budget_tracker::BudgetLevel::Warning => {
194            HealthStatus::Degraded("runtime budget warning".into())
195        }
196        crate::core::budget_tracker::BudgetLevel::Exhausted => {
197            HealthStatus::Unhealthy("runtime budget exhausted".into())
198        }
199    };
200    ComponentHealth {
201        name: "budget".into(),
202        status,
203        latency_ms: Some(started_at.elapsed().as_millis() as u64),
204        details: None,
205    }
206}
207
208fn check_dlq(queue: &DeadLetterQueue) -> ComponentHealth {
209    let started_at = Instant::now();
210    let stats = queue.stats();
211    let status = if stats.total > 500 {
212        HealthStatus::Unhealthy(format!("DLQ contains {} entries", stats.total))
213    } else if stats.total > 100 {
214        HealthStatus::Degraded(format!("DLQ contains {} entries", stats.total))
215    } else {
216        HealthStatus::Healthy
217    };
218    ComponentHealth {
219        name: "dlq".into(),
220        status,
221        latency_ms: Some(started_at.elapsed().as_millis() as u64),
222        details: Some(DlqHealthDetails {
223            total: stats.total,
224            oldest_age_secs: stats.oldest_age_seconds,
225            ..Default::default()
226        }),
227    }
228}
229
230fn check_capsule_store() -> ComponentHealth {
231    let started_at = Instant::now();
232    let stats = global_capsule_store().stats();
233    let status = if stats.total_entries > 10_000 {
234        HealthStatus::Degraded(format!(
235            "capsule store contains {} entries",
236            stats.total_entries
237        ))
238    } else {
239        HealthStatus::Healthy
240    };
241    ComponentHealth {
242        name: "capsule_store".into(),
243        status,
244        latency_ms: Some(started_at.elapsed().as_millis() as u64),
245        details: Some(DlqHealthDetails {
246            total_entries: Some(stats.total_entries),
247            total_bytes: Some(stats.total_bytes),
248            max_depth: Some(stats.max_depth),
249            ..Default::default()
250        }),
251    }
252}
253
254fn check_response_cache() -> ComponentHealth {
255    let started_at = Instant::now();
256    let stats = global_response_cache().stats();
257    let status = if stats.evictions > stats.hits {
258        HealthStatus::Degraded("response cache is thrashing".into())
259    } else if stats.hit_rate > 0.0 || stats.entries == 0 {
260        HealthStatus::Healthy
261    } else {
262        HealthStatus::Degraded("response cache has no hits".into())
263    };
264    ComponentHealth {
265        name: "response_cache".into(),
266        status,
267        latency_ms: Some(started_at.elapsed().as_millis() as u64),
268        details: Some(DlqHealthDetails {
269            total_entries: Some(stats.entries),
270            hits: Some(stats.hits),
271            misses: Some(stats.misses),
272            evictions: Some(stats.evictions),
273            ..Default::default()
274        }),
275    }
276}
277
278fn check_tracing() -> ComponentHealth {
279    let started_at = Instant::now();
280    let (status, span_count) = match initialized_collector() {
281        Some(collector) => (HealthStatus::Healthy, collector.span_count()),
282        None => (HealthStatus::Healthy, 0),
283    };
284    ComponentHealth {
285        name: "tracing".into(),
286        status,
287        latency_ms: Some(started_at.elapsed().as_millis() as u64),
288        details: Some(DlqHealthDetails {
289            span_count: Some(span_count),
290            ..Default::default()
291        }),
292    }
293}
294
295fn aggregate_statuses(components: &[ComponentHealth]) -> HealthStatus {
296    if let Some(reason) = components
297        .iter()
298        .find_map(|component| match &component.status {
299            HealthStatus::Unhealthy(reason) => Some(reason.clone()),
300            _ => None,
301        })
302    {
303        return HealthStatus::Unhealthy(reason);
304    }
305    if let Some(reason) = components
306        .iter()
307        .find_map(|component| match &component.status {
308            HealthStatus::Degraded(reason) => Some(reason.clone()),
309            _ => None,
310        })
311    {
312        return HealthStatus::Degraded(reason);
313    }
314    HealthStatus::Healthy
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    fn component(status: HealthStatus) -> ComponentHealth {
322        ComponentHealth {
323            name: "test".into(),
324            status,
325            latency_ms: None,
326            details: None,
327        }
328    }
329
330    #[test]
331    fn health_includes_capsule_store() {
332        let report = check_system_health();
333        assert!(
334            report
335                .components
336                .iter()
337                .any(|component| { component.name == "capsule_store" })
338        );
339    }
340
341    #[test]
342    fn health_includes_response_cache() {
343        let report = check_system_health();
344        assert!(
345            report
346                .components
347                .iter()
348                .any(|component| { component.name == "response_cache" })
349        );
350    }
351
352    #[test]
353    fn health_includes_tracing() {
354        let report = check_system_health();
355        assert!(
356            report
357                .components
358                .iter()
359                .any(|component| { component.name == "tracing" })
360        );
361    }
362
363    #[test]
364    fn capsule_store_healthy() {
365        let health = check_capsule_store();
366        assert_eq!(health.status, HealthStatus::Healthy);
367        assert!(
368            health
369                .details
370                .as_ref()
371                .expect("details")
372                .total_entries
373                .is_some(),
374            "capsule store health must report total_entries"
375        );
376    }
377
378    #[test]
379    fn all_healthy_aggregates_to_healthy() {
380        let components = vec![
381            component(HealthStatus::Healthy),
382            component(HealthStatus::Healthy),
383        ];
384        assert_eq!(aggregate_statuses(&components), HealthStatus::Healthy);
385    }
386
387    #[test]
388    fn mixed_health_aggregates_to_degraded() {
389        let components = vec![
390            component(HealthStatus::Healthy),
391            component(HealthStatus::Degraded("slow".into())),
392        ];
393        assert_eq!(
394            aggregate_statuses(&components),
395            HealthStatus::Degraded("slow".into())
396        );
397    }
398
399    #[test]
400    fn all_unhealthy_aggregates_to_unhealthy() {
401        let components = vec![
402            component(HealthStatus::Unhealthy("first failed".into())),
403            component(HealthStatus::Unhealthy("second failed".into())),
404        ];
405        assert_eq!(
406            aggregate_statuses(&components),
407            HealthStatus::Unhealthy("first failed".into())
408        );
409    }
410
411    #[test]
412    fn unhealthy_takes_precedence_over_degraded() {
413        let components = vec![
414            component(HealthStatus::Degraded("slow".into())),
415            component(HealthStatus::Unhealthy("failed".into())),
416        ];
417        assert_eq!(
418            aggregate_statuses(&components),
419            HealthStatus::Unhealthy("failed".into())
420        );
421    }
422
423    #[test]
424    fn system_health_reports_all_components() {
425        let report = check_system_health();
426        assert_eq!(report.components.len(), 21);
427        assert_eq!(report.version, OCLA_API_VERSION);
428    }
429
430    #[test]
431    fn dlq_health_thresholds_and_details_are_reported() {
432        let queue = DeadLetterQueue::new();
433        let healthy = check_dlq(&queue);
434        assert_eq!(healthy.status, HealthStatus::Healthy);
435        assert_eq!(healthy.details.as_ref().expect("details").total, 0);
436
437        for index in 0..101 {
438            queue.enqueue(crate::core::a2a::dlq::DeadLetter {
439                id: index.to_string(),
440                original_message: "message".into(),
441                target_agent: "agent".into(),
442                error: "error".into(),
443                attempts: 1,
444                first_failed_at: "2026-01-01T00:00:00Z".into(),
445                last_failed_at: "2026-01-01T00:00:00Z".into(),
446            });
447        }
448        assert!(matches!(
449            check_dlq(&queue).status,
450            HealthStatus::Degraded(_)
451        ));
452
453        for index in 101..501 {
454            queue.enqueue(crate::core::a2a::dlq::DeadLetter {
455                id: index.to_string(),
456                original_message: "message".into(),
457                target_agent: "agent".into(),
458                error: "error".into(),
459                attempts: 1,
460                first_failed_at: "2026-01-01T00:00:00Z".into(),
461                last_failed_at: "2026-01-01T00:00:00Z".into(),
462            });
463        }
464        assert!(matches!(
465            check_dlq(&queue).status,
466            HealthStatus::Unhealthy(_)
467        ));
468    }
469}