use crate::event::Event;
#[cfg(test)]
use crate::test_utils::Utc;
use chrono::DateTime;
#[cfg(not(test))]
use chrono::Utc;
use serde::Serialize;
use std::collections::{HashMap, HashSet, VecDeque};
use std::net::IpAddr;
use std::time::Duration;
use thiserror::Error;
use tokio::sync::broadcast::{self, error::RecvError};
use tokio::{select, time};
use tokio_graceful_shutdown::{FutureExt, IntoSubsystem, SubsystemHandle};
use tracing::{debug, warn};
#[cfg(feature = "health-monitor-telegram")]
pub mod telegram;
#[derive(Debug, Serialize, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub enum ClientEventKind {
Connect,
Disconnect,
ConnectionLoss,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(Clone))]
pub struct ClientEvent {
pub kind: ClientEventKind,
pub conn_id: usize,
pub timestamp: DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize, Ord, PartialOrd, Eq, PartialEq, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub enum Health {
Healthy,
Unstable,
Offline,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(Clone))]
pub struct ClientStatus {
pub health: Health,
pub since: DateTime<chrono::Utc>,
pub local_ip: Option<IpAddr>,
pub events: VecDeque<ClientEvent>,
}
impl Default for ClientStatus {
fn default() -> Self {
Self {
health: Health::Offline,
since: Utc::now(),
local_ip: None,
events: VecDeque::new(),
}
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(test, derive(Clone))]
pub struct HealthReport {
pub clients: HashMap<String, ClientStatus>,
}
impl HealthReport {
fn new(client_ids: HashSet<String>) -> Self {
Self {
clients: client_ids
.into_iter()
.map(|client_id| (client_id, ClientStatus::default()))
.collect(),
}
}
pub fn as_text_report(&self) -> String {
let mut clients = self
.clients
.iter()
.collect::<Vec<(&String, &ClientStatus)>>();
clients.sort_by(|&a, &b| {
if a.1.health == b.1.health {
return a.0.cmp(b.0);
}
a.1.health.cmp(&b.1.health)
});
let now = Utc::now();
clients
.iter()
.map(|(client_id, status)| {
let mut text = String::new();
let local_ip = match status.local_ip {
Some(local_ip) => local_ip.to_string(),
None => "unknown".to_string(),
};
let (emoji, label) = match status.health {
Health::Healthy => ('🟢', "Healthy"),
Health::Unstable => ('🔴', "Unhealthy"),
Health::Offline => ('âš«', "Offline"),
};
let status_duration = (now - status.since).num_minutes();
text.push_str(&format!(
"{emoji} {client_id} ({local_ip}): {label} for {status_duration} minutes"
));
text
})
.collect::<Vec<String>>()
.join("\n")
}
}
pub trait HealthReportSender {
type Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static;
fn send(
&mut self,
report: &HealthReport,
silent: bool,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
}
#[derive(Debug, Error)]
pub enum BuilderError {
#[error("missing field: {0}")]
MissingField(&'static str),
#[error("invalid value: {0}")]
InvalidValue(&'static str),
}
#[derive(Debug, Default)]
pub struct HealthMonitorBuilder<T: HealthReportSender> {
sender: Option<T>,
event_rx: Option<broadcast::Receiver<Event>>,
client_ids: HashSet<String>,
max_client_events: usize,
connection_loss_limit: usize,
check_interval: Duration,
reminder_interval: Duration,
offline_grace_period: Duration,
connection_loss_window: Duration,
recovery_grace_period: Duration,
}
impl<T: HealthReportSender> HealthMonitorBuilder<T> {
pub fn new() -> Self {
Self {
sender: None,
event_rx: None,
client_ids: HashSet::new(),
max_client_events: 50,
connection_loss_limit: 3,
check_interval: Duration::from_secs(30),
reminder_interval: Duration::from_secs(1800),
offline_grace_period: Duration::from_secs(120),
connection_loss_window: Duration::from_secs(600),
recovery_grace_period: Duration::from_secs(1200),
}
}
pub fn sender(mut self, sender: T) -> Self {
self.sender = Some(sender);
self
}
pub fn event_rx(mut self, event_rx: broadcast::Receiver<Event>) -> Self {
self.event_rx = Some(event_rx);
self
}
pub fn client_ids(mut self, client_ids: HashSet<String>) -> Self {
self.client_ids = client_ids;
self
}
pub fn max_client_events(mut self, max_client_events: usize) -> Self {
self.max_client_events = max_client_events;
self
}
pub fn connection_loss_limit(mut self, connection_loss_limit: usize) -> Self {
self.connection_loss_limit = connection_loss_limit;
self
}
pub fn check_interval(mut self, check_interval: Duration) -> Self {
self.check_interval = check_interval;
self
}
pub fn reminder_interval(mut self, reminder_interval: Duration) -> Self {
self.reminder_interval = reminder_interval;
self
}
pub fn offline_grace_period(mut self, offline_grace_period: Duration) -> Self {
self.offline_grace_period = offline_grace_period;
self
}
pub fn connection_loss_window(mut self, connection_loss_window: Duration) -> Self {
self.connection_loss_window = connection_loss_window;
self
}
pub fn recovery_grace_period(mut self, recovery_grace_period: Duration) -> Self {
self.recovery_grace_period = recovery_grace_period;
self
}
pub fn build(self) -> Result<HealthMonitor<T>, BuilderError> {
let sender = self.sender.ok_or(BuilderError::MissingField("sender"))?;
let event_rx = self.event_rx.ok_or(BuilderError::MissingField("event_r"))?;
if self.max_client_events < 2 {
return Err(BuilderError::InvalidValue(
"max_client_events must be at least 2",
));
}
if self.connection_loss_limit < 1 {
return Err(BuilderError::InvalidValue(
"connection_loss_limit must be at least 1",
));
}
Ok(HealthMonitor {
sender,
event_rx,
report: HealthReport::new(self.client_ids),
max_client_events: self.max_client_events,
connection_loss_limit: self.connection_loss_limit,
check_interval: self.check_interval,
reminder_interval: self.reminder_interval,
offline_grace_period: self.offline_grace_period,
connection_loss_window: self.connection_loss_window,
recovery_grace_period: self.recovery_grace_period,
#[cfg(test)]
test_notify_event_processed: std::sync::Arc::new(tokio::sync::Notify::new()),
})
}
}
#[derive(Debug)]
pub struct HealthMonitor<T: HealthReportSender> {
sender: T,
event_rx: broadcast::Receiver<Event>,
report: HealthReport,
max_client_events: usize,
connection_loss_limit: usize,
check_interval: Duration,
reminder_interval: Duration,
offline_grace_period: Duration,
connection_loss_window: Duration,
recovery_grace_period: Duration,
#[cfg(test)]
pub test_notify_event_processed: std::sync::Arc<tokio::sync::Notify>,
}
impl<T: HealthReportSender> HealthMonitor<T> {
pub async fn listen(&mut self) {
let _ = self.sender.send(&self.report, false).await;
let mut check_interval = time::interval(self.check_interval);
let mut reminder_interval = time::interval(self.reminder_interval);
check_interval.tick().await;
reminder_interval.tick().await;
loop {
let should_continue = select! {
_ = check_interval.tick() => {
self.check_clients().await;
true
}
_ = reminder_interval.tick() => {
self.send_reminder().await;
true
}
result = self.event_rx.recv() => {
self.handle_event_recv(result).await
}
};
if !should_continue {
break;
}
}
}
async fn check_clients(&mut self) {
let now = Utc::now();
let offline_cutoff = now - self.offline_grace_period;
let connection_loss_cutoff = now - self.connection_loss_window;
let recovery_cutoff = now - self.recovery_grace_period;
let mut silent_updates: usize = 0;
let mut alert_updates: usize = 0;
for client in self.report.clients.values_mut() {
if matches!(client.health, Health::Offline) {
continue;
}
let mut recent_events: Vec<_> = client.events.iter().take(2).collect();
recent_events.sort_by_key(|event| std::cmp::Reverse(event.conn_id));
let Some(latest_event) = recent_events.first() else {
continue;
};
match (client.health, latest_event.kind) {
(Health::Healthy | Health::Unstable, ClientEventKind::ConnectionLoss)
if latest_event.timestamp <= offline_cutoff =>
{
client.health = Health::Offline;
client.since = now;
alert_updates += 1;
}
(Health::Unstable, ClientEventKind::Connect)
if latest_event.timestamp <= recovery_cutoff =>
{
client.health = Health::Healthy;
client.since = now;
silent_updates += 1;
}
(Health::Healthy, _) => {
let mut connection_loss_count: usize = 0;
for event in client.events.iter() {
if event.timestamp < connection_loss_cutoff {
break;
}
if matches!(event.kind, ClientEventKind::ConnectionLoss) {
connection_loss_count += 1;
}
}
if connection_loss_count >= self.connection_loss_limit {
client.health = Health::Unstable;
client.since = now;
alert_updates += 1;
}
}
(_, _) => {}
}
}
if alert_updates > 0 {
self.send_report(false).await;
} else if silent_updates > 0 {
self.send_report(true).await;
}
}
async fn send_reminder(&mut self) {
if self
.report
.clients
.values()
.any(|client| !matches!(client.health, Health::Healthy))
{
self.send_report(false).await;
}
}
async fn handle_event_recv(&mut self, result: Result<Event, RecvError>) -> bool {
match result {
Ok(Event::ClientConnect {
client_id,
local_ip,
conn_id,
}) => {
let client = self.report.clients.entry(client_id).or_default();
client.local_ip = Some(local_ip);
let mut should_send_report = false;
if matches!(client.health, Health::Offline) {
client.health = Health::Healthy;
client.since = Utc::now();
should_send_report = true;
}
client.events.push_front(ClientEvent {
kind: ClientEventKind::Connect,
conn_id,
timestamp: Utc::now(),
});
println!("connect");
if client.events.len() > self.max_client_events {
client.events.pop_back();
}
if should_send_report {
self.send_report(true).await;
}
#[cfg(test)]
self.test_notify_event_processed.notify_one();
true
}
Ok(Event::ClientDisconnect { client_id, conn_id }) => {
let client = self.report.clients.entry(client_id).or_default();
client.health = Health::Offline;
client.since = Utc::now();
client.events.push_front(ClientEvent {
kind: ClientEventKind::Disconnect,
conn_id,
timestamp: Utc::now(),
});
if client.events.len() > self.max_client_events {
client.events.pop_back();
}
#[cfg(test)]
self.test_notify_event_processed.notify_one();
self.send_report(true).await;
true
}
Ok(Event::ClientConnectionLoss { client_id, conn_id }) => {
let client = self.report.clients.entry(client_id).or_default();
client.events.push_front(ClientEvent {
kind: ClientEventKind::ConnectionLoss,
conn_id,
timestamp: Utc::now(),
});
if client.events.len() > self.max_client_events {
client.events.pop_back();
}
#[cfg(test)]
self.test_notify_event_processed.notify_one();
true
}
Ok(_) => true,
Err(RecvError::Lagged(n)) => {
warn!("HealthMonitor lagged by {n} messages, some events were missed");
true
}
Err(RecvError::Closed) => {
debug!("HealthMonitor event stream closed, exiting event loop");
false
}
}
}
async fn send_report(&mut self, silent: bool) {
if let Err(err) = self.sender.send(&self.report, silent).await {
warn!("Failed to send health report: {:?}", err);
}
}
}
#[cfg(feature = "tokio-graceful-shutdown")]
#[derive(Debug, Error)]
pub enum NeverError {}
#[cfg(feature = "tokio-graceful-shutdown")]
impl<T: HealthReportSender + Send + Sync + 'static> IntoSubsystem<NeverError> for HealthMonitor<T> {
async fn run(mut self, subsys: &mut SubsystemHandle) -> Result<(), NeverError> {
let _ = self.listen().cancel_on_shutdown(subsys).await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Event;
use ntest::timeout;
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::broadcast;
use tokio::time::advance;
#[derive(Default, Clone)]
struct MockSender {
last: Arc<Mutex<Option<(HealthReport, bool)>>>,
}
impl HealthReportSender for MockSender {
type Error = Infallible;
async fn send(&mut self, report: &HealthReport, silent: bool) -> Result<(), Self::Error> {
let mut last = self.last.lock().unwrap();
*last = Some((report.clone(), silent));
Ok(())
}
}
async fn wait_for_report(sender: &MockSender) -> (HealthReport, bool) {
loop {
if let Some(report) = sender.last.lock().unwrap().take() {
return report;
}
tokio::task::yield_now().await;
}
}
#[tokio::test(start_paused = true)]
#[timeout(1000)]
async fn sends_initial_report_on_start() {
let (_tx, rx) = broadcast::channel(16);
let sender = MockSender::default();
let mut monitor = HealthMonitorBuilder::new()
.sender(sender.clone())
.event_rx(rx)
.client_ids(Default::default())
.build()
.unwrap();
let handle = tokio::spawn(async move {
monitor.listen().await;
});
let (_, silent) = wait_for_report(&sender).await;
assert!(!silent, "Initial report should not be silent");
drop(handle);
}
#[tokio::test(start_paused = true)]
#[timeout(1000)]
async fn client_connect_sets_healthy() {
let (tx, rx) = broadcast::channel(16);
let sender = MockSender::default();
let mut monitor = HealthMonitorBuilder::new()
.sender(sender.clone())
.event_rx(rx)
.client_ids(["client1".to_string()].into_iter().collect())
.build()
.unwrap();
let notify = monitor.test_notify_event_processed.clone();
let handle = tokio::spawn(async move {
monitor.listen().await;
});
wait_for_report(&sender).await;
tx.send(Event::ClientConnect {
client_id: "client1".to_string(),
local_ip: "127.0.0.1".parse().unwrap(),
conn_id: 1,
})
.unwrap();
notify.notified().await;
let (report, _) = wait_for_report(&sender).await;
assert_eq!(report.clients["client1"].health, Health::Healthy);
drop(tx);
handle.await.unwrap();
}
#[tokio::test(start_paused = true)]
#[timeout(1000)]
async fn client_disconnect_sets_offline_immediately() {
let (tx, rx) = broadcast::channel(16);
let sender = MockSender::default();
let mut monitor = HealthMonitorBuilder::new()
.sender(sender.clone())
.event_rx(rx)
.client_ids(["client1".to_string()].into_iter().collect())
.build()
.unwrap();
let notify = monitor.test_notify_event_processed.clone();
let handle = tokio::spawn(async move {
monitor.listen().await;
});
wait_for_report(&sender).await;
tx.send(Event::ClientConnect {
client_id: "client1".to_string(),
local_ip: "127.0.0.1".parse().unwrap(),
conn_id: 1,
})
.unwrap();
notify.notified().await;
wait_for_report(&sender).await;
tx.send(Event::ClientDisconnect {
client_id: "client1".to_string(),
conn_id: 1,
})
.unwrap();
notify.notified().await;
let (report, _) = wait_for_report(&sender).await;
assert_eq!(report.clients["client1"].health, Health::Offline);
drop(tx);
handle.await.unwrap();
}
#[tokio::test(start_paused = true)]
#[timeout(1000)]
async fn client_connection_loss_triggers_unstable_then_offline() {
let (tx, rx) = broadcast::channel(16);
let sender = MockSender::default();
let mut monitor = HealthMonitorBuilder::new()
.sender(sender.clone())
.event_rx(rx)
.client_ids(["client1".to_string()].into_iter().collect())
.build()
.unwrap();
let notify = monitor.test_notify_event_processed.clone();
let handle = tokio::spawn(async move {
monitor.listen().await;
});
wait_for_report(&sender).await;
tx.send(Event::ClientConnect {
client_id: "client1".to_string(),
local_ip: "127.0.0.1".parse().unwrap(),
conn_id: 1,
})
.unwrap();
notify.notified().await;
wait_for_report(&sender).await;
for i in 1..=3 {
tx.send(Event::ClientConnectionLoss {
client_id: "client1".to_string(),
conn_id: i,
})
.unwrap();
notify.notified().await;
}
advance(Duration::from_secs(30)).await;
let (report, _) = wait_for_report(&sender).await;
assert_eq!(report.clients["client1"].health, Health::Unstable);
advance(Duration::from_secs(120)).await;
let (report, _) = wait_for_report(&sender).await;
assert_eq!(report.clients["client1"].health, Health::Offline);
drop(tx);
handle.await.unwrap();
}
#[tokio::test(start_paused = true)]
#[timeout(1000)]
async fn client_recovers_from_unstable_after_recovery_grace_period() {
let (tx, rx) = broadcast::channel(16);
let sender = MockSender::default();
let mut monitor = HealthMonitorBuilder::new()
.sender(sender.clone())
.event_rx(rx)
.client_ids(["client1".to_string()].into_iter().collect())
.connection_loss_limit(1)
.build()
.unwrap();
let notify = monitor.test_notify_event_processed.clone();
let handle = tokio::spawn(async move {
monitor.listen().await;
});
wait_for_report(&sender).await;
tx.send(Event::ClientConnect {
client_id: "client1".to_string(),
local_ip: "127.0.0.1".parse().unwrap(),
conn_id: 1,
})
.unwrap();
notify.notified().await;
wait_for_report(&sender).await;
tx.send(Event::ClientConnectionLoss {
client_id: "client1".to_string(),
conn_id: 1,
})
.unwrap();
notify.notified().await;
advance(Duration::from_secs(30)).await;
let (report, _) = wait_for_report(&sender).await;
assert_eq!(report.clients["client1"].health, Health::Unstable);
tx.send(Event::ClientConnect {
client_id: "client1".to_string(),
local_ip: "127.0.0.1".parse().unwrap(),
conn_id: 2,
})
.unwrap();
notify.notified().await;
advance(Duration::from_secs(1199)).await;
assert!(sender.last.lock().unwrap().is_none());
advance(Duration::from_secs(1)).await;
let (report, _) = wait_for_report(&sender).await;
assert_eq!(report.clients["client1"].health, Health::Healthy);
drop(tx);
handle.await.unwrap();
}
#[tokio::test(start_paused = true)]
#[timeout(1000)]
async fn sends_reminder_reports_if_any_unhealthy() {
let (tx, rx) = broadcast::channel(16);
let sender = MockSender::default();
let mut monitor = HealthMonitorBuilder::new()
.sender(sender.clone())
.event_rx(rx)
.client_ids(["client1".to_string()].into_iter().collect())
.connection_loss_limit(1)
.build()
.unwrap();
let notify = monitor.test_notify_event_processed.clone();
let handle = tokio::spawn(async move {
monitor.listen().await;
});
wait_for_report(&sender).await;
tx.send(Event::ClientConnect {
client_id: "client1".to_string(),
local_ip: "127.0.0.1".parse().unwrap(),
conn_id: 1,
})
.unwrap();
notify.notified().await;
wait_for_report(&sender).await;
tx.send(Event::ClientConnectionLoss {
client_id: "client1".to_string(),
conn_id: 1,
})
.unwrap();
notify.notified().await;
advance(Duration::from_secs(30)).await;
let (report, _) = wait_for_report(&sender).await;
assert_eq!(report.clients["client1"].health, Health::Unstable);
advance(Duration::from_secs(1800)).await;
let (report, _) = wait_for_report(&sender).await;
assert_ne!(report.clients["client1"].health, Health::Healthy);
drop(tx);
handle.await.unwrap();
}
#[tokio::test(start_paused = true)]
#[timeout(1000)]
async fn client_goes_offline_after_grace_period() {
let (tx, rx) = broadcast::channel(16);
let sender = MockSender::default();
let mut monitor = HealthMonitorBuilder::new()
.sender(sender.clone())
.event_rx(rx)
.client_ids(["client1".to_string()].into_iter().collect())
.build()
.unwrap();
let notify = monitor.test_notify_event_processed.clone();
let handle = tokio::spawn(async move {
monitor.listen().await;
});
wait_for_report(&sender).await;
tx.send(Event::ClientConnect {
client_id: "client1".to_string(),
local_ip: "127.0.0.1".parse().unwrap(),
conn_id: 1,
})
.unwrap();
notify.notified().await;
let report = wait_for_report(&sender).await;
assert_eq!(report.0.clients["client1"].health, Health::Healthy);
tx.send(Event::ClientConnectionLoss {
client_id: "client1".to_string(),
conn_id: 1,
})
.unwrap();
notify.notified().await;
advance(Duration::from_secs(120)).await;
let report = wait_for_report(&sender).await;
assert_eq!(report.0.clients["client1"].health, Health::Offline);
drop(tx);
handle.await.unwrap();
}
}