//! Background health and SSH-banner check tasks, including the transition gate, //! incident bookkeeping, and latency-drift alerting. use tokio::task::JoinHandle; use tracing::info; use pom::alerts::Alerter; use pom::checks::{drift, http, ssh_banner}; use pom::config::Config; use pom::db; use pom::types::{HealthStatus, LatencyStats}; use super::super::incident::{IncidentAction, incident_action}; use super::super::transition::TransitionGate; use super::{CheckInterval, configured_targets}; pub(crate) fn spawn_health_tasks( config: &Config, pool: &sqlx::SqlitePool, cancel: &tokio_util::sync::CancellationToken, alerter: Option<&Alerter>, ) -> Vec> { let default_interval = config.serve.interval_secs; let confirmations = config.serve.confirmations; let mut handles = Vec::new(); for (name, target_config) in configured_targets(config) { if let Some(health_config) = target_config.health { let interval_secs = health_config.interval_secs.unwrap_or(default_interval); let pool = pool.clone(); let name = name.clone(); let label = target_config.label.clone(); let alerter = alerter.cloned(); let cancel = cancel.clone(); let trending_config = health_config.trending.clone(); info!("{name}: health check every {interval_secs}s"); handles.push(tokio::spawn(async move { let mut ticks = CheckInterval::new(interval_secs, cancel); let expect = health_config.expect.as_ref(); let mut in_drift = false; // Seed the N-of-M transition gate from the last stored status so a // restart of a persistently-degraded target doesn't re-fire, and a // transient blip is debounced before it pages/opens an incident. let seed = db::get_latest_health(&pool, &name) .await .ok() .flatten() .map(|s| s.status); let mut gate = TransitionGate::seeded(seed, confirmations); // RECONCILE THE INCIDENT LEDGER AGAINST THE SEED, before the // first tick. // // Seeding is what stops a restart re-firing a transition, and it // is also how a restart can LOSE one. An incident closes on a // confirmed transition back to Operational; if the process // restarts across that recovery, the gate seeds Operational, // observes Operational, reports no transition, and the incident // stays open forever with nothing to reopen the question. // // Measured on astra 2026-08-23: incident 307, `mt`, opened // 2026-08-16 16:57 with `ended_at` still NULL a week later. Two // sibling incidents opened in the same minute by the same blip // (`mnw` 308, `htpy` 309) both closed an hour later, and mt's // health had been Operational at 100% uptime throughout. The // instance had read `failed` ever since, which is a monitor // reporting its own bookkeeping as an outage. // // Idempotent and once per target per start, so it costs one // query at boot and nothing afterwards. Deliberately does NOT // alert: this closes a stale record, and a recovery notice for // an outage that ended a week ago is noise. if seed == Some(HealthStatus::Operational) { match db::close_open_incidents(&pool, &name).await { Ok(0) => {} Ok(n) => info!( "{name}: closed {n} incident(s) left open across a restart; health reads operational" ), Err(e) => { tracing::error!("{name}: failed to reconcile open incidents: {e}"); } } } while ticks.next().await { let snapshot = http::check_health(&name, &health_config, expect).await; info!( "{}: {} ({}ms)", name, snapshot.status, snapshot.response_time_ms ); if let Err(e) = db::insert_health_check(&pool, &snapshot).await { tracing::error!("{name}: failed to store health check: {e}"); } // Alert AND open/close incidents only on a *confirmed* // transition (debounced + restart-seeded above). if let Some((from_status, to_status)) = gate.observe(snapshot.status) { if let Some(ref alerter) = alerter { let from = from_status.to_string(); let to = to_status.to_string(); if to_status == HealthStatus::Operational { alerter.send_health_recovery(&name, &label, &from).await; } else { alerter .send_health_alert( &name, &label, &from, &to, snapshot.error.as_deref(), ) .await; } } match incident_action(from_status, to_status) { IncidentAction::None => {} IncidentAction::Open => { if let Err(e) = db::insert_incident( &pool, &name, &from_status.to_string(), &to_status.to_string(), ) .await { tracing::error!("{name}: failed to open incident: {e}"); } } IncidentAction::Close => { if let Err(e) = db::close_open_incidents(&pool, &name).await { tracing::error!("{name}: failed to close incidents: {e}"); } } IncidentAction::CloseAndOpen => { if let Err(e) = db::close_and_open_incident( &pool, &name, &from_status.to_string(), &to_status.to_string(), ) .await { tracing::error!("{name}: failed to close+open incident: {e}"); } } } } // Latency drift detection if let Some(ref trending) = trending_config && snapshot.status == HealthStatus::Operational { let baseline_cutoff = (chrono::Utc::now() - chrono::Duration::hours(trending.baseline_window_hours as i64)) .to_rfc3339(); let baseline_data = db::get_response_times(&pool, &name, &baseline_cutoff) .await .unwrap_or_default(); let operational_times: Vec = baseline_data .iter() .filter(|(_, ms)| *ms > 0) .map(|(_, ms)| *ms) .collect(); let baseline = LatencyStats::from_times(&operational_times); let recent = db::get_recent_response_times(&pool, &name, 3) .await .unwrap_or_default(); if let Some(ref bl) = baseline { if let Some(msg) = drift::detect_latency_drift(&recent, bl, trending.spike_threshold) { if !in_drift { info!("{name}: {msg}"); if let Some(ref alerter) = alerter { alerter.send_latency_drift_alert(&name, &label, &msg).await; } in_drift = true; } } else if in_drift { info!("{name}: latency drift recovered"); if let Some(ref alerter) = alerter { alerter.send_latency_recovery(&name, &label).await; } in_drift = false; } } } } })); } } // SSH banner checks, stored as health check entries with target name "{name}:ssh" for (name, target_config) in configured_targets(config) { if let Some(ssh_config) = target_config.ssh_banner { let interval_secs = default_interval; let pool = pool.clone(); let check_name = format!("{name}:ssh"); let label = target_config.label.clone(); let alerter = alerter.cloned(); let cancel = cancel.clone(); info!("{check_name}: SSH banner check every {interval_secs}s"); handles.push(tokio::spawn(async move { let mut ticks = CheckInterval::new(interval_secs, cancel); let seed = db::get_latest_health(&pool, &check_name) .await .ok() .flatten() .map(|s| s.status); let mut gate = TransitionGate::seeded(seed, confirmations); while ticks.next().await { let snapshot = ssh_banner::check_ssh_banner(&check_name, &ssh_config).await; info!( "{}: {} ({}ms)", check_name, snapshot.status, snapshot.response_time_ms ); if let Err(e) = db::insert_health_check(&pool, &snapshot).await { tracing::error!("{check_name}: failed to store SSH banner check: {e}"); } if let Some((from_status, to_status)) = gate.observe(snapshot.status) && let Some(ref alerter) = alerter { let from = from_status.to_string(); let to = to_status.to_string(); if to_status == HealthStatus::Operational { alerter .send_health_recovery(&check_name, &label, &from) .await; } else { alerter .send_health_alert( &check_name, &label, &from, &to, snapshot.error.as_deref(), ) .await; } } } })); } } handles }