use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use trusty_common::console_metrics::{ConsoleMetricsReport, ServiceHealth, make_report};
use super::spool::Spool;
pub const DEFAULT_RED_AFTER: Duration = Duration::from_secs(600);
pub const METRICS_SCHEMA_VERSION: u32 = 3;
pub const WEBHOOK_SERVICE_ID: &str = "trusty-console-webhooks";
const MAX_LISTED_EXHAUSTED: usize = 10;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpoolHealth {
pub status: ServiceHealth,
pub pending: usize,
pub exhausted: usize,
pub exhausted_delivery_ids: Vec<String>,
pub oldest_exhausted_age_secs: Option<u64>,
pub oldest_pending_age_secs: Option<u64>,
pub oldest_pending_delivery_id: Option<String>,
pub oldest_pending_last_error: Option<String>,
pub oldest_pending_attempts: Option<u32>,
pub red_after_secs: u64,
pub undecodable: Vec<String>,
pub scan_error: Option<String>,
pub undrained: Vec<UndrainedTarget>,
pub undrained_total: usize,
pub quarantined_total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UndrainedTarget {
pub source: String,
pub held: usize,
#[serde(default)]
pub quarantined: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub fn scan_health(
spool: &Spool,
now_unix_ms: u64,
red_after: Duration,
inbox_roots: &[(String, PathBuf)],
) -> SpoolHealth {
let red_after_secs = red_after.as_secs();
let undrained = scan_undrained(inbox_roots);
let undrained_total = undrained.iter().map(|u| u.held).sum::<usize>();
let quarantined_total = undrained.iter().map(|u| u.quarantined).sum::<usize>();
let age_of = |at: u64| now_unix_ms.saturating_sub(at).div_euclid(1000);
let census = match spool.scan_metadata() {
Ok(census) => census,
Err(e) => {
return SpoolHealth {
status: ServiceHealth::Error,
red_after_secs,
scan_error: Some(format!("{e}")),
undrained,
undrained_total,
quarantined_total,
..SpoolHealth::empty(red_after_secs)
};
}
};
let mut undecodable: Vec<String> = census
.unparsable
.iter()
.map(|(path, reason)| format!("{}: {reason}", path.display()))
.collect();
let oldest_live = census.live.first();
let (oldest_pending_last_error, oldest_pending_attempts) = match oldest_live {
Some(meta) => match spool.load(&meta.path) {
Ok(entry) => (entry.last_error, Some(entry.attempts)),
Err(e) => {
undecodable.push(format!("{}: {e}", meta.path.display()));
(None, None)
}
},
None => (None, None),
};
let oldest_pending_age_secs = oldest_live.map(|m| age_of(m.received_at_unix_ms));
let oldest_exhausted_age_secs = census
.exhausted
.first()
.map(|m| age_of(m.received_at_unix_ms));
let aged_out = oldest_pending_age_secs.is_some_and(|age| age >= red_after_secs);
let status = if !undecodable.is_empty()
|| !census.exhausted.is_empty()
|| aged_out
|| quarantined_total > 0
|| undrained.iter().any(|u| u.error.is_some())
{
ServiceHealth::Error
} else if census.live.is_empty() && undrained_total == 0 {
ServiceHealth::Ok
} else {
ServiceHealth::Degraded
};
SpoolHealth {
status,
pending: census.live.len(),
exhausted: census.exhausted.len(),
exhausted_delivery_ids: census
.exhausted
.iter()
.take(MAX_LISTED_EXHAUSTED)
.map(|m| m.delivery_id.clone())
.collect(),
oldest_exhausted_age_secs,
oldest_pending_age_secs,
oldest_pending_delivery_id: oldest_live.map(|m| m.delivery_id.clone()),
oldest_pending_last_error,
oldest_pending_attempts,
red_after_secs,
undecodable,
scan_error: None,
undrained,
undrained_total,
quarantined_total,
}
}
fn scan_undrained(inbox_roots: &[(String, PathBuf)]) -> Vec<UndrainedTarget> {
inbox_roots
.iter()
.map(|(source, root)| {
let held = trusty_common::webhook_relay::held_count(root);
let quarantined = trusty_common::webhook_relay::quarantined_count(root);
let error = held.as_ref().err().map(|e| format!("{e}")).or_else(|| {
quarantined
.as_ref()
.err()
.map(|e| format!("quarantine: {e}"))
});
UndrainedTarget {
source: source.clone(),
held: held.unwrap_or(0),
quarantined: quarantined.unwrap_or(0),
error,
}
})
.collect()
}
pub fn scan_failed(red_after: Duration, reason: String) -> SpoolHealth {
SpoolHealth {
status: ServiceHealth::Error,
scan_error: Some(reason),
..SpoolHealth::empty(red_after.as_secs())
}
}
impl SpoolHealth {
fn empty(red_after_secs: u64) -> Self {
Self {
status: ServiceHealth::Ok,
pending: 0,
exhausted: 0,
exhausted_delivery_ids: Vec::new(),
oldest_exhausted_age_secs: None,
oldest_pending_age_secs: None,
oldest_pending_delivery_id: None,
oldest_pending_last_error: None,
oldest_pending_attempts: None,
red_after_secs,
undecodable: Vec::new(),
scan_error: None,
undrained: Vec::new(),
undrained_total: 0,
quarantined_total: 0,
}
}
}
pub fn to_report(health: &SpoolHealth) -> ConsoleMetricsReport {
let metrics = serde_json::to_value(health).unwrap_or_else(|e| {
serde_json::json!({ "status": "error", "scan_error": format!("serialize health: {e}") })
});
make_report(
WEBHOOK_SERVICE_ID,
"Webhooks",
env!("CARGO_PKG_VERSION"),
health.status.clone(),
metrics,
METRICS_SCHEMA_VERSION,
)
}