use super::{Anomaly, AnomalyDetector, AnomalySeverity, AnomalyType, Baseline, DataPoint};
use crate::error::{ObservabilityError, Result};
use chrono::{DateTime, Duration, Utc};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
pub struct MadDetector {
baseline: RwLock<Option<MadBaseline>>,
threshold: f64,
metric_name: String,
}
#[derive(Debug, Clone)]
struct MadBaseline {
median: f64,
mad: f64,
consistency_constant: f64,
}
impl MadDetector {
pub fn new(threshold: f64, metric_name: &str) -> Self {
Self {
baseline: RwLock::new(None),
threshold,
metric_name: metric_name.to_string(),
}
}
fn calculate_median(sorted_values: &[f64]) -> Option<f64> {
if sorted_values.is_empty() {
return None;
}
let n = sorted_values.len();
if n % 2 == 0 {
Some((sorted_values[n / 2 - 1] + sorted_values[n / 2]) / 2.0)
} else {
Some(sorted_values[n / 2])
}
}
fn calculate_baseline(data: &[DataPoint]) -> Result<MadBaseline> {
if data.is_empty() {
return Err(ObservabilityError::AnomalyDetectionError(
"Cannot calculate MAD baseline from empty data".to_string(),
));
}
let mut values: Vec<f64> = data.iter().map(|d| d.value).collect();
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = Self::calculate_median(&values).ok_or_else(|| {
ObservabilityError::AnomalyDetectionError("Failed to calculate median".to_string())
})?;
let mut deviations: Vec<f64> = values.iter().map(|v| (v - median).abs()).collect();
deviations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mad = Self::calculate_median(&deviations).ok_or_else(|| {
ObservabilityError::AnomalyDetectionError("Failed to calculate MAD".to_string())
})?;
let consistency_constant = 1.4826;
Ok(MadBaseline {
median,
mad,
consistency_constant,
})
}
}
impl AnomalyDetector for MadDetector {
fn detect(&self, data: &[DataPoint]) -> Result<Vec<Anomaly>> {
let baseline = self.baseline.read();
let baseline = baseline.as_ref().ok_or_else(|| {
ObservabilityError::AnomalyDetectionError("MAD baseline not established".to_string())
})?;
let mut anomalies = Vec::new();
let scaled_mad = baseline.mad * baseline.consistency_constant;
if scaled_mad < f64::EPSILON {
return Ok(anomalies);
}
for point in data {
let modified_zscore = (point.value - baseline.median).abs() / scaled_mad;
if modified_zscore > self.threshold {
let score = (modified_zscore / (self.threshold * 2.0)).min(1.0);
let severity = classify_severity_by_score(score);
let anomaly_type = if point.value > baseline.median {
AnomalyType::Spike
} else {
AnomalyType::Drop
};
anomalies.push(Anomaly {
timestamp: point.timestamp,
metric_name: self.metric_name.clone(),
observed_value: point.value,
expected_value: baseline.median,
score,
severity,
anomaly_type,
description: format!(
"Modified Z-score: {:.2}, threshold: {:.2}",
modified_zscore, self.threshold
),
});
}
}
Ok(anomalies)
}
fn update_baseline(&mut self, data: &[DataPoint]) -> Result<()> {
let new_baseline = Self::calculate_baseline(data)?;
*self.baseline.write() = Some(new_baseline);
Ok(())
}
}
pub struct EwmaDetector {
alpha: f64,
threshold_factor: f64,
ewma: RwLock<Option<f64>>,
ewma_variance: RwLock<Option<f64>>,
metric_name: String,
}
impl EwmaDetector {
pub fn new(alpha: f64, threshold_factor: f64, metric_name: &str) -> Self {
Self {
alpha: alpha.clamp(0.01, 0.99),
threshold_factor,
ewma: RwLock::new(None),
ewma_variance: RwLock::new(None),
metric_name: metric_name.to_string(),
}
}
fn update_ewma(&self, value: f64) -> (f64, f64) {
let mut ewma = self.ewma.write();
let mut ewma_var = self.ewma_variance.write();
let predicted = ewma.unwrap_or(value);
let error = value - predicted;
let new_ewma = self.alpha * value + (1.0 - self.alpha) * predicted;
*ewma = Some(new_ewma);
let current_var = ewma_var.unwrap_or(0.0);
let new_var = self.alpha * error * error + (1.0 - self.alpha) * current_var;
*ewma_var = Some(new_var);
(predicted, new_var.sqrt())
}
}
impl AnomalyDetector for EwmaDetector {
fn detect(&self, data: &[DataPoint]) -> Result<Vec<Anomaly>> {
let mut anomalies = Vec::new();
for point in data {
let (predicted, std_dev) = self.update_ewma(point.value);
if std_dev < f64::EPSILON {
continue;
}
let z_score = (point.value - predicted).abs() / std_dev;
if z_score > self.threshold_factor {
let score = (z_score / (self.threshold_factor * 2.0)).min(1.0);
let severity = classify_severity_by_score(score);
let anomaly_type = if point.value > predicted {
AnomalyType::Spike
} else {
AnomalyType::Drop
};
anomalies.push(Anomaly {
timestamp: point.timestamp,
metric_name: self.metric_name.clone(),
observed_value: point.value,
expected_value: predicted,
score,
severity,
anomaly_type,
description: format!(
"EWMA anomaly: predicted={:.2}, actual={:.2}, z={:.2}",
predicted, point.value, z_score
),
});
}
}
Ok(anomalies)
}
fn update_baseline(&mut self, data: &[DataPoint]) -> Result<()> {
if let Some(first) = data.first() {
*self.ewma.write() = Some(first.value);
*self.ewma_variance.write() = Some(0.0);
for point in data.iter().skip(1) {
let _ = self.update_ewma(point.value);
}
}
Ok(())
}
}
pub struct HoltWintersDetector {
alpha: f64, beta: f64, gamma: f64, period: usize, threshold: f64,
state: RwLock<Option<HoltWintersState>>,
metric_name: String,
}
#[derive(Debug, Clone)]
struct HoltWintersState {
level: f64,
trend: f64,
seasonal: Vec<f64>,
fitted_values: Vec<f64>,
}
impl HoltWintersDetector {
pub fn new(
alpha: f64,
beta: f64,
gamma: f64,
period: usize,
threshold: f64,
metric_name: &str,
) -> Self {
Self {
alpha: alpha.clamp(0.01, 0.99),
beta: beta.clamp(0.01, 0.99),
gamma: gamma.clamp(0.01, 0.99),
period: period.max(2),
threshold,
state: RwLock::new(None),
metric_name: metric_name.to_string(),
}
}
fn initialize(&self, data: &[DataPoint]) -> Result<HoltWintersState> {
if data.len() < self.period * 2 {
return Err(ObservabilityError::AnomalyDetectionError(
format!(
"Insufficient data for Holt-Winters: need at least {} points",
self.period * 2
),
));
}
let initial_level: f64 = data[..self.period].iter().map(|d| d.value).sum::<f64>()
/ self.period as f64;
let first_period_mean: f64 = data[..self.period].iter().map(|d| d.value).sum::<f64>()
/ self.period as f64;
let second_period_mean: f64 = data[self.period..self.period * 2]
.iter()
.map(|d| d.value)
.sum::<f64>()
/ self.period as f64;
let initial_trend = (second_period_mean - first_period_mean) / self.period as f64;
let mut seasonal = vec![0.0; self.period];
for i in 0..self.period {
let season_values: Vec<f64> = data
.iter()
.skip(i)
.step_by(self.period)
.take(2)
.map(|d| d.value)
.collect();
if !season_values.is_empty() {
let avg = season_values.iter().sum::<f64>() / season_values.len() as f64;
seasonal[i] = avg - initial_level;
}
}
Ok(HoltWintersState {
level: initial_level,
trend: initial_trend,
seasonal,
fitted_values: Vec::new(),
})
}
fn predict_and_update(&self, state: &mut HoltWintersState, value: f64, season_idx: usize) -> f64 {
let prediction = state.level + state.trend + state.seasonal[season_idx];
let new_level = self.alpha * (value - state.seasonal[season_idx])
+ (1.0 - self.alpha) * (state.level + state.trend);
let new_trend = self.beta * (new_level - state.level) + (1.0 - self.beta) * state.trend;
let new_seasonal = self.gamma * (value - new_level) + (1.0 - self.gamma) * state.seasonal[season_idx];
state.level = new_level;
state.trend = new_trend;
state.seasonal[season_idx] = new_seasonal;
state.fitted_values.push(prediction);
prediction
}
}
impl AnomalyDetector for HoltWintersDetector {
fn detect(&self, data: &[DataPoint]) -> Result<Vec<Anomaly>> {
let mut state_guard = self.state.write();
let state = state_guard.as_mut().ok_or_else(|| {
ObservabilityError::AnomalyDetectionError("Holt-Winters model not initialized".to_string())
})?;
let mut anomalies = Vec::new();
let residuals: Vec<f64> = state
.fitted_values
.iter()
.zip(data.iter())
.map(|(f, d)| (d.value - f).abs())
.collect();
let residual_std = if residuals.len() > 1 {
let mean = residuals.iter().sum::<f64>() / residuals.len() as f64;
let variance = residuals.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
/ (residuals.len() - 1) as f64;
variance.sqrt()
} else {
1.0
};
for (i, point) in data.iter().enumerate() {
let season_idx = i % self.period;
let prediction = self.predict_and_update(state, point.value, season_idx);
let residual = (point.value - prediction).abs();
if residual > self.threshold * residual_std {
let score = (residual / (self.threshold * residual_std * 2.0)).min(1.0);
let severity = classify_severity_by_score(score);
let anomaly_type = if point.value > prediction {
AnomalyType::Spike
} else {
AnomalyType::Drop
};
anomalies.push(Anomaly {
timestamp: point.timestamp,
metric_name: self.metric_name.clone(),
observed_value: point.value,
expected_value: prediction,
score,
severity,
anomaly_type,
description: format!(
"Holt-Winters anomaly: predicted={:.2}, actual={:.2}",
prediction, point.value
),
});
}
}
Ok(anomalies)
}
fn update_baseline(&mut self, data: &[DataPoint]) -> Result<()> {
let new_state = self.initialize(data)?;
*self.state.write() = Some(new_state);
Ok(())
}
}
pub struct ChangePointDetector {
threshold: f64,
drift: f64,
baseline_mean: RwLock<Option<f64>>,
baseline_std: RwLock<Option<f64>>,
metric_name: String,
}
impl ChangePointDetector {
pub fn new(threshold: f64, drift: f64, metric_name: &str) -> Self {
Self {
threshold,
drift,
baseline_mean: RwLock::new(None),
baseline_std: RwLock::new(None),
metric_name: metric_name.to_string(),
}
}
}
impl AnomalyDetector for ChangePointDetector {
fn detect(&self, data: &[DataPoint]) -> Result<Vec<Anomaly>> {
let mean = self.baseline_mean.read();
let std = self.baseline_std.read();
let mean = *mean.as_ref().ok_or_else(|| {
ObservabilityError::AnomalyDetectionError("Baseline not established".to_string())
})?;
let std = *std.as_ref().ok_or_else(|| {
ObservabilityError::AnomalyDetectionError("Baseline not established".to_string())
})?;
if std < f64::EPSILON {
return Ok(Vec::new());
}
let mut anomalies = Vec::new();
let mut s_high = 0.0_f64;
let mut s_low = 0.0_f64;
for point in data {
let normalized = (point.value - mean) / std;
s_high = (s_high + normalized - self.drift).max(0.0);
s_low = (s_low - normalized - self.drift).max(0.0);
if s_high > self.threshold || s_low > self.threshold {
let score = (s_high.max(s_low) / (self.threshold * 2.0)).min(1.0);
let severity = classify_severity_by_score(score);
let anomaly_type = if s_high > s_low {
AnomalyType::UpwardTrend
} else {
AnomalyType::DownwardTrend
};
anomalies.push(Anomaly {
timestamp: point.timestamp,
metric_name: self.metric_name.clone(),
observed_value: point.value,
expected_value: mean,
score,
severity,
anomaly_type,
description: format!(
"Change point detected: S+={:.2}, S-={:.2}",
s_high, s_low
),
});
s_high = 0.0;
s_low = 0.0;
}
}
Ok(anomalies)
}
fn update_baseline(&mut self, data: &[DataPoint]) -> Result<()> {
if data.is_empty() {
return Err(ObservabilityError::AnomalyDetectionError(
"Cannot calculate baseline from empty data".to_string(),
));
}
let values: Vec<f64> = data.iter().map(|d| d.value).collect();
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
let std = variance.sqrt();
*self.baseline_mean.write() = Some(mean);
*self.baseline_std.write() = Some(std);
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeoDataPoint {
pub longitude: f64,
pub latitude: f64,
pub value: f64,
pub timestamp: DateTime<Utc>,
pub metadata: HashMap<String, String>,
}
impl GeoDataPoint {
pub fn new(longitude: f64, latitude: f64, value: f64, timestamp: DateTime<Utc>) -> Self {
Self {
longitude,
latitude,
value,
timestamp,
metadata: HashMap::new(),
}
}
pub fn distance_to(&self, other: &GeoDataPoint) -> f64 {
const EARTH_RADIUS_KM: f64 = 6371.0;
let lat1_rad = self.latitude.to_radians();
let lat2_rad = other.latitude.to_radians();
let delta_lat = (other.latitude - self.latitude).to_radians();
let delta_lon = (other.longitude - self.longitude).to_radians();
let a = (delta_lat / 2.0).sin().powi(2)
+ lat1_rad.cos() * lat2_rad.cos() * (delta_lon / 2.0).sin().powi(2);
let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
EARTH_RADIUS_KM * c
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeoAnomaly {
pub center: (f64, f64),
pub radius_km: f64,
pub point_count: usize,
pub avg_value: f64,
pub expected_value: f64,
pub score: f64,
pub severity: AnomalySeverity,
pub anomaly_type: GeoAnomalyType,
pub description: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GeoAnomalyType {
Hotspot,
Coldspot,
IsolatedOutlier,
ClusterAnomaly,
BoundaryAnomaly,
}
pub struct GeoAnomalyDetector {
cell_size: f64,
neighbor_distance: f64,
min_neighbors: usize,
threshold: f64,
global_stats: RwLock<Option<GeoBaseline>>,
}
#[derive(Debug, Clone)]
struct GeoBaseline {
global_mean: f64,
global_std: f64,
cell_stats: HashMap<(i32, i32), CellStats>,
}
#[derive(Debug, Clone)]
struct CellStats {
mean: f64,
std: f64,
count: usize,
}
impl GeoAnomalyDetector {
pub fn new(cell_size: f64, neighbor_distance: f64, min_neighbors: usize, threshold: f64) -> Self {
Self {
cell_size: cell_size.max(0.001),
neighbor_distance: neighbor_distance.max(0.1),
min_neighbors: min_neighbors.max(1),
threshold,
global_stats: RwLock::new(None),
}
}
fn get_cell(&self, lon: f64, lat: f64) -> (i32, i32) {
let cell_x = (lon / self.cell_size).floor() as i32;
let cell_y = (lat / self.cell_size).floor() as i32;
(cell_x, cell_y)
}
pub fn update_geo_baseline(&mut self, data: &[GeoDataPoint]) -> Result<()> {
if data.is_empty() {
return Err(ObservabilityError::AnomalyDetectionError(
"Cannot calculate geo baseline from empty data".to_string(),
));
}
let values: Vec<f64> = data.iter().map(|p| p.value).collect();
let n = values.len() as f64;
let global_mean = values.iter().sum::<f64>() / n;
let global_variance = values.iter().map(|v| (v - global_mean).powi(2)).sum::<f64>() / n;
let global_std = global_variance.sqrt();
let mut cell_values: HashMap<(i32, i32), Vec<f64>> = HashMap::new();
for point in data {
let cell = self.get_cell(point.longitude, point.latitude);
cell_values.entry(cell).or_default().push(point.value);
}
let mut cell_stats = HashMap::new();
for (cell, values) in cell_values {
let count = values.len();
let mean = values.iter().sum::<f64>() / count as f64;
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / count as f64;
let std = variance.sqrt();
cell_stats.insert(cell, CellStats { mean, std, count });
}
*self.global_stats.write() = Some(GeoBaseline {
global_mean,
global_std,
cell_stats,
});
Ok(())
}
pub fn detect_geo_anomalies(&self, data: &[GeoDataPoint]) -> Result<Vec<GeoAnomaly>> {
let stats = self.global_stats.read();
let stats = stats.as_ref().ok_or_else(|| {
ObservabilityError::AnomalyDetectionError("Geo baseline not established".to_string())
})?;
if stats.global_std < f64::EPSILON {
return Ok(Vec::new());
}
let mut anomalies = Vec::new();
for point in data {
let neighbors: Vec<&GeoDataPoint> = data
.iter()
.filter(|p| point.distance_to(p) <= self.neighbor_distance)
.collect();
if neighbors.len() < self.min_neighbors {
let z_score = (point.value - stats.global_mean) / stats.global_std;
if z_score.abs() > self.threshold {
let score = (z_score.abs() / (self.threshold * 2.0)).min(1.0);
anomalies.push(GeoAnomaly {
center: (point.longitude, point.latitude),
radius_km: 0.0,
point_count: 1,
avg_value: point.value,
expected_value: stats.global_mean,
score,
severity: classify_severity_by_score(score),
anomaly_type: GeoAnomalyType::IsolatedOutlier,
description: format!(
"Isolated outlier at ({:.4}, {:.4}): value={:.2}, z={:.2}",
point.longitude, point.latitude, point.value, z_score
),
timestamp: point.timestamp,
});
}
continue;
}
let local_sum: f64 = neighbors.iter().map(|p| p.value).sum();
let local_mean = local_sum / neighbors.len() as f64;
let n = data.len() as f64;
let local_n = neighbors.len() as f64;
let gi_star = (local_sum - stats.global_mean * local_n)
/ (stats.global_std * (n * local_n - local_n.powi(2)).sqrt() / (n - 1.0).sqrt());
if gi_star.abs() > self.threshold {
let score = (gi_star.abs() / (self.threshold * 2.0)).min(1.0);
let anomaly_type = if gi_star > 0.0 {
GeoAnomalyType::Hotspot
} else {
GeoAnomalyType::Coldspot
};
anomalies.push(GeoAnomaly {
center: (point.longitude, point.latitude),
radius_km: self.neighbor_distance,
point_count: neighbors.len(),
avg_value: local_mean,
expected_value: stats.global_mean,
score,
severity: classify_severity_by_score(score),
anomaly_type,
description: format!(
"{:?} at ({:.4}, {:.4}): Gi*={:.2}, local_avg={:.2}",
anomaly_type, point.longitude, point.latitude, gi_star, local_mean
),
timestamp: point.timestamp,
});
}
}
Ok(anomalies)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertThreshold {
pub id: String,
pub metric_name: String,
pub warning_threshold: f64,
pub critical_threshold: f64,
pub operator: ThresholdOperator,
pub hysteresis: f64,
pub min_duration: Duration,
pub cooldown: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThresholdOperator {
GreaterThan,
LessThan,
GreaterThanOrEqual,
LessThanOrEqual,
Equals,
NotEquals,
}
impl ThresholdOperator {
pub fn check(&self, value: f64, threshold: f64) -> bool {
const EPSILON: f64 = 1e-9;
match self {
Self::GreaterThan => value > threshold,
Self::LessThan => value < threshold,
Self::GreaterThanOrEqual => value >= threshold,
Self::LessThanOrEqual => value <= threshold,
Self::Equals => (value - threshold).abs() < EPSILON,
Self::NotEquals => (value - threshold).abs() >= EPSILON,
}
}
}
#[derive(Debug, Clone)]
pub struct AlertState {
pub status: AlertStatus,
pub triggered_at: Option<DateTime<Utc>>,
pub last_notified: Option<DateTime<Utc>>,
pub violation_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AlertStatus {
Ok,
Warning,
Critical,
Recovering,
}
pub struct ThresholdAlertManager {
thresholds: HashMap<String, AlertThreshold>,
states: RwLock<HashMap<String, AlertState>>,
}
impl ThresholdAlertManager {
pub fn new() -> Self {
Self {
thresholds: HashMap::new(),
states: RwLock::new(HashMap::new()),
}
}
pub fn add_threshold(&mut self, threshold: AlertThreshold) {
let id = threshold.id.clone();
self.thresholds.insert(id.clone(), threshold);
self.states.write().insert(
id,
AlertState {
status: AlertStatus::Ok,
triggered_at: None,
last_notified: None,
violation_count: 0,
},
);
}
pub fn evaluate(&self, metric_name: &str, value: f64, timestamp: DateTime<Utc>) -> Vec<Alert> {
let mut alerts = Vec::new();
for (id, threshold) in &self.thresholds {
if threshold.metric_name != metric_name {
continue;
}
let mut states = self.states.write();
let state = states.entry(id.clone()).or_insert(AlertState {
status: AlertStatus::Ok,
triggered_at: None,
last_notified: None,
violation_count: 0,
});
let is_critical = threshold.operator.check(value, threshold.critical_threshold);
let is_warning = threshold.operator.check(value, threshold.warning_threshold);
let hysteresis_factor = 1.0 - threshold.hysteresis;
let critical_recovery = threshold.critical_threshold * hysteresis_factor;
let warning_recovery = threshold.warning_threshold * hysteresis_factor;
let new_status = if is_critical {
AlertStatus::Critical
} else if is_warning {
AlertStatus::Warning
} else if state.status == AlertStatus::Critical {
if threshold.operator.check(value, critical_recovery) {
AlertStatus::Critical
} else {
AlertStatus::Recovering
}
} else if state.status == AlertStatus::Warning {
if threshold.operator.check(value, warning_recovery) {
AlertStatus::Warning
} else {
AlertStatus::Recovering
}
} else {
AlertStatus::Ok
};
if new_status != state.status {
if new_status == AlertStatus::Ok {
state.triggered_at = None;
state.violation_count = 0;
} else if state.status == AlertStatus::Ok {
state.triggered_at = Some(timestamp);
}
}
if matches!(new_status, AlertStatus::Warning | AlertStatus::Critical) {
state.violation_count += 1;
}
let should_alert = if let Some(triggered_at) = state.triggered_at {
let duration_satisfied = timestamp.signed_duration_since(triggered_at)
>= threshold.min_duration;
let cooldown_satisfied = state
.last_notified
.map(|t| timestamp.signed_duration_since(t) >= threshold.cooldown)
.unwrap_or(true);
duration_satisfied && cooldown_satisfied && new_status != AlertStatus::Ok
} else {
false
};
if should_alert {
state.last_notified = Some(timestamp);
alerts.push(Alert {
id: id.clone(),
metric_name: metric_name.to_string(),
value,
threshold: if new_status == AlertStatus::Critical {
threshold.critical_threshold
} else {
threshold.warning_threshold
},
status: new_status,
severity: match new_status {
AlertStatus::Critical => AnomalySeverity::Critical,
AlertStatus::Warning => AnomalySeverity::Medium,
_ => AnomalySeverity::Low,
},
timestamp,
description: format!(
"Threshold alert: {} {} {:?} {}",
metric_name,
match threshold.operator {
ThresholdOperator::GreaterThan => ">",
ThresholdOperator::LessThan => "<",
ThresholdOperator::GreaterThanOrEqual => ">=",
ThresholdOperator::LessThanOrEqual => "<=",
ThresholdOperator::Equals => "==",
ThresholdOperator::NotEquals => "!=",
},
new_status,
threshold.critical_threshold
),
});
}
state.status = new_status;
}
alerts
}
pub fn get_state(&self, id: &str) -> Option<AlertState> {
self.states.read().get(id).cloned()
}
pub fn reset_alert(&self, id: &str) {
if let Some(state) = self.states.write().get_mut(id) {
state.status = AlertStatus::Ok;
state.triggered_at = None;
state.last_notified = None;
state.violation_count = 0;
}
}
}
impl Default for ThresholdAlertManager {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alert {
pub id: String,
pub metric_name: String,
pub value: f64,
pub threshold: f64,
pub status: AlertStatus,
pub severity: AnomalySeverity,
pub timestamp: DateTime<Utc>,
pub description: String,
}
pub struct LofDetector {
k_neighbors: usize,
threshold: f64,
training_data: RwLock<Vec<DataPoint>>,
metric_name: String,
}
impl LofDetector {
pub fn new(k_neighbors: usize, threshold: f64, metric_name: &str) -> Self {
Self {
k_neighbors: k_neighbors.max(1),
threshold: threshold.max(1.0),
training_data: RwLock::new(Vec::new()),
metric_name: metric_name.to_string(),
}
}
fn k_distance(&self, point: f64, data: &[f64]) -> f64 {
let mut distances: Vec<f64> = data.iter().map(|d| (d - point).abs()).collect();
distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
distances.get(self.k_neighbors.min(distances.len()) - 1).copied().unwrap_or(0.0)
}
fn reachability_distance(&self, point_a: f64, point_b: f64, data: &[f64]) -> f64 {
let k_dist_b = self.k_distance(point_b, data);
let dist_ab = (point_a - point_b).abs();
k_dist_b.max(dist_ab)
}
fn local_reachability_density(&self, point: f64, data: &[f64]) -> f64 {
let mut distances: Vec<(f64, f64)> = data
.iter()
.map(|d| ((d - point).abs(), *d))
.collect();
distances.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let k_nearest: Vec<f64> = distances
.iter()
.take(self.k_neighbors)
.map(|(_, v)| *v)
.collect();
if k_nearest.is_empty() {
return 0.0;
}
let sum_reach_dist: f64 = k_nearest
.iter()
.map(|&neighbor| self.reachability_distance(point, neighbor, data))
.sum();
if sum_reach_dist < f64::EPSILON {
return f64::MAX;
}
k_nearest.len() as f64 / sum_reach_dist
}
fn calculate_lof(&self, point: f64, data: &[f64]) -> f64 {
let point_lrd = self.local_reachability_density(point, data);
if point_lrd < f64::EPSILON {
return 1.0;
}
let mut distances: Vec<(f64, f64)> = data
.iter()
.map(|d| ((d - point).abs(), *d))
.collect();
distances.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let k_nearest: Vec<f64> = distances
.iter()
.take(self.k_neighbors)
.map(|(_, v)| *v)
.collect();
if k_nearest.is_empty() {
return 1.0;
}
let sum_lrd: f64 = k_nearest
.iter()
.map(|&neighbor| self.local_reachability_density(neighbor, data))
.sum();
sum_lrd / (k_nearest.len() as f64 * point_lrd)
}
}
impl AnomalyDetector for LofDetector {
fn detect(&self, data: &[DataPoint]) -> Result<Vec<Anomaly>> {
let training_data = self.training_data.read();
if training_data.len() < self.k_neighbors {
return Ok(Vec::new());
}
let training_values: Vec<f64> = training_data.iter().map(|d| d.value).collect();
let mut anomalies = Vec::new();
for point in data {
let lof = self.calculate_lof(point.value, &training_values);
if lof > self.threshold {
let score = ((lof - 1.0) / (self.threshold - 1.0)).min(1.0);
let severity = classify_severity_by_score(score);
let mut distances: Vec<(f64, f64)> = training_values
.iter()
.map(|d| ((d - point.value).abs(), *d))
.collect();
distances.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let expected = distances
.iter()
.take(self.k_neighbors)
.map(|(_, v)| v)
.sum::<f64>()
/ self.k_neighbors as f64;
anomalies.push(Anomaly {
timestamp: point.timestamp,
metric_name: self.metric_name.clone(),
observed_value: point.value,
expected_value: expected,
score,
severity,
anomaly_type: AnomalyType::Pattern,
description: format!("LOF anomaly: LOF={:.2}, threshold={:.2}", lof, self.threshold),
});
}
}
Ok(anomalies)
}
fn update_baseline(&mut self, data: &[DataPoint]) -> Result<()> {
*self.training_data.write() = data.to_vec();
Ok(())
}
}
pub struct HistoricalBaseline {
window_size: usize,
data_buffer: RwLock<VecDeque<DataPoint>>,
hourly_baselines: RwLock<HashMap<u32, Baseline>>,
daily_baselines: RwLock<HashMap<u32, Baseline>>,
weekly_baselines: RwLock<HashMap<u32, Baseline>>,
}
impl HistoricalBaseline {
pub fn new(window_size: usize) -> Self {
Self {
window_size,
data_buffer: RwLock::new(VecDeque::new()),
hourly_baselines: RwLock::new(HashMap::new()),
daily_baselines: RwLock::new(HashMap::new()),
weekly_baselines: RwLock::new(HashMap::new()),
}
}
pub fn add_data_point(&self, point: DataPoint) {
let mut buffer = self.data_buffer.write();
buffer.push_back(point.clone());
while buffer.len() > self.window_size {
buffer.pop_front();
}
let hour = point.timestamp.hour();
self.update_period_baseline(&buffer, hour, &self.hourly_baselines);
let day_of_week = point.timestamp.weekday().num_days_from_monday();
self.update_period_baseline(&buffer, day_of_week, &self.daily_baselines);
let week = point.timestamp.iso_week().week();
self.update_period_baseline(&buffer, week, &self.weekly_baselines);
}
fn update_period_baseline(
&self,
buffer: &VecDeque<DataPoint>,
period: u32,
baselines: &RwLock<HashMap<u32, Baseline>>,
) {
let points: Vec<DataPoint> = buffer.iter().cloned().collect();
if let Ok(baseline) = Baseline::from_data(&points) {
baselines.write().insert(period, baseline);
}
}
pub fn get_hourly_baseline(&self, hour: u32) -> Option<Baseline> {
self.hourly_baselines.read().get(&hour).cloned()
}
pub fn get_daily_baseline(&self, day_of_week: u32) -> Option<Baseline> {
self.daily_baselines.read().get(&day_of_week).cloned()
}
pub fn get_weekly_baseline(&self, week: u32) -> Option<Baseline> {
self.weekly_baselines.read().get(&week).cloned()
}
pub fn get_combined_baseline(&self, timestamp: DateTime<Utc>) -> Option<Baseline> {
let hour = timestamp.hour();
let day = timestamp.weekday().num_days_from_monday();
let week = timestamp.iso_week().week();
let hourly = self.get_hourly_baseline(hour);
let daily = self.get_daily_baseline(day);
let weekly = self.get_weekly_baseline(week);
let mut means = Vec::new();
let mut std_devs = Vec::new();
let weights = [0.5, 0.3, 0.2];
if let Some(b) = hourly {
means.push((b.mean, weights[0]));
std_devs.push((b.std_dev, weights[0]));
}
if let Some(b) = daily {
means.push((b.mean, weights[1]));
std_devs.push((b.std_dev, weights[1]));
}
if let Some(b) = weekly {
means.push((b.mean, weights[2]));
std_devs.push((b.std_dev, weights[2]));
}
if means.is_empty() {
return None;
}
let total_weight: f64 = means.iter().map(|(_, w)| w).sum();
let weighted_mean: f64 = means.iter().map(|(m, w)| m * w).sum::<f64>() / total_weight;
let weighted_std: f64 = std_devs.iter().map(|(s, w)| s * w).sum::<f64>() / total_weight;
Some(Baseline {
mean: weighted_mean,
std_dev: weighted_std,
min: f64::NEG_INFINITY,
max: f64::INFINITY,
count: 0,
})
}
pub fn clear(&self) {
self.data_buffer.write().clear();
self.hourly_baselines.write().clear();
self.daily_baselines.write().clear();
self.weekly_baselines.write().clear();
}
}
pub struct SeverityClassifier {
weights: SeverityWeights,
context: RwLock<SeverityContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeverityWeights {
pub score_weight: f64,
pub deviation_weight: f64,
pub frequency_weight: f64,
pub time_weight: f64,
pub criticality_weight: f64,
}
impl Default for SeverityWeights {
fn default() -> Self {
Self {
score_weight: 0.3,
deviation_weight: 0.25,
frequency_weight: 0.15,
time_weight: 0.1,
criticality_weight: 0.2,
}
}
}
#[derive(Debug, Clone, Default)]
struct SeverityContext {
recent_counts: HashMap<String, usize>,
criticality_levels: HashMap<String, f64>,
}
impl SeverityClassifier {
pub fn new() -> Self {
Self {
weights: SeverityWeights::default(),
context: RwLock::new(SeverityContext::default()),
}
}
pub fn with_weights(weights: SeverityWeights) -> Self {
Self {
weights,
context: RwLock::new(SeverityContext::default()),
}
}
pub fn set_metric_criticality(&self, metric_name: &str, criticality: f64) {
self.context
.write()
.criticality_levels
.insert(metric_name.to_string(), criticality.clamp(0.0, 1.0));
}
pub fn record_anomaly(&self, metric_name: &str) {
let mut context = self.context.write();
*context.recent_counts.entry(metric_name.to_string()).or_insert(0) += 1;
}
pub fn reset_frequency_counts(&self) {
self.context.write().recent_counts.clear();
}
pub fn classify(&self, anomaly: &Anomaly) -> AnomalySeverity {
let context = self.context.read();
let score_factor = anomaly.score;
let deviation = if anomaly.expected_value.abs() > f64::EPSILON {
((anomaly.observed_value - anomaly.expected_value) / anomaly.expected_value).abs()
} else {
anomaly.observed_value.abs()
};
let deviation_factor = (deviation / 2.0).min(1.0);
let frequency = context
.recent_counts
.get(&anomaly.metric_name)
.copied()
.unwrap_or(0);
let frequency_factor = (frequency as f64 / 10.0).min(1.0);
let hour = anomaly.timestamp.hour();
let time_factor = if (9..17).contains(&hour) { 1.0 } else { 0.5 };
let criticality_factor = context
.criticality_levels
.get(&anomaly.metric_name)
.copied()
.unwrap_or(0.5);
let severity_score = self.weights.score_weight * score_factor
+ self.weights.deviation_weight * deviation_factor
+ self.weights.frequency_weight * frequency_factor
+ self.weights.time_weight * time_factor
+ self.weights.criticality_weight * criticality_factor;
if severity_score >= 0.8 {
AnomalySeverity::Critical
} else if severity_score >= 0.6 {
AnomalySeverity::High
} else if severity_score >= 0.4 {
AnomalySeverity::Medium
} else {
AnomalySeverity::Low
}
}
}
impl Default for SeverityClassifier {
fn default() -> Self {
Self::new()
}
}
fn classify_severity_by_score(score: f64) -> AnomalySeverity {
if score >= 0.75 {
AnomalySeverity::Critical
} else if score >= 0.5 {
AnomalySeverity::High
} else if score >= 0.25 {
AnomalySeverity::Medium
} else {
AnomalySeverity::Low
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mad_detector() {
let mut detector = MadDetector::new(3.5, "test_metric");
let baseline_data: Vec<DataPoint> = (0..100)
.map(|i| DataPoint::new(Utc::now(), 50.0 + (i as f64 % 10.0)))
.collect();
detector.update_baseline(&baseline_data).expect("Failed to update baseline");
let test_data = vec![
DataPoint::new(Utc::now(), 55.0), DataPoint::new(Utc::now(), 150.0), ];
let anomalies = detector.detect(&test_data).expect("Failed to detect");
assert_eq!(anomalies.len(), 1);
assert_eq!(anomalies[0].anomaly_type, AnomalyType::Spike);
}
#[test]
fn test_ewma_detector() {
let mut detector = EwmaDetector::new(0.3, 3.0, "test_metric");
let baseline_data: Vec<DataPoint> = (0..50)
.map(|_| DataPoint::new(Utc::now(), 100.0))
.collect();
detector.update_baseline(&baseline_data).expect("Failed to update baseline");
let test_data = vec![
DataPoint::new(Utc::now(), 100.0), DataPoint::new(Utc::now(), 500.0), ];
let anomalies = detector.detect(&test_data).expect("Failed to detect");
assert!(!anomalies.is_empty());
}
#[test]
fn test_change_point_detector() {
let mut detector = ChangePointDetector::new(5.0, 0.5, "test_metric");
let baseline_data: Vec<DataPoint> = (0..100)
.map(|_| DataPoint::new(Utc::now(), 50.0))
.collect();
detector.update_baseline(&baseline_data).expect("Failed to update baseline");
let mut test_data: Vec<DataPoint> = (0..20)
.map(|_| DataPoint::new(Utc::now(), 50.0))
.collect();
test_data.extend((0..20).map(|_| DataPoint::new(Utc::now(), 100.0)));
let anomalies = detector.detect(&test_data).expect("Failed to detect");
assert!(!anomalies.is_empty());
}
#[test]
fn test_geo_anomaly_detector() {
let mut detector = GeoAnomalyDetector::new(0.1, 10.0, 3, 2.0);
let baseline_data: Vec<GeoDataPoint> = (0..100)
.map(|i| {
let lon = -122.0 + (i as f64 % 10.0) * 0.01;
let lat = 37.0 + (i as f64 / 10.0) * 0.01;
GeoDataPoint::new(lon, lat, 50.0, Utc::now())
})
.collect();
detector.update_geo_baseline(&baseline_data).expect("Failed to update geo baseline");
let mut test_data = baseline_data.clone();
for i in 0..5 {
test_data.push(GeoDataPoint::new(
-122.05 + i as f64 * 0.001,
37.05,
200.0, Utc::now(),
));
}
let anomalies = detector.detect_geo_anomalies(&test_data).expect("Failed to detect");
assert!(anomalies.len() >= 0);
}
#[test]
fn test_threshold_alert_manager() {
let mut manager = ThresholdAlertManager::new();
manager.add_threshold(AlertThreshold {
id: "cpu_high".to_string(),
metric_name: "cpu_usage".to_string(),
warning_threshold: 70.0,
critical_threshold: 90.0,
operator: ThresholdOperator::GreaterThan,
hysteresis: 0.1,
min_duration: Duration::seconds(0),
cooldown: Duration::seconds(0),
});
let alerts = manager.evaluate("cpu_usage", 50.0, Utc::now());
assert!(alerts.is_empty());
let alerts = manager.evaluate("cpu_usage", 75.0, Utc::now());
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].status, AlertStatus::Warning);
let alerts = manager.evaluate("cpu_usage", 95.0, Utc::now());
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].status, AlertStatus::Critical);
}
#[test]
fn test_lof_detector() {
let mut detector = LofDetector::new(5, 1.5, "test_metric");
let training_data: Vec<DataPoint> = (0..100)
.map(|i| DataPoint::new(Utc::now(), 45.0 + (i as f64 % 10.0)))
.collect();
detector.update_baseline(&training_data).expect("Failed to update baseline");
let test_data = vec![
DataPoint::new(Utc::now(), 50.0), DataPoint::new(Utc::now(), 200.0), ];
let anomalies = detector.detect(&test_data).expect("Failed to detect");
assert!(!anomalies.is_empty());
}
#[test]
fn test_historical_baseline() {
let baseline = HistoricalBaseline::new(1000);
for i in 0..100 {
baseline.add_data_point(DataPoint::new(
Utc::now(),
50.0 + (i as f64 % 20.0),
));
}
let combined = baseline.get_combined_baseline(Utc::now());
assert!(combined.is_some());
}
#[test]
fn test_severity_classifier() {
let classifier = SeverityClassifier::new();
classifier.set_metric_criticality("critical_metric", 1.0);
classifier.set_metric_criticality("normal_metric", 0.3);
let critical_anomaly = Anomaly {
timestamp: Utc::now(),
metric_name: "critical_metric".to_string(),
observed_value: 1000.0,
expected_value: 100.0,
score: 0.9,
severity: AnomalySeverity::Low, anomaly_type: AnomalyType::Spike,
description: "Test".to_string(),
};
let severity = classifier.classify(&critical_anomaly);
assert!(matches!(severity, AnomalySeverity::Critical | AnomalySeverity::High));
let normal_anomaly = Anomaly {
timestamp: Utc::now(),
metric_name: "normal_metric".to_string(),
observed_value: 110.0,
expected_value: 100.0,
score: 0.2,
severity: AnomalySeverity::High, anomaly_type: AnomalyType::Spike,
description: "Test".to_string(),
};
let severity = classifier.classify(&normal_anomaly);
assert!(matches!(severity, AnomalySeverity::Low | AnomalySeverity::Medium));
}
#[test]
fn test_geo_data_point_distance() {
let point1 = GeoDataPoint::new(-122.4194, 37.7749, 0.0, Utc::now()); let point2 = GeoDataPoint::new(-118.2437, 34.0522, 0.0, Utc::now());
let distance = point1.distance_to(&point2);
assert!(distance > 500.0 && distance < 600.0);
}
#[test]
fn test_threshold_operators() {
assert!(ThresholdOperator::GreaterThan.check(10.0, 5.0));
assert!(!ThresholdOperator::GreaterThan.check(5.0, 10.0));
assert!(ThresholdOperator::LessThan.check(5.0, 10.0));
assert!(!ThresholdOperator::LessThan.check(10.0, 5.0));
assert!(ThresholdOperator::GreaterThanOrEqual.check(10.0, 10.0));
assert!(ThresholdOperator::LessThanOrEqual.check(10.0, 10.0));
assert!(ThresholdOperator::Equals.check(10.0, 10.0));
assert!(!ThresholdOperator::NotEquals.check(10.0, 10.0));
}
}