1use 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
69pub 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}