#![allow(dead_code)]
use std::collections::VecDeque;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum HealthGrade {
Critical,
Poor,
Fair,
Good,
Excellent,
}
impl HealthGrade {
#[must_use]
pub fn from_score(score: f64) -> Self {
if score >= 95.0 {
Self::Excellent
} else if score >= 75.0 {
Self::Good
} else if score >= 50.0 {
Self::Fair
} else if score >= 25.0 {
Self::Poor
} else {
Self::Critical
}
}
}
impl std::fmt::Display for HealthGrade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Critical => write!(f, "CRITICAL"),
Self::Poor => write!(f, "POOR"),
Self::Fair => write!(f, "FAIR"),
Self::Good => write!(f, "GOOD"),
Self::Excellent => write!(f, "EXCELLENT"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AlertSeverity {
Info,
Warning,
Error,
Critical,
}
#[derive(Debug, Clone)]
pub struct HealthAlert {
pub severity: AlertSeverity,
pub message: String,
pub sample_index: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct HealthSample {
pub index: usize,
pub packet_loss: f64,
pub rtt_ms: f64,
pub jitter_ms: f64,
pub bitrate_bps: f64,
pub fec_recoveries: u32,
pub unrecoverable_errors: u32,
}
#[derive(Debug, Clone)]
pub struct HealthConfig {
pub window_size: usize,
pub loss_warn_threshold: f64,
pub loss_error_threshold: f64,
pub rtt_warn_ms: f64,
pub rtt_error_ms: f64,
pub jitter_warn_ms: f64,
pub nominal_bitrate_bps: f64,
pub bitrate_deviation_ratio: f64,
}
impl Default for HealthConfig {
fn default() -> Self {
Self {
window_size: 30,
loss_warn_threshold: 0.001,
loss_error_threshold: 0.01,
rtt_warn_ms: 50.0,
rtt_error_ms: 200.0,
jitter_warn_ms: 10.0,
nominal_bitrate_bps: 0.0,
bitrate_deviation_ratio: 0.2,
}
}
}
#[derive(Debug, Clone)]
pub struct HealthSnapshot {
pub sample_count: usize,
pub score: f64,
pub grade: HealthGrade,
pub avg_loss: f64,
pub avg_rtt_ms: f64,
pub avg_jitter_ms: f64,
pub avg_bitrate_bps: f64,
pub peak_loss: f64,
pub peak_rtt_ms: f64,
pub total_fec_recoveries: u32,
pub total_unrecoverable: u32,
}
#[derive(Debug)]
pub struct StreamHealthMonitor {
config: HealthConfig,
window: VecDeque<HealthSample>,
alerts: Vec<HealthAlert>,
total_samples: usize,
}
impl StreamHealthMonitor {
#[must_use]
pub fn new(config: HealthConfig) -> Self {
Self {
window: VecDeque::with_capacity(config.window_size),
config,
alerts: Vec::new(),
total_samples: 0,
}
}
pub fn push_sample(&mut self, sample: HealthSample) {
if self.window.len() == self.config.window_size {
self.window.pop_front();
}
self.window.push_back(sample);
self.total_samples += 1;
self.check_alerts(&sample);
}
pub fn record(
&mut self,
packet_loss: f64,
rtt_ms: f64,
jitter_ms: f64,
bitrate_bps: f64,
fec_recoveries: u32,
unrecoverable: u32,
) {
let sample = HealthSample {
index: self.total_samples,
packet_loss,
rtt_ms,
jitter_ms,
bitrate_bps,
fec_recoveries,
unrecoverable_errors: unrecoverable,
};
self.push_sample(sample);
}
pub fn snapshot(&self) -> HealthSnapshot {
let n = self.window.len();
if n == 0 {
return HealthSnapshot {
sample_count: 0,
score: 100.0,
grade: HealthGrade::Excellent,
avg_loss: 0.0,
avg_rtt_ms: 0.0,
avg_jitter_ms: 0.0,
avg_bitrate_bps: 0.0,
peak_loss: 0.0,
peak_rtt_ms: 0.0,
total_fec_recoveries: 0,
total_unrecoverable: 0,
};
}
#[allow(clippy::cast_precision_loss)]
let nf = n as f64;
let avg_loss = self.window.iter().map(|s| s.packet_loss).sum::<f64>() / nf;
let avg_rtt = self.window.iter().map(|s| s.rtt_ms).sum::<f64>() / nf;
let avg_jitter = self.window.iter().map(|s| s.jitter_ms).sum::<f64>() / nf;
let avg_bitrate = self.window.iter().map(|s| s.bitrate_bps).sum::<f64>() / nf;
let peak_loss = self
.window
.iter()
.map(|s| s.packet_loss)
.fold(0.0_f64, f64::max);
let peak_rtt = self.window.iter().map(|s| s.rtt_ms).fold(0.0_f64, f64::max);
let total_fec: u32 = self.window.iter().map(|s| s.fec_recoveries).sum();
let total_unrec: u32 = self.window.iter().map(|s| s.unrecoverable_errors).sum();
let score = self.compute_score(avg_loss, avg_rtt, avg_jitter, avg_bitrate);
let grade = HealthGrade::from_score(score);
HealthSnapshot {
sample_count: n,
score,
grade,
avg_loss,
avg_rtt_ms: avg_rtt,
avg_jitter_ms: avg_jitter,
avg_bitrate_bps: avg_bitrate,
peak_loss,
peak_rtt_ms: peak_rtt,
total_fec_recoveries: total_fec,
total_unrecoverable: total_unrec,
}
}
#[must_use]
pub fn alerts(&self) -> &[HealthAlert] {
&self.alerts
}
#[must_use]
pub fn total_samples(&self) -> usize {
self.total_samples
}
#[must_use]
pub fn estimated_uptime_ratio(&self) -> f64 {
if self.window.is_empty() {
return 1.0;
}
#[allow(clippy::cast_precision_loss)]
let good = self
.window
.iter()
.filter(|s| {
s.packet_loss < self.config.loss_error_threshold && s.unrecoverable_errors == 0
})
.count() as f64;
#[allow(clippy::cast_precision_loss)]
let total = self.window.len() as f64;
good / total
}
fn compute_score(&self, loss: f64, rtt: f64, jitter: f64, bitrate: f64) -> f64 {
let loss_score = (1.0 - loss * 100.0).max(0.0) * 40.0;
let rtt_score = (1.0 - (rtt / self.config.rtt_error_ms).min(1.0)) * 25.0;
let jitter_score = (1.0 - (jitter / (self.config.jitter_warn_ms * 5.0)).min(1.0)) * 20.0;
let bitrate_score = if self.config.nominal_bitrate_bps > 0.0 {
let dev = ((bitrate - self.config.nominal_bitrate_bps)
/ self.config.nominal_bitrate_bps)
.abs();
(1.0 - (dev / self.config.bitrate_deviation_ratio).min(1.0)) * 15.0
} else {
15.0 };
(loss_score + rtt_score + jitter_score + bitrate_score).clamp(0.0, 100.0)
}
fn check_alerts(&mut self, sample: &HealthSample) {
if sample.packet_loss >= self.config.loss_error_threshold {
self.alerts.push(HealthAlert {
severity: AlertSeverity::Error,
message: format!(
"High packet loss: {:.3}% (threshold {:.3}%)",
sample.packet_loss * 100.0,
self.config.loss_error_threshold * 100.0,
),
sample_index: sample.index,
});
} else if sample.packet_loss >= self.config.loss_warn_threshold {
self.alerts.push(HealthAlert {
severity: AlertSeverity::Warning,
message: format!("Elevated packet loss: {:.3}%", sample.packet_loss * 100.0),
sample_index: sample.index,
});
}
if sample.rtt_ms >= self.config.rtt_error_ms {
self.alerts.push(HealthAlert {
severity: AlertSeverity::Error,
message: format!("High RTT: {:.1} ms", sample.rtt_ms),
sample_index: sample.index,
});
} else if sample.rtt_ms >= self.config.rtt_warn_ms {
self.alerts.push(HealthAlert {
severity: AlertSeverity::Warning,
message: format!("Elevated RTT: {:.1} ms", sample.rtt_ms),
sample_index: sample.index,
});
}
if sample.unrecoverable_errors > 0 {
self.alerts.push(HealthAlert {
severity: AlertSeverity::Critical,
message: format!(
"{} unrecoverable packet errors",
sample.unrecoverable_errors,
),
sample_index: sample.index,
});
}
}
}
#[must_use]
pub fn meets_sla(
snapshot: &HealthSnapshot,
max_loss: f64,
max_rtt: Duration,
min_score: f64,
) -> bool {
snapshot.avg_loss <= max_loss
&& snapshot.avg_rtt_ms <= max_rtt.as_millis() as f64
&& snapshot.score >= min_score
}
#[cfg(test)]
mod tests {
use super::*;
fn healthy_sample(idx: usize) -> HealthSample {
HealthSample {
index: idx,
packet_loss: 0.0,
rtt_ms: 5.0,
jitter_ms: 0.5,
bitrate_bps: 10_000_000.0,
fec_recoveries: 0,
unrecoverable_errors: 0,
}
}
fn lossy_sample(idx: usize) -> HealthSample {
HealthSample {
index: idx,
packet_loss: 0.05,
rtt_ms: 250.0,
jitter_ms: 30.0,
bitrate_bps: 5_000_000.0,
fec_recoveries: 10,
unrecoverable_errors: 3,
}
}
#[test]
fn test_health_grade_from_score() {
assert_eq!(HealthGrade::from_score(100.0), HealthGrade::Excellent);
assert_eq!(HealthGrade::from_score(95.0), HealthGrade::Excellent);
assert_eq!(HealthGrade::from_score(80.0), HealthGrade::Good);
assert_eq!(HealthGrade::from_score(60.0), HealthGrade::Fair);
assert_eq!(HealthGrade::from_score(30.0), HealthGrade::Poor);
assert_eq!(HealthGrade::from_score(10.0), HealthGrade::Critical);
}
#[test]
fn test_empty_monitor_snapshot() {
let m = StreamHealthMonitor::new(HealthConfig::default());
let snap = m.snapshot();
assert_eq!(snap.sample_count, 0);
assert!((snap.score - 100.0).abs() < 0.01);
assert_eq!(snap.grade, HealthGrade::Excellent);
}
#[test]
fn test_healthy_stream() {
let mut m = StreamHealthMonitor::new(HealthConfig::default());
for i in 0..10 {
m.push_sample(healthy_sample(i));
}
let snap = m.snapshot();
assert!(snap.score >= 90.0);
assert!(snap.grade >= HealthGrade::Good);
assert!(snap.avg_loss < 0.001);
}
#[test]
fn test_degraded_stream() {
let mut m = StreamHealthMonitor::new(HealthConfig::default());
for i in 0..10 {
m.push_sample(lossy_sample(i));
}
let snap = m.snapshot();
assert!(snap.score < 50.0);
assert!(snap.avg_loss > 0.01);
}
#[test]
fn test_alerts_on_loss() {
let mut m = StreamHealthMonitor::new(HealthConfig::default());
m.push_sample(lossy_sample(0));
assert!(!m.alerts().is_empty());
assert!(m
.alerts()
.iter()
.any(|a| a.severity == AlertSeverity::Error));
}
#[test]
fn test_alerts_on_unrecoverable() {
let mut m = StreamHealthMonitor::new(HealthConfig::default());
m.push_sample(lossy_sample(0));
assert!(m
.alerts()
.iter()
.any(|a| a.severity == AlertSeverity::Critical));
}
#[test]
fn test_no_alerts_healthy() {
let mut m = StreamHealthMonitor::new(HealthConfig::default());
m.push_sample(healthy_sample(0));
assert!(m.alerts().is_empty());
}
#[test]
fn test_window_eviction() {
let cfg = HealthConfig {
window_size: 5,
..Default::default()
};
let mut m = StreamHealthMonitor::new(cfg);
for i in 0..20 {
m.push_sample(healthy_sample(i));
}
let snap = m.snapshot();
assert_eq!(snap.sample_count, 5);
assert_eq!(m.total_samples(), 20);
}
#[test]
fn test_record_convenience() {
let mut m = StreamHealthMonitor::new(HealthConfig::default());
m.record(0.0, 5.0, 1.0, 10_000_000.0, 0, 0);
assert_eq!(m.total_samples(), 1);
let snap = m.snapshot();
assert!(snap.score >= 90.0);
}
#[test]
fn test_estimated_uptime() {
let mut m = StreamHealthMonitor::new(HealthConfig::default());
for i in 0..8 {
m.push_sample(healthy_sample(i));
}
m.push_sample(lossy_sample(8));
m.push_sample(lossy_sample(9));
let uptime = m.estimated_uptime_ratio();
assert!(uptime > 0.5 && uptime < 1.0);
}
#[test]
fn test_meets_sla_pass() {
let mut m = StreamHealthMonitor::new(HealthConfig::default());
for i in 0..10 {
m.push_sample(healthy_sample(i));
}
let snap = m.snapshot();
assert!(meets_sla(&snap, 0.001, Duration::from_millis(100), 80.0));
}
#[test]
fn test_meets_sla_fail() {
let mut m = StreamHealthMonitor::new(HealthConfig::default());
for i in 0..10 {
m.push_sample(lossy_sample(i));
}
let snap = m.snapshot();
assert!(!meets_sla(&snap, 0.001, Duration::from_millis(100), 80.0));
}
#[test]
fn test_health_grade_display() {
assert_eq!(HealthGrade::Critical.to_string(), "CRITICAL");
assert_eq!(HealthGrade::Good.to_string(), "GOOD");
assert_eq!(HealthGrade::Excellent.to_string(), "EXCELLENT");
}
#[test]
fn test_peak_values() {
let mut m = StreamHealthMonitor::new(HealthConfig::default());
m.push_sample(healthy_sample(0));
m.push_sample(lossy_sample(1));
let snap = m.snapshot();
assert!(snap.peak_loss >= 0.05);
assert!(snap.peak_rtt_ms >= 250.0);
}
}