1use std::collections::{BTreeMap, VecDeque};
18use std::sync::Arc;
19
20use crate::anomaly::{
21 AnomalyDetector, AnomalyScore, CusumDetector, DetectorType, DynamicThresholdDetector,
22 IqrDetector, ModifiedZScoreDetector, MovingAverageResidualDetector, ZScoreDetector,
23};
24use chronix_core::FieldValue;
25use dashmap::DashMap;
26use metrics::counter;
27use parking_lot::RwLock;
28use tracing::{debug, trace, warn};
29
30use crate::error::{AnalyticsError, Result};
31
32#[derive(Debug, Clone)]
34pub struct StreamingAnomalyConfig {
35 pub measurement: String,
37 pub field: Option<String>,
39 pub detector_type: DetectorType,
41 pub threshold: f64,
43 pub enabled: bool,
45 pub min_fit_points: usize,
47 pub max_buffer_size: usize,
49 pub refit_interval: usize,
52}
53
54impl StreamingAnomalyConfig {
55 #[must_use]
57 pub fn new(
58 measurement: impl Into<String>,
59 detector_type: DetectorType,
60 threshold: f64,
61 ) -> Self {
62 Self {
63 measurement: measurement.into(),
64 field: None,
65 detector_type,
66 threshold,
67 enabled: true,
68 min_fit_points: 30,
69 max_buffer_size: 10_000,
70 refit_interval: 1_000,
71 }
72 }
73
74 #[must_use]
76 pub fn with_field(mut self, field: impl Into<String>) -> Self {
77 self.field = Some(field.into());
78 self
79 }
80
81 #[must_use]
83 pub fn with_min_fit_points(mut self, n: usize) -> Self {
84 self.min_fit_points = n;
85 self
86 }
87
88 #[must_use]
90 pub fn with_max_buffer_size(mut self, n: usize) -> Self {
91 self.max_buffer_size = n;
92 self
93 }
94
95 #[must_use]
98 pub fn with_refit_interval(mut self, n: usize) -> Self {
99 self.refit_interval = n;
100 self
101 }
102}
103
104struct SeriesState {
106 timestamps: VecDeque<i64>,
107 values: VecDeque<f64>,
108 detector: Box<dyn AnomalyDetector>,
109 fitted: bool,
110 min_fit_points: usize,
111 max_buffer_size: usize,
112 refit_interval: usize,
113 observations_since_fit: usize,
115}
116
117impl SeriesState {
118 fn new(config: &StreamingAnomalyConfig) -> Self {
119 let detector: Box<dyn AnomalyDetector> = match &config.detector_type {
120 DetectorType::ZScore => Box::new(ZScoreDetector::new(Some(config.threshold))),
121 DetectorType::ModifiedZScore => {
122 Box::new(ModifiedZScoreDetector::new(Some(config.threshold)))
123 }
124 DetectorType::Iqr => Box::new(IqrDetector::new(Some(config.threshold))),
125 DetectorType::MovingAverageResidual => {
126 Box::new(MovingAverageResidualDetector::new(
127 Some(30.min(config.min_fit_points)),
128 Some(config.threshold),
129 ))
130 }
131 DetectorType::DynamicThreshold => {
132 Box::new(DynamicThresholdDetector::new(Some(100), Some(config.threshold)))
133 }
134 DetectorType::Cusum => {
135 Box::new(CusumDetector::new(None, None))
136 }
137 DetectorType::ForecastResidual => {
139 warn!(
140 measurement = config.measurement,
141 "ForecastResidual detector type not available in streaming mode, \
142 falling back to ZScore"
143 );
144 Box::new(ZScoreDetector::new(Some(config.threshold)))
145 }
146 DetectorType::Custom(name) => {
147 crate::registry::global_registry()
148 .create_detector_default(name)
149 .unwrap_or_else(|e| {
150 warn!(plugin = %name, error = %e, "custom detector plugin failed, falling back to ZScore");
151 Box::new(ZScoreDetector::new(Some(config.threshold)))
152 })
153 }
154 };
155 Self {
156 timestamps: VecDeque::with_capacity(config.min_fit_points),
157 values: VecDeque::with_capacity(config.min_fit_points),
158 detector,
159 fitted: false,
160 min_fit_points: config.min_fit_points,
161 max_buffer_size: config.max_buffer_size,
162 refit_interval: config.refit_interval,
163 observations_since_fit: 0,
164 }
165 }
166}
167
168type SeriesStateKey = (String, String);
170
171pub struct StreamingAnomalyEngine {
176 configs: DashMap<String, StreamingAnomalyConfig>,
178 series_state: DashMap<SeriesStateKey, Arc<parking_lot::Mutex<SeriesState>>>,
182 scores: RwLock<VecDeque<ScoredAnomaly>>,
184 max_scores: usize,
186}
187
188#[derive(Debug, Clone)]
190pub struct ScoredAnomaly {
191 pub score: AnomalyScore,
193 pub measurement: String,
195 pub tags: BTreeMap<String, String>,
197}
198
199impl StreamingAnomalyEngine {
200 #[must_use]
202 pub fn new() -> Self {
203 Self {
204 configs: DashMap::new(),
205 series_state: DashMap::new(),
206 scores: RwLock::new(VecDeque::new()),
207 max_scores: 100_000,
208 }
209 }
210
211 pub fn enable(&self, config: StreamingAnomalyConfig) -> Result<()> {
213 if !config.enabled {
214 return Err(AnalyticsError::Config("Config is disabled".into()));
215 }
216 let measurement = config.measurement.clone();
217 self.configs.insert(measurement.clone(), config);
218 debug!(measurement = %measurement, "Streaming anomaly detection enabled");
219 Ok(())
220 }
221
222 pub fn disable(&self, measurement: &str) {
224 self.configs.remove(measurement);
225 self.series_state.retain(|k, _| k.0 != measurement);
227 debug!(measurement = %measurement, "Streaming anomaly detection disabled");
228 }
229
230 #[must_use]
232 pub fn is_enabled(&self, measurement: &str) -> bool {
233 self.configs.contains_key(measurement)
234 }
235
236 pub fn process_write(
241 &self,
242 measurement: &str,
243 tags: &BTreeMap<String, String>,
244 fields: &BTreeMap<String, FieldValue>,
245 timestamp: i64,
246 ) -> Option<ScoredAnomaly> {
247 let config = self.configs.get(measurement)?;
248
249 let value = extract_numeric_value(fields, config.field.as_deref())?;
251
252 let series_key = compute_tags_key(tags);
254 let key = (measurement.to_string(), series_key);
255
256 let state_arc = self
259 .series_state
260 .entry(key)
261 .or_insert_with(|| Arc::new(parking_lot::Mutex::new(SeriesState::new(&config))))
262 .clone();
263
264 let mut state = state_arc.lock();
265
266 state.observations_since_fit += 1;
267
268 while state.timestamps.len() > state.max_buffer_size {
270 state.timestamps.pop_front();
271 state.values.pop_front();
272 }
273
274 let should_fit = if !state.fitted {
277 state.values.len() >= state.min_fit_points
278 } else {
279 state.refit_interval > 0 && state.observations_since_fit >= state.refit_interval
281 };
282
283 if should_fit {
284 let ts: Vec<i64> = state.timestamps.iter().copied().collect();
285 let vals: Vec<f64> = state.values.iter().copied().collect();
286 match state.detector.fit(&ts, &vals) {
287 Ok(()) => {
288 state.fitted = true;
289 state.observations_since_fit = 0;
290 debug!(
291 measurement = %measurement,
292 points = state.values.len(),
293 "Anomaly detector fitted"
294 );
295 }
296 Err(e) => {
297 warn!(
298 measurement = %measurement,
299 error = %e,
300 "Failed to fit anomaly detector"
301 );
302 return None;
303 }
304 }
305 }
306
307 state.timestamps.push_back(timestamp);
312 state.values.push_back(value);
313
314 if state.fitted {
316 match state.detector.detect_point(timestamp, value) {
317 Ok(score) => {
318 if score.is_anomaly {
319 counter!(
320 "chronix_streaming_anomaly_detected_total",
321 "measurement" => measurement.to_string()
322 )
323 .increment(1);
324 }
325
326 let scored = ScoredAnomaly {
327 score,
328 measurement: measurement.to_string(),
329 tags: tags.clone(),
330 };
331
332 let mut scores = self.scores.write();
334 if scores.len() >= self.max_scores {
335 scores.pop_front();
336 }
337 scores.push_back(scored.clone());
338
339 trace!(
340 measurement = %measurement,
341 score = scored.score.score,
342 is_anomaly = scored.score.is_anomaly,
343 "Anomaly detection result"
344 );
345
346 return Some(scored);
347 }
348 Err(e) => {
349 warn!(
350 measurement = %measurement,
351 error = %e,
352 "Anomaly detection failed"
353 );
354 }
355 }
356 }
357
358 None
359 }
360
361 #[must_use]
363 pub fn scores(&self) -> Vec<ScoredAnomaly> {
364 self.scores.read().iter().cloned().collect()
365 }
366
367 #[must_use]
369 pub fn scores_for_measurement(&self, measurement: &str) -> Vec<ScoredAnomaly> {
370 self.scores
371 .read()
372 .iter()
373 .filter(|s| s.measurement == measurement)
374 .cloned()
375 .collect()
376 }
377
378 #[must_use]
380 pub fn anomalies(&self) -> Vec<ScoredAnomaly> {
381 self.scores
382 .read()
383 .iter()
384 .filter(|s| s.score.is_anomaly)
385 .cloned()
386 .collect()
387 }
388
389 #[must_use]
391 pub fn config_count(&self) -> usize {
392 self.configs.len()
393 }
394
395 #[must_use]
397 pub fn series_count(&self) -> usize {
398 self.series_state.len()
399 }
400
401 pub fn drain_scores(&self) -> Vec<ScoredAnomaly> {
403 self.scores.write().drain(..).collect()
404 }
405}
406
407impl Default for StreamingAnomalyEngine {
408 fn default() -> Self {
409 Self::new()
410 }
411}
412
413use crate::util::{compute_tags_key, extract_numeric_value};
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 fn make_fields(value: f64) -> BTreeMap<String, FieldValue> {
420 let mut fields = BTreeMap::new();
421 fields.insert("value".to_string(), FieldValue::F64(value));
422 fields
423 }
424
425 fn make_tags(host: &str) -> BTreeMap<String, String> {
426 let mut tags = BTreeMap::new();
427 tags.insert("host".to_string(), host.to_string());
428 tags
429 }
430
431 #[test]
432 fn enable_disable_config() {
433 let engine = StreamingAnomalyEngine::new();
434 let config =
435 StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 3.0).with_min_fit_points(5);
436 engine.enable(config).unwrap();
437 assert!(engine.is_enabled("cpu"));
438 assert!(!engine.is_enabled("memory"));
439 assert_eq!(engine.config_count(), 1);
440
441 engine.disable("cpu");
442 assert!(!engine.is_enabled("cpu"));
443 assert_eq!(engine.config_count(), 0);
444 }
445
446 #[test]
447 fn process_write_accumulates_training_data() {
448 let engine = StreamingAnomalyEngine::new();
449 let config =
450 StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 3.0).with_min_fit_points(10);
451 engine.enable(config).unwrap();
452
453 let tags = make_tags("host1");
454
455 for i in 0..5 {
457 let result = engine.process_write("cpu", &tags, &make_fields(50.0), i * 1000);
458 assert!(result.is_none());
459 }
460
461 assert_eq!(engine.series_count(), 1);
462 }
463
464 #[test]
465 fn process_write_fits_and_detects() {
466 let engine = StreamingAnomalyEngine::new();
467 let config =
468 StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 2.0).with_min_fit_points(10);
469 engine.enable(config).unwrap();
470
471 let tags = make_tags("host1");
472
473 for i in 0..10 {
475 engine.process_write(
476 "cpu",
477 &tags,
478 &make_fields(50.0 + (i as f64) * 0.1),
479 i * 1000,
480 );
481 }
482
483 let result = engine.process_write("cpu", &tags, &make_fields(50.5), 10_000);
485 assert!(result.is_some());
486 let scored = result.unwrap();
487 assert!(!scored.score.is_anomaly);
488
489 let result = engine.process_write("cpu", &tags, &make_fields(1000.0), 11_000);
491 assert!(result.is_some());
492 let scored = result.unwrap();
493 assert!(scored.score.is_anomaly);
494 }
495
496 #[test]
497 fn unconfigured_measurement_ignored() {
498 let engine = StreamingAnomalyEngine::new();
499 let tags = make_tags("host1");
500 let result = engine.process_write("cpu", &tags, &make_fields(50.0), 0);
501 assert!(result.is_none());
502 }
503
504 #[test]
505 fn specific_field_extraction() {
506 let engine = StreamingAnomalyEngine::new();
507 let config = StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 2.0)
508 .with_field("usage")
509 .with_min_fit_points(5);
510 engine.enable(config).unwrap();
511
512 let tags = make_tags("host1");
513 let mut fields = BTreeMap::new();
514 fields.insert("usage".to_string(), FieldValue::F64(50.0));
515 fields.insert("idle".to_string(), FieldValue::F64(50.0));
516
517 for i in 0..5 {
518 engine.process_write("cpu", &tags, &fields, i * 1000);
519 }
520
521 let result = engine.process_write("cpu", &tags, &fields, 5000);
523 assert!(result.is_some());
524 }
525
526 #[test]
527 fn multiple_series_tracked_independently() {
528 let engine = StreamingAnomalyEngine::new();
529 let config =
530 StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 2.0).with_min_fit_points(5);
531 engine.enable(config).unwrap();
532
533 let tags_a = make_tags("host1");
534 let tags_b = make_tags("host2");
535
536 for i in 0..5 {
537 engine.process_write("cpu", &tags_a, &make_fields(50.0), i * 1000);
538 engine.process_write("cpu", &tags_b, &make_fields(100.0), i * 1000);
539 }
540
541 assert_eq!(engine.series_count(), 2);
542 }
543
544 #[test]
545 fn scores_query() {
546 let engine = StreamingAnomalyEngine::new();
547 let config =
548 StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 2.0).with_min_fit_points(5);
549 engine.enable(config).unwrap();
550
551 let tags = make_tags("host1");
552 for i in 0..5 {
553 engine.process_write("cpu", &tags, &make_fields(50.0), i * 1000);
554 }
555 engine.process_write("cpu", &tags, &make_fields(50.0), 5000);
557
558 let all = engine.scores();
559 assert!(!all.is_empty());
560 let cpu = engine.scores_for_measurement("cpu");
561 assert_eq!(cpu.len(), all.len());
562 }
563
564 #[test]
565 fn extract_numeric_from_field_types() {
566 let mut fields = BTreeMap::new();
567 fields.insert("i64_field".to_string(), FieldValue::I64(42));
568 assert_eq!(
569 extract_numeric_value(&fields, Some("i64_field")),
570 Some(42.0)
571 );
572
573 fields.insert("u64_field".to_string(), FieldValue::U64(99));
574 assert_eq!(
575 extract_numeric_value(&fields, Some("u64_field")),
576 Some(99.0)
577 );
578
579 fields.insert("bool_field".to_string(), FieldValue::Bool(true));
580 assert_eq!(extract_numeric_value(&fields, Some("bool_field")), None);
581 }
582
583 #[test]
584 fn disabled_config_rejected() {
585 let engine = StreamingAnomalyEngine::new();
586 let mut config = StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 3.0);
587 config.enabled = false;
588 assert!(engine.enable(config).is_err());
589 }
590
591 #[test]
592 fn test_nan_inf_rejected() {
593 use crate::util::extract_numeric_value;
594 use chronix_core::FieldValue;
595
596 let mut fields = BTreeMap::new();
597
598 fields.insert("v".to_string(), FieldValue::F64(f64::NAN));
599 assert_eq!(extract_numeric_value(&fields, Some("v")), None);
600
601 fields.insert("v".to_string(), FieldValue::F64(f64::INFINITY));
602 assert_eq!(extract_numeric_value(&fields, Some("v")), None);
603
604 fields.insert("v".to_string(), FieldValue::F64(f64::NEG_INFINITY));
605 assert_eq!(extract_numeric_value(&fields, Some("v")), None);
606
607 fields.insert("v".to_string(), FieldValue::F64(42.0));
608 assert_eq!(extract_numeric_value(&fields, Some("v")), Some(42.0));
609 }
610
611 #[test]
612 fn test_vecdeque_eviction_bounded() {
613 let engine = StreamingAnomalyEngine::new();
614 let config = StreamingAnomalyConfig::new("cpu", DetectorType::ZScore, 3.0)
615 .with_max_buffer_size(50)
616 .with_min_fit_points(10);
617 engine.enable(config).unwrap();
618
619 let tags = make_tags("host1");
620
621 for i in 0..200 {
623 engine.process_write(
624 "cpu",
625 &tags,
626 &make_fields(50.0 + (i as f64) * 0.01),
627 i * 1000,
628 );
629 }
630
631 assert_eq!(engine.series_count(), 1);
633 }
634}