Skip to main content

chronix_analytics/
streaming_anomaly.rs

1//! Streaming anomaly detection — subscribes to CDC writes and runs
2//! per-series anomaly detection in real time.
3//!
4//! ## Architecture
5//!
6//! ```text
7//! CDC PointWritten events
8//!       │
9//!       ▼
10//! StreamingAnomalyEngine
11//!   ├── Per-measurement config (detector type, threshold, field)
12//!   ├── Per-series fitted AnomalyDetector (DashMap)
13//!   ├── On each point: detect_point() → AnomalyScore
14//!   └── Results emitted as scored records
15//! ```
16
17use 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/// Configuration for streaming anomaly detection on a measurement.
33#[derive(Debug, Clone)]
34pub struct StreamingAnomalyConfig {
35    /// Measurement to monitor.
36    pub measurement: String,
37    /// Which field to watch (default: first f64 field).
38    pub field: Option<String>,
39    /// Detector type to use.
40    pub detector_type: DetectorType,
41    /// Anomaly threshold (meaning depends on detector type).
42    pub threshold: f64,
43    /// Whether streaming is enabled.
44    pub enabled: bool,
45    /// Minimum data points before detection starts.
46    pub min_fit_points: usize,
47    /// Maximum buffer size for training data per series (prevents unbounded growth).
48    pub max_buffer_size: usize,
49    /// Re-fit the detector every N new observations for concept drift adaptation.
50    /// Set to `0` to disable periodic re-fit (only fit once).
51    pub refit_interval: usize,
52}
53
54impl StreamingAnomalyConfig {
55    /// Create a new config.
56    #[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    /// Set the field to monitor.
75    #[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    /// Set minimum fit points.
82    #[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    /// Set maximum buffer size per series.
89    #[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    /// Set re-fit interval (number of new observations between re-fits).
96    /// Set to `0` to disable periodic re-fit.
97    #[must_use]
98    pub fn with_refit_interval(mut self, n: usize) -> Self {
99        self.refit_interval = n;
100        self
101    }
102}
103
104/// Per-series state: training buffer + fitted detector.
105struct 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    /// Counter of new observations since the last fit.
114    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            // ForecastResidual requires a forecast model — fallback to ZScore
138            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
168/// Key for per-series state: `(measurement, canonical_tags_key)`.
169type SeriesStateKey = (String, String);
170
171/// Real-time streaming anomaly detection engine.
172///
173/// Processes CDC write events and runs per-series anomaly detection,
174/// emitting `AnomalyScore` results.
175pub struct StreamingAnomalyEngine {
176    /// Per-measurement configurations.
177    configs: DashMap<String, StreamingAnomalyConfig>,
178    /// Per-series detector state. Uses `Arc<Mutex>` so the DashMap shard
179    /// lock is released before acquiring the per-series Mutex (avoids
180    /// blocking other series in the same shard during expensive fit()).
181    series_state: DashMap<SeriesStateKey, Arc<parking_lot::Mutex<SeriesState>>>,
182    /// Emitted anomaly scores (for querying/persistence).
183    scores: RwLock<VecDeque<ScoredAnomaly>>,
184    /// Maximum stored scores.
185    max_scores: usize,
186}
187
188/// A scored anomaly result from streaming detection.
189#[derive(Debug, Clone)]
190pub struct ScoredAnomaly {
191    /// The anomaly score from the detector.
192    pub score: AnomalyScore,
193    /// The measurement this was detected on.
194    pub measurement: String,
195    /// Tags of the series.
196    pub tags: BTreeMap<String, String>,
197}
198
199impl StreamingAnomalyEngine {
200    /// Create a new streaming anomaly engine.
201    #[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    /// Enable streaming anomaly detection for a measurement.
212    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    /// Disable streaming anomaly detection for a measurement.
223    pub fn disable(&self, measurement: &str) {
224        self.configs.remove(measurement);
225        // Remove all series state for this measurement
226        self.series_state.retain(|k, _| k.0 != measurement);
227        debug!(measurement = %measurement, "Streaming anomaly detection disabled");
228    }
229
230    /// Check if a measurement has streaming anomaly detection enabled.
231    #[must_use]
232    pub fn is_enabled(&self, measurement: &str) -> bool {
233        self.configs.contains_key(measurement)
234    }
235
236    /// Process a CDC write event.
237    ///
238    /// Returns the anomaly score if detection was performed (i.e., the
239    /// series has enough data and is configured).
240    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        // Extract numeric value from fields
250        let value = extract_numeric_value(fields, config.field.as_deref())?;
251
252        // Compute series key for state lookup
253        let series_key = compute_tags_key(tags);
254        let key = (measurement.to_string(), series_key);
255
256        // Get or create series state — clone the Arc to release the
257        // DashMap shard lock before acquiring the per-series Mutex.
258        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        // Evict oldest data if buffer is full (O(1) per eviction)
269        while state.timestamps.len() > state.max_buffer_size {
270            state.timestamps.pop_front();
271            state.values.pop_front();
272        }
273
274        // Fit if we have enough data and haven't fitted yet,
275        // or re-fit periodically for concept drift adaptation.
276        let should_fit = if !state.fitted {
277            state.values.len() >= state.min_fit_points
278        } else {
279            // Periodic re-fit every `refit_interval` new observations
280            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        // Accumulate the current point AFTER fitting so that
308        // the detector is trained on strictly historical data. The point
309        // is available for future refit cycles but does NOT participate
310        // in the model that scores it — eliminating look-ahead bias.
311        state.timestamps.push_back(timestamp);
312        state.values.push_back(value);
313
314        // Detect if fitted
315        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                    // Store the score
333                    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    /// Get all scored anomalies.
362    #[must_use]
363    pub fn scores(&self) -> Vec<ScoredAnomaly> {
364        self.scores.read().iter().cloned().collect()
365    }
366
367    /// Get anomaly scores for a measurement.
368    #[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    /// Get only anomalous scores.
379    #[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    /// Number of configured measurements.
390    #[must_use]
391    pub fn config_count(&self) -> usize {
392        self.configs.len()
393    }
394
395    /// Number of tracked series.
396    #[must_use]
397    pub fn series_count(&self) -> usize {
398        self.series_state.len()
399    }
400
401    /// Drain all scores.
402    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        // Write 5 points — not enough for fitting
456        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        // Write 10 normal points to fit
474        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        // Write a normal point — should get a score now
484        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        // Write an extreme outlier
490        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        // After fitting, detection should use the "usage" field
522        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        // Post-fit point
556        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        // Feed 200 points into the same series
622        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        // Should still be exactly 1 series tracked
632        assert_eq!(engine.series_count(), 1);
633    }
634}