mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 22:20:28 +09:00
224 lines
7.8 KiB
Rust
224 lines
7.8 KiB
Rust
//! Lock-free fusion observability counters. The orchestrator bumps these
|
|
//! on ingress / emit / eviction; HTTP handlers (and eventually the admin
|
|
//! dashboard) read atomic snapshots without touching orchestrator state.
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
use serde::Serialize;
|
|
|
|
use crate::domain::common::event::DetectionSource;
|
|
|
|
/// Relaxed ordering is enough for counters: readers tolerate arbitrary
|
|
/// interleaving, and no counter's value gates access to another memory
|
|
/// location. Anything stronger would just waste fence instructions on the
|
|
/// hot packet path without adding any real invariant.
|
|
const ORDER: Ordering = Ordering::Relaxed;
|
|
|
|
/// Atomic counters maintained by the detection orchestrator. Shared via
|
|
/// `Arc` with the HTTP metrics handler so dashboards can read without
|
|
/// blocking the orchestrator task.
|
|
#[derive(Debug, Default)]
|
|
pub struct FusionMetrics {
|
|
/// Total fused emits (single-source + multi-source combined).
|
|
total_emits: AtomicU64,
|
|
/// Fused emits whose final `active_source_count` was ≥ 2 (i.e. fusion
|
|
/// actually fired across multiple sources rather than single-source solo).
|
|
multi_source_emits: AtomicU64,
|
|
/// Fusion windows evicted under LRU pressure before they could emit.
|
|
/// Kept separate from shutdown-drain drops — those are legitimate.
|
|
windows_evicted: AtomicU64,
|
|
/// Per-source event count. Increments once per ingress detection
|
|
/// regardless of whether the event fires a fused emit downstream.
|
|
ml_fires: AtomicU64,
|
|
suricata_fires: AtomicU64,
|
|
beaconing_fires: AtomicU64,
|
|
correlation_fires: AtomicU64,
|
|
}
|
|
|
|
impl FusionMetrics {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Record an ingress detection from the given source. Called before
|
|
/// fusion-window bookkeeping so per-source counters reflect raw
|
|
/// volume, not what survives dedup.
|
|
pub fn record_fire(&self, source: DetectionSource) {
|
|
let counter = match source {
|
|
DetectionSource::ML => &self.ml_fires,
|
|
DetectionSource::Suricata => &self.suricata_fires,
|
|
DetectionSource::Beaconing => &self.beaconing_fires,
|
|
DetectionSource::Correlation => &self.correlation_fires,
|
|
};
|
|
counter.fetch_add(1, ORDER);
|
|
}
|
|
|
|
/// Record a fused emit. `source_count` is the number of distinct
|
|
/// sources that contributed to this emit — 1 for single-source,
|
|
/// 2..=4 when fusion actually agreed.
|
|
pub fn record_emit(&self, source_count: usize) {
|
|
self.total_emits.fetch_add(1, ORDER);
|
|
if source_count >= 2 {
|
|
self.multi_source_emits.fetch_add(1, ORDER);
|
|
}
|
|
}
|
|
|
|
/// Record a fusion-window eviction that happened before the window
|
|
/// could emit. Shutdown-drain drops are not counted here.
|
|
pub fn record_eviction(&self) {
|
|
self.windows_evicted.fetch_add(1, ORDER);
|
|
}
|
|
|
|
/// Take an atomic snapshot of every counter and derive the three
|
|
/// rate figures the dashboard surfaces.
|
|
pub fn snapshot(&self) -> FusionMetricsSnapshot {
|
|
let total_emits = self.total_emits.load(ORDER);
|
|
let multi_source_emits = self.multi_source_emits.load(ORDER);
|
|
let windows_evicted = self.windows_evicted.load(ORDER);
|
|
|
|
let ml = self.ml_fires.load(ORDER);
|
|
let suricata = self.suricata_fires.load(ORDER);
|
|
let beaconing = self.beaconing_fires.load(ORDER);
|
|
let correlation = self.correlation_fires.load(ORDER);
|
|
|
|
let agreed_rate = ratio(multi_source_emits, total_emits);
|
|
let drop_denominator = total_emits + windows_evicted;
|
|
let window_drop_rate = ratio(windows_evicted, drop_denominator);
|
|
|
|
let total_fires = ml + suricata + beaconing + correlation;
|
|
let per_source_fire_rate = PerSourceRate {
|
|
ml: ratio(ml, total_fires),
|
|
suricata: ratio(suricata, total_fires),
|
|
beaconing: ratio(beaconing, total_fires),
|
|
correlation: ratio(correlation, total_fires),
|
|
};
|
|
|
|
FusionMetricsSnapshot {
|
|
total_emits,
|
|
multi_source_emits,
|
|
agreed_rate,
|
|
windows_evicted,
|
|
window_drop_rate,
|
|
per_source_fires: PerSourceCount {
|
|
ml,
|
|
suricata,
|
|
beaconing,
|
|
correlation,
|
|
},
|
|
per_source_fire_rate,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Return `numerator / denominator` as `f64`, or `0.0` when the
|
|
/// denominator is zero. Saves every rate caller from an `if denom == 0`
|
|
/// rewrite of the same guard.
|
|
fn ratio(numerator: u64, denominator: u64) -> f64 {
|
|
if denominator == 0 {
|
|
0.0
|
|
} else {
|
|
numerator as f64 / denominator as f64
|
|
}
|
|
}
|
|
|
|
/// Wire-format snapshot consumed by `GET /api/fusion/metrics`. Derived
|
|
/// fields (`agreed_rate`, `window_drop_rate`, `per_source_fire_rate`)
|
|
/// are precomputed server-side so the UI doesn't have to re-implement
|
|
/// the formulas and drift.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct FusionMetricsSnapshot {
|
|
pub total_emits: u64,
|
|
pub multi_source_emits: u64,
|
|
/// `multi_source_emits / total_emits`.
|
|
pub agreed_rate: f64,
|
|
pub windows_evicted: u64,
|
|
/// `windows_evicted / (windows_evicted + total_emits)`.
|
|
pub window_drop_rate: f64,
|
|
pub per_source_fires: PerSourceCount,
|
|
pub per_source_fire_rate: PerSourceRate,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct PerSourceCount {
|
|
pub ml: u64,
|
|
pub suricata: u64,
|
|
pub beaconing: u64,
|
|
pub correlation: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct PerSourceRate {
|
|
pub ml: f64,
|
|
pub suricata: f64,
|
|
pub beaconing: f64,
|
|
pub correlation: f64,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn zero_traffic_snapshot_reports_zero_rates() {
|
|
let m = FusionMetrics::new();
|
|
let s = m.snapshot();
|
|
assert_eq!(s.total_emits, 0);
|
|
assert_eq!(s.agreed_rate, 0.0);
|
|
assert_eq!(s.window_drop_rate, 0.0);
|
|
assert_eq!(s.per_source_fire_rate.ml, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn agreed_rate_reflects_multi_source_ratio() {
|
|
let m = FusionMetrics::new();
|
|
m.record_emit(1); // single-source
|
|
m.record_emit(2); // multi
|
|
m.record_emit(3); // multi
|
|
m.record_emit(1); // single
|
|
let s = m.snapshot();
|
|
assert_eq!(s.total_emits, 4);
|
|
assert_eq!(s.multi_source_emits, 2);
|
|
assert!((s.agreed_rate - 0.5).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn window_drop_rate_isolates_evictions_from_emits() {
|
|
let m = FusionMetrics::new();
|
|
for _ in 0..9 {
|
|
m.record_emit(1);
|
|
}
|
|
m.record_eviction(); // 1 evicted / 10 total tracked
|
|
let s = m.snapshot();
|
|
assert_eq!(s.windows_evicted, 1);
|
|
assert!((s.window_drop_rate - 0.1).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn per_source_fire_rate_sums_to_one_when_nonzero() {
|
|
let m = FusionMetrics::new();
|
|
m.record_fire(DetectionSource::ML);
|
|
m.record_fire(DetectionSource::ML);
|
|
m.record_fire(DetectionSource::Suricata);
|
|
m.record_fire(DetectionSource::Beaconing);
|
|
let s = m.snapshot();
|
|
let sum = s.per_source_fire_rate.ml
|
|
+ s.per_source_fire_rate.suricata
|
|
+ s.per_source_fire_rate.beaconing
|
|
+ s.per_source_fire_rate.correlation;
|
|
assert!((sum - 1.0).abs() < 1e-9, "per-source rates must sum to 1, got {sum}");
|
|
assert!((s.per_source_fire_rate.ml - 0.5).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn record_fire_routes_to_correct_source_bucket() {
|
|
let m = FusionMetrics::new();
|
|
m.record_fire(DetectionSource::Suricata);
|
|
m.record_fire(DetectionSource::Correlation);
|
|
let s = m.snapshot();
|
|
assert_eq!(s.per_source_fires.suricata, 1);
|
|
assert_eq!(s.per_source_fires.correlation, 1);
|
|
assert_eq!(s.per_source_fires.ml, 0);
|
|
assert_eq!(s.per_source_fires.beaconing, 0);
|
|
}
|
|
}
|