use std::collections::{BTreeMap, VecDeque};
use std::sync::Arc;
use crate::anomaly::{
AnomalyDetector, AnomalyScore, CusumDetector, DetectorType, DynamicThresholdDetector,
IqrDetector, ModifiedZScoreDetector, MovingAverageResidualDetector, ZScoreDetector,
};
use chronix_core::FieldValue;
use dashmap::DashMap;
use metrics::counter;
use parking_lot::RwLock;
use tracing::{debug, trace, warn};
use crate::error::{AnalyticsError, Result};
#[derive(Debug, Clone)]
pub struct StreamingAnomalyConfig {
pub measurement: String,
pub field: Option<String>,
pub detector_type: DetectorType,
pub threshold: f64,
pub enabled: bool,
pub min_fit_points: usize,
pub max_buffer_size: usize,
pub refit_interval: usize,
}
impl StreamingAnomalyConfig {
#[must_use]
pub fn new(
measurement: impl Into<String>,
detector_type: DetectorType,
threshold: f64,
) -> Self {
Self {
measurement: measurement.into(),
field: None,
detector_type,
threshold,
enabled: true,
min_fit_points: 30,
max_buffer_size: 10_000,
refit_interval: 1_000,
}
}
#[must_use]
pub fn with_field(mut self, field: impl Into<String>) -> Self {
self.field = Some(field.into());
self
}
#[must_use]
pub fn with_min_fit_points(mut self, n: usize) -> Self {
self.min_fit_points = n;
self
}
#[must_use]
pub fn with_max_buffer_size(mut self, n: usize) -> Self {
self.max_buffer_size = n;
self
}
#[must_use]
pub fn with_refit_interval(mut self, n: usize) -> Self {
self.refit_interval = n;
self
}
}
struct SeriesState {
timestamps: VecDeque<i64>,
values: VecDeque<f64>,
detector: Box<dyn AnomalyDetector>,
fitted: bool,
min_fit_points: usize,
max_buffer_size: usize,
refit_interval: usize,
observations_since_fit: usize,
}
impl SeriesState {
fn new(config: &StreamingAnomalyConfig) -> Self {
let detector: Box<dyn AnomalyDetector> = match &config.detector_type {
DetectorType::ZScore => Box::new(ZScoreDetector::new(Some(config.threshold))),
DetectorType::ModifiedZScore => {
Box::new(ModifiedZScoreDetector::new(Some(config.threshold)))
}
DetectorType::Iqr => Box::new(IqrDetector::new(Some(config.threshold))),
DetectorType::MovingAverageResidual => {
Box::new(MovingAverageResidualDetector::new(
Some(30.min(config.min_fit_points)),
Some(config.threshold),
))
}
DetectorType::DynamicThreshold => {
Box::new(DynamicThresholdDetector::new(Some(100), Some(config.threshold)))
}
DetectorType::Cusum => {
Box::new(CusumDetector::new(None, None))
}
DetectorType::ForecastResidual => {
warn!(
measurement = config.measurement,
"ForecastResidual detector type not available in streaming mode, \
falling back to ZScore"
);
Box::new(ZScoreDetector::new(Some(config.threshold)))
}
DetectorType::Custom(name) => {
crate::registry::global_registry()
.create_detector_default(name)
.unwrap_or_else(|e| {
warn!(plugin = %name, error = %e, "custom detector plugin failed, falling back to ZScore");
Box::new(ZScoreDetector::new(Some(config.threshold)))
})
}
};
Self {
timestamps: VecDeque::with_capacity(config.min_fit_points),
values: VecDeque::with_capacity(config.min_fit_points),
detector,
fitted: false,
min_fit_points: config.min_fit_points,
max_buffer_size: config.max_buffer_size,
refit_interval: config.refit_interval,
observations_since_fit: 0,
}
}
}
type SeriesStateKey = (String, String);
pub struct StreamingAnomalyEngine {
configs: DashMap<String, StreamingAnomalyConfig>,
series_state: DashMap<SeriesStateKey, Arc<parking_lot::Mutex<SeriesState>>>,
scores: RwLock<VecDeque<ScoredAnomaly>>,
max_scores: usize,
}
#[derive(Debug, Clone)]
pub struct ScoredAnomaly {
pub score: AnomalyScore,
pub measurement: String,
pub tags: BTreeMap<String, String>,
}
impl StreamingAnomalyEngine {
#[must_use]
pub fn new() -> Self {
Self {
configs: DashMap::new(),
series_state: DashMap::new(),
scores: RwLock::new(VecDeque::new()),
max_scores: 100_000,
}
}
pub fn enable(&self, config: StreamingAnomalyConfig) -> Result<()> {
if !config.enabled {
return Err(AnalyticsError::Config("Config is disabled".into()));
}
let measurement = config.measurement.clone();
self.configs.insert(measurement.clone(), config);
debug!(measurement = %measurement, "Streaming anomaly detection enabled");
Ok(())
}
pub fn disable(&self, measurement: &str) {
self.configs.remove(measurement);
self.series_state.retain(|k, _| k.0 != measurement);
debug!(measurement = %measurement, "Streaming anomaly detection disabled");
}
#[must_use]
pub fn is_enabled(&self, measurement: &str) -> bool {
self.configs.contains_key(measurement)
}
pub fn process_write(
&self,
measurement: &str,
tags: &BTreeMap<String, String>,
fields: &BTreeMap<String, FieldValue>,
timestamp: i64,
) -> Option<ScoredAnomaly> {
let config = self.configs.get(measurement)?;
let value = extract_numeric_value(fields, config.field.as_deref())?;
let series_key = compute_tags_key(tags);
let key = (measurement.to_string(), series_key);
let state_arc = self
.series_state
.entry(key)
.or_insert_with(|| Arc::new(parking_lot::Mutex::new(SeriesState::new(&config))))
.clone();
let mut state = state_arc.lock();
state.observations_since_fit += 1;
while state.timestamps.len() > state.max_buffer_size {
state.timestamps.pop_front();
state.values.pop_front();
}
let should_fit = if !state.fitted {
state.values.len() >= state.min_fit_points
} else {
state.refit_interval > 0 && state.observations_since_fit >= state.refit_interval
};
if should_fit {
let ts: Vec<i64> = state.timestamps.iter().copied().collect();
let vals: Vec<f64> = state.values.iter().copied().collect();
match state.detector.fit(&ts, &vals) {
Ok(()) => {
state.fitted = true;
state.observations_since_fit = 0;
debug!(
measurement = %measurement,
points = state.values.len(),
"Anomaly detector fitted"
);
}
Err(e) => {
warn!(
measurement = %measurement,
error = %e,
"Failed to fit anomaly detector"
);
return None;
}
}
}
state.timestamps.push_back(timestamp);
state.values.push_back(value);
if state.fitted {
match state.detector.detect_point(timestamp, value) {
Ok(score) => {
if score.is_anomaly {
counter!(
"chronix_streaming_anomaly_detected_total",
"measurement" => measurement.to_string()
)
.increment(1);
}
let scored = ScoredAnomaly {
score,
measurement: measurement.to_string(),
tags: tags.clone(),
};
let mut scores = self.scores.write();
if scores.len() >= self.max_scores {
scores.pop_front();
}
scores.push_back(scored.clone());
trace!(
measurement = %measurement,
score = scored.score.score,
is_anomaly = scored.score.is_anomaly,
"Anomaly detection result"
);
return Some(scored);
}
Err(e) => {
warn!(
measurement = %measurement,
error = %e,
"Anomaly detection failed"
);
}
}
}
None
}
#[must_use]
pub fn scores(&self) -> Vec<ScoredAnomaly> {
self.scores.read().iter().cloned().collect()
}
#[must_use]
pub fn scores_for_measurement(&self, measurement: &str) -> Vec<ScoredAnomaly> {
self.scores
.read()
.iter()
.filter(|s| s.measurement == measurement)
.cloned()
.collect()
}
#[must_use]
pub fn anomalies(&self) -> Vec<ScoredAnomaly> {
self.scores
.read()
.iter()
.filter(|s| s.score.is_anomaly)
.cloned()
.collect()
}
#[must_use]
pub fn config_count(&self) -> usize {
self.configs.len()
}
#[must_use]
pub fn series_count(&self) -> usize {
self.series_state.len()
}
pub fn drain_scores(&self) -> Vec<ScoredAnomaly> {
self.scores.write().drain(..).collect()
}
}
impl Default for StreamingAnomalyEngine {
fn default() -> Self {
Self::new()
}
}
use crate::util::{compute_tags_key, extract_numeric_value};
#[cfg(test)]
mod tests {
use super::*;
fn make_fields(value: f64) -> BTreeMap<String, FieldValue> {
let mut fields = BTreeMap::new();
fields.insert("value".to_string(), FieldValue::F64(value));
fields
}
fn make_tags(host: &str) -> BTreeMap<String, String> {
let mut tags = BTreeMap::new();
tags.insert("host".to_string(), host.to_string());
tags
}
#[test]
fn enable_disable_config() {
let engine = StreamingAnomalyEngine::new();
let config =
StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 3.0).with_min_fit_points(5);
engine.enable(config).unwrap();
assert!(engine.is_enabled("cpu"));
assert!(!engine.is_enabled("memory"));
assert_eq!(engine.config_count(), 1);
engine.disable("cpu");
assert!(!engine.is_enabled("cpu"));
assert_eq!(engine.config_count(), 0);
}
#[test]
fn process_write_accumulates_training_data() {
let engine = StreamingAnomalyEngine::new();
let config =
StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 3.0).with_min_fit_points(10);
engine.enable(config).unwrap();
let tags = make_tags("host1");
for i in 0..5 {
let result = engine.process_write("cpu", &tags, &make_fields(50.0), i * 1000);
assert!(result.is_none());
}
assert_eq!(engine.series_count(), 1);
}
#[test]
fn process_write_fits_and_detects() {
let engine = StreamingAnomalyEngine::new();
let config =
StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 2.0).with_min_fit_points(10);
engine.enable(config).unwrap();
let tags = make_tags("host1");
for i in 0..10 {
engine.process_write(
"cpu",
&tags,
&make_fields(50.0 + (i as f64) * 0.1),
i * 1000,
);
}
let result = engine.process_write("cpu", &tags, &make_fields(50.5), 10_000);
assert!(result.is_some());
let scored = result.unwrap();
assert!(!scored.score.is_anomaly);
let result = engine.process_write("cpu", &tags, &make_fields(1000.0), 11_000);
assert!(result.is_some());
let scored = result.unwrap();
assert!(scored.score.is_anomaly);
}
#[test]
fn unconfigured_measurement_ignored() {
let engine = StreamingAnomalyEngine::new();
let tags = make_tags("host1");
let result = engine.process_write("cpu", &tags, &make_fields(50.0), 0);
assert!(result.is_none());
}
#[test]
fn specific_field_extraction() {
let engine = StreamingAnomalyEngine::new();
let config = StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 2.0)
.with_field("usage")
.with_min_fit_points(5);
engine.enable(config).unwrap();
let tags = make_tags("host1");
let mut fields = BTreeMap::new();
fields.insert("usage".to_string(), FieldValue::F64(50.0));
fields.insert("idle".to_string(), FieldValue::F64(50.0));
for i in 0..5 {
engine.process_write("cpu", &tags, &fields, i * 1000);
}
let result = engine.process_write("cpu", &tags, &fields, 5000);
assert!(result.is_some());
}
#[test]
fn multiple_series_tracked_independently() {
let engine = StreamingAnomalyEngine::new();
let config =
StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 2.0).with_min_fit_points(5);
engine.enable(config).unwrap();
let tags_a = make_tags("host1");
let tags_b = make_tags("host2");
for i in 0..5 {
engine.process_write("cpu", &tags_a, &make_fields(50.0), i * 1000);
engine.process_write("cpu", &tags_b, &make_fields(100.0), i * 1000);
}
assert_eq!(engine.series_count(), 2);
}
#[test]
fn scores_query() {
let engine = StreamingAnomalyEngine::new();
let config =
StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 2.0).with_min_fit_points(5);
engine.enable(config).unwrap();
let tags = make_tags("host1");
for i in 0..5 {
engine.process_write("cpu", &tags, &make_fields(50.0), i * 1000);
}
engine.process_write("cpu", &tags, &make_fields(50.0), 5000);
let all = engine.scores();
assert!(!all.is_empty());
let cpu = engine.scores_for_measurement("cpu");
assert_eq!(cpu.len(), all.len());
}
#[test]
fn extract_numeric_from_field_types() {
let mut fields = BTreeMap::new();
fields.insert("i64_field".to_string(), FieldValue::I64(42));
assert_eq!(
extract_numeric_value(&fields, Some("i64_field")),
Some(42.0)
);
fields.insert("u64_field".to_string(), FieldValue::U64(99));
assert_eq!(
extract_numeric_value(&fields, Some("u64_field")),
Some(99.0)
);
fields.insert("bool_field".to_string(), FieldValue::Bool(true));
assert_eq!(extract_numeric_value(&fields, Some("bool_field")), None);
}
#[test]
fn disabled_config_rejected() {
let engine = StreamingAnomalyEngine::new();
let mut config = StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 3.0);
config.enabled = false;
assert!(engine.enable(config).is_err());
}
#[test]
fn test_nan_inf_rejected() {
use crate::util::extract_numeric_value;
use chronix_core::FieldValue;
let mut fields = BTreeMap::new();
fields.insert("v".to_string(), FieldValue::F64(f64::NAN));
assert_eq!(extract_numeric_value(&fields, Some("v")), None);
fields.insert("v".to_string(), FieldValue::F64(f64::INFINITY));
assert_eq!(extract_numeric_value(&fields, Some("v")), None);
fields.insert("v".to_string(), FieldValue::F64(f64::NEG_INFINITY));
assert_eq!(extract_numeric_value(&fields, Some("v")), None);
fields.insert("v".to_string(), FieldValue::F64(42.0));
assert_eq!(extract_numeric_value(&fields, Some("v")), Some(42.0));
}
#[test]
fn test_vecdeque_eviction_bounded() {
let engine = StreamingAnomalyEngine::new();
let config = StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 3.0)
.with_max_buffer_size(50)
.with_min_fit_points(10);
engine.enable(config).unwrap();
let tags = make_tags("host1");
for i in 0..200 {
engine.process_write(
"cpu",
&tags,
&make_fields(50.0 + (i as f64) * 0.01),
i * 1000,
);
}
assert_eq!(engine.series_count(), 1);
}
}