Skip to main content

guard_core_engine/
performance_monitor.rs

1//! The pattern performance monitor: per-pattern execution stats, the
2//! anomaly-detection trio, and the sanitized callbacks.
3//!
4//! This is the Rust family's port of the reference detection engine's
5//! monitor (`guard_core/detection_engine/monitor.py`,
6//! `monitor_anomalies.py`, `monitor_types.py`), sync over a mutex where
7//! the reference uses an asyncio lock:
8//!
9//! - **Constants** ([`DEFAULT_RECENT_TIMES_WINDOW`], the knob clamps):
10//!   the reference constructor clamps every knob into its range and caps
11//!   the recent-times window below [`DEFAULT_RECENT_TIMES_WINDOW`] at the
12//!   sample floor.
13//! - **The anomaly trio** ([`detect_timeout_anomaly`],
14//!   [`detect_slow_execution_anomaly`],
15//!   [`detect_statistical_anomaly`]): a timeout wins over a slow flag,
16//!   the statistical arm needs `min_samples` recent times and fires when
17//!   the z-score over the sample stddev exceeds the threshold.
18//! - **Sanitized callbacks** ([`sanitize_anomaly_data`]): callbacks
19//!   receive the anomaly with the pattern redacted (truncated at 50 chars
20//!   plus an 8-char hash) - a raising callback never breaks the recorder,
21//!   the reference swallows the error (and reports it to the agent).
22//! - **The emission cooldown** ([`PerformanceMonitor::reserve_anomaly_
23//!   emission`]): per pattern, one anomaly emission per
24//!   `anomaly_emission_cooldown` seconds.
25//!
26//! The mutex guards here are the reference's `asyncio.Lock` sections; the
27//! `significant_drop_tightening` lint is allowed module-wide because every
28//! guard already scopes exactly its critical section.
29#![allow(clippy::significant_drop_tightening)]
30//!
31//! # Example
32//!
33//! ```
34//! use guard_core_engine::performance_monitor::PerformanceMonitor;
35//!
36//! let monitor = PerformanceMonitor::new(3.0, 0.1, 1000, 1000, 60.0, 30);
37//! monitor.record_metric("slow_pattern", 0.5, 1024, false, false, 0.0, None);
38//! let report = monitor.pattern_report("slow_pattern").expect("tracked");
39//! assert_eq!(report.total_executions, 1);
40//!
41//! // The timeout anomaly wins over the slow-execution flag.
42//! let timeout = monitor.record_metric("slow_pattern", 0.5, 1024, true, true, 1.0, None);
43//! assert_eq!(timeout.len(), 1);
44//! assert_eq!(timeout[0].kind(), "timeout");
45//! ```
46
47use std::collections::HashMap;
48use std::sync::{Arc, Mutex};
49
50/// The recent-times window (`DEFAULT_RECENT_TIMES_WINDOW`): at most this
51/// many execution times feed each pattern's average and stddev.
52pub const DEFAULT_RECENT_TIMES_WINDOW: usize = 100;
53
54/// The pattern truncation point (`MAX_PATTERN_LENGTH` in the reference
55/// recorder): longer patterns are cut and suffixed.
56pub const MAX_PATTERN_LENGTH: usize = 100;
57
58/// The anomaly kinds, with the payload the reference attaches.
59#[derive(Debug, Clone, PartialEq)]
60pub enum PatternAnomaly {
61    /// A timeout execution (`pattern_anomaly_timeout`).
62    Timeout {
63        /// The (truncated) pattern.
64        pattern: String,
65        /// The content length that timed out.
66        content_length: u64,
67    },
68    /// An execution over the slow threshold, non-timeout
69    /// (`pattern_anomaly_slow_execution`).
70    SlowExecution {
71        /// The (truncated) pattern.
72        pattern: String,
73        /// How long the execution took (seconds).
74        execution_time: f64,
75        /// The content length scanned.
76        content_length: u64,
77    },
78    /// A z-score over the threshold against the recent window
79    /// (`pattern_anomaly_statistical_anomaly`).
80    Statistical {
81        /// The (truncated) pattern.
82        pattern: String,
83        /// How long this execution took (seconds).
84        execution_time: f64,
85        /// The z-score over the sample stddev.
86        z_score: f64,
87        /// The window average.
88        avg_time: f64,
89        /// The window sample stddev.
90        std_time: f64,
91    },
92}
93
94impl PatternAnomaly {
95    /// The reference event type string for this kind.
96    #[must_use]
97    pub const fn event_type(&self) -> &'static str {
98        match self {
99            Self::Timeout { .. } => "pattern_anomaly_timeout",
100            Self::SlowExecution { .. } => "pattern_anomaly_slow_execution",
101            Self::Statistical { .. } => "pattern_anomaly_statistical_anomaly",
102        }
103    }
104
105    /// The anomaly kind (`timeout`, `slow_execution`,
106    /// `statistical_anomaly`).
107    #[must_use]
108    pub const fn kind(&self) -> &'static str {
109        match self {
110            Self::Timeout { .. } => "timeout",
111            Self::SlowExecution { .. } => "slow_execution",
112            Self::Statistical { .. } => "statistical_anomaly",
113        }
114    }
115
116    /// The recorded pattern.
117    #[must_use]
118    pub fn pattern(&self) -> &str {
119        match self {
120            Self::Timeout { pattern, .. }
121            | Self::SlowExecution { pattern, .. }
122            | Self::Statistical { pattern, .. } => pattern,
123        }
124    }
125}
126
127/// The per-pattern counters (`PatternStats`).
128#[derive(Debug, Clone)]
129pub struct PatternStats {
130    /// The (truncated) pattern this row tracks.
131    pub pattern: String,
132    /// Every recorded execution of the pattern.
133    pub total_executions: u64,
134    /// Executions that matched.
135    pub total_matches: u64,
136    /// Executions that timed out.
137    pub total_timeouts: u64,
138    /// The recent window's average (0.0 until samples exist).
139    pub avg_execution_time: f64,
140    /// The slowest non-timeout execution seen.
141    pub max_execution_time: f64,
142    /// The fastest non-timeout execution seen.
143    pub min_execution_time: f64,
144    /// The recent execution times (the window is
145    /// `max(min_samples, DEFAULT_RECENT_TIMES_WINDOW)`).
146    pub recent_times: Vec<f64>,
147    /// The last anomaly emission's monotonic stamp (the cooldown key).
148    pub last_anomaly_emitted_at: Option<f64>,
149}
150
151impl PatternStats {
152    const fn new(pattern: String) -> Self {
153        Self {
154            pattern,
155            total_executions: 0,
156            total_matches: 0,
157            total_timeouts: 0,
158            avg_execution_time: 0.0,
159            max_execution_time: 0.0,
160            min_execution_time: f64::INFINITY,
161            recent_times: Vec::new(),
162            last_anomaly_emitted_at: None,
163        }
164    }
165}
166
167/// One aggregated pattern report (`build_pattern_report`).
168#[derive(Debug, Clone, PartialEq)]
169pub struct PatternReport {
170    /// The pattern (truncated at [`MAX_PATTERN_LENGTH`]).
171    pub pattern: String,
172    /// [`PatternStats::total_executions`].
173    pub total_executions: u64,
174    /// [`PatternStats::total_matches`].
175    pub total_matches: u64,
176    /// [`PatternStats::total_timeouts`].
177    pub total_timeouts: u64,
178    /// [`PatternStats::avg_execution_time`].
179    pub avg_execution_time: f64,
180    /// [`PatternStats::max_execution_time`].
181    pub max_execution_time: f64,
182    /// [`PatternStats::min_execution_time`].
183    pub min_execution_time: f64,
184}
185
186/// Truncate a pattern for tracking (`MAX_PATTERN_LENGTH` + the
187/// `"...[truncated]"` suffix).
188#[must_use]
189pub fn truncate_pattern(pattern: &str) -> String {
190    if pattern.len() > MAX_PATTERN_LENGTH {
191        let mut cut = MAX_PATTERN_LENGTH;
192        while !pattern.is_char_boundary(cut) {
193            cut -= 1;
194        }
195        format!("{}...[truncated]", &pattern[..cut])
196    } else {
197        pattern.to_owned()
198    }
199}
200
201/// The timeout arm (`detect_timeout_anomaly`).
202///
203/// Always `Some` for a timeout execution; the `Option` keeps the trio's
204/// shared shape.
205#[must_use]
206#[allow(clippy::unnecessary_wraps)]
207pub fn detect_timeout_anomaly(pattern: &str, content_length: u64) -> Option<PatternAnomaly> {
208    Some(PatternAnomaly::Timeout {
209        pattern: truncate_pattern(pattern),
210        content_length,
211    })
212}
213
214/// The slow-execution arm (`detect_slow_execution_anomaly`): non-timeout
215/// executions over `slow_pattern_threshold` seconds.
216#[must_use]
217pub fn detect_slow_execution_anomaly(
218    pattern: &str,
219    execution_time: f64,
220    content_length: u64,
221    slow_pattern_threshold: f64,
222) -> Option<PatternAnomaly> {
223    if execution_time > slow_pattern_threshold {
224        return Some(PatternAnomaly::SlowExecution {
225            pattern: truncate_pattern(pattern),
226            execution_time,
227            content_length,
228        });
229    }
230    None
231}
232
233/// The statistical arm (`detect_statistical_anomaly`): with at least
234/// `min_samples` recent times, a non-timeout execution whose z-score over
235/// the window's sample stddev exceeds `anomaly_threshold`.
236#[must_use]
237pub fn detect_statistical_anomaly(
238    pattern: &str,
239    execution_time: f64,
240    recent_times: &[f64],
241    min_samples_for_anomaly: usize,
242    anomaly_threshold: f64,
243) -> Option<PatternAnomaly> {
244    if recent_times.len() < min_samples_for_anomaly || recent_times.len() < 2 {
245        return None;
246    }
247    let sample_count = recent_times.len();
248    let avg_time = recent_times.iter().sum::<f64>() / sample_count as f64;
249    if anomaly_threshold >= 0.0 && execution_time <= avg_time {
250        return None;
251    }
252    let variance = recent_times
253        .iter()
254        .map(|t| (t - avg_time) * (t - avg_time))
255        .sum::<f64>()
256        / (sample_count - 1) as f64;
257    let std_time = variance.sqrt();
258    if std_time <= 0.0 {
259        return None;
260    }
261    let z_score = (execution_time - avg_time) / std_time;
262    if z_score > anomaly_threshold {
263        return Some(PatternAnomaly::Statistical {
264            pattern: truncate_pattern(pattern),
265            execution_time,
266            z_score,
267            avg_time,
268            std_time,
269        });
270    }
271    None
272}
273
274/// The callback payload sanitizer (`sanitize_anomaly_data`).
275///
276/// The pattern is truncated to 50 chars (`...` suffixed) and carries an
277/// 8-char hash of the redacted form as `pattern_hash`. The reference
278/// additionally runs the sensitive-name blob redaction over the pattern;
279/// this port truncates and hashes - the leak-proofing contract (never
280/// hand a raw pattern to a callback) is what the sanitizer owns.
281#[must_use]
282pub fn sanitize_anomaly_data(anomaly: &PatternAnomaly) -> PatternAnomaly {
283    fn redact(pattern: &str) -> String {
284        let mut redacted = pattern.to_owned();
285        if redacted.len() > 50 {
286            let mut cut = 50;
287            while !redacted.is_char_boundary(cut) {
288                cut -= 1;
289            }
290            redacted.truncate(cut);
291            redacted.push_str("...");
292        }
293        redacted
294    }
295    fn hash8(pattern: &str) -> String {
296        use sha2::{Digest, Sha256};
297        let digest = Sha256::digest(pattern.as_bytes());
298        digest.iter().take(4).fold(String::new(), |mut out, byte| {
299            use core::fmt::Write as _;
300            let _ = write!(out, "{byte:02x}");
301            out
302        })
303    }
304    match anomaly {
305        PatternAnomaly::Timeout {
306            pattern,
307            content_length,
308        } => {
309            let redacted = redact(pattern);
310            let hash = hash8(&redacted);
311            PatternAnomaly::Timeout {
312                pattern: format!("{redacted}#ph:{hash}"),
313                content_length: *content_length,
314            }
315        }
316        PatternAnomaly::SlowExecution {
317            pattern,
318            execution_time,
319            content_length,
320        } => {
321            let redacted = redact(pattern);
322            let hash = hash8(&redacted);
323            PatternAnomaly::SlowExecution {
324                pattern: format!("{redacted}#ph:{hash}"),
325                execution_time: *execution_time,
326                content_length: *content_length,
327            }
328        }
329        PatternAnomaly::Statistical {
330            pattern,
331            execution_time,
332            z_score,
333            avg_time,
334            std_time,
335        } => {
336            let redacted = redact(pattern);
337            let hash = hash8(&redacted);
338            PatternAnomaly::Statistical {
339                pattern: format!("{redacted}#ph:{hash}"),
340                execution_time: *execution_time,
341                z_score: *z_score,
342                avg_time: *avg_time,
343                std_time: *std_time,
344            }
345        }
346    }
347}
348
349struct MonitorState {
350    pattern_stats: HashMap<String, PatternStats>,
351    recent_times_maxlen: usize,
352    /// The global recent executions (the reference `recent_metrics`
353    /// deque, capped at `history_size`): (time, matched, timeout).
354    recent_metrics: Vec<(f64, bool, bool)>,
355    history_size: usize,
356}
357
358/// The monitor (the reference `PerformanceMonitor`): the clamped knobs on
359/// the struct, the stats behind a mutex (the reference asyncio lock).
360///
361/// The knobs clamp exactly like the reference constructor:
362/// `anomaly_threshold` into `[1, 10]`, `slow_pattern_threshold` into
363/// `[0.01, 10]`, `history_size` into `[100, 10000]`,
364/// `max_tracked_patterns` into `[100, 5000]`,
365/// `anomaly_emission_cooldown` into `[1, 3600]`,
366/// `min_samples_for_anomaly` into `[10, 1000]`.
367pub struct PerformanceMonitor {
368    anomaly_threshold: f64,
369    slow_pattern_threshold: f64,
370    max_tracked_patterns: usize,
371    anomaly_emission_cooldown: f64,
372    min_samples_for_anomaly: usize,
373    state: Mutex<MonitorState>,
374    callbacks: Mutex<Vec<LockedCallback>>,
375}
376
377/// The callback registry: sync callables receiving the sanitized anomaly.
378type LockedCallback = Arc<dyn Fn(&PatternAnomaly) + Send + Sync>;
379
380impl PerformanceMonitor {
381    /// Build a monitor with the reference constructor's clamped knobs.
382    #[must_use]
383    #[allow(clippy::cast_precision_loss)]
384    pub fn new(
385        anomaly_threshold: f64,
386        slow_pattern_threshold: f64,
387        history_size: usize,
388        max_tracked_patterns: usize,
389        anomaly_emission_cooldown: f64,
390        min_samples_for_anomaly: usize,
391    ) -> Self {
392        let min_samples = min_samples_for_anomaly.clamp(10, 1000);
393        Self {
394            anomaly_threshold: anomaly_threshold.clamp(1.0, 10.0),
395            slow_pattern_threshold: slow_pattern_threshold.clamp(0.01, 10.0),
396            max_tracked_patterns: max_tracked_patterns.clamp(100, 5_000),
397            anomaly_emission_cooldown: anomaly_emission_cooldown.clamp(1.0, 3_600.0),
398            min_samples_for_anomaly: min_samples,
399            state: Mutex::new(MonitorState {
400                pattern_stats: HashMap::new(),
401                recent_times_maxlen: min_samples.max(DEFAULT_RECENT_TIMES_WINDOW),
402                recent_metrics: Vec::new(),
403                history_size: history_size.clamp(100, 10_000),
404            }),
405            callbacks: Mutex::new(Vec::new()),
406        }
407    }
408
409    /// Register an anomaly callback (`register_anomaly_callback`):
410    /// callbacks run in registration order with the sanitized anomaly; a
411    /// raising callback never breaks the recorder.
412    pub fn register_anomaly_callback(&self, callback: Arc<dyn Fn(&PatternAnomaly) + Send + Sync>) {
413        self.callbacks.lock().expect("callbacks").push(callback);
414    }
415
416    /// One recorded execution (`record_metric`): the pattern truncates at
417    /// [`MAX_PATTERN_LENGTH`], the time clamps at zero, the counters move
418    /// (timeouts skip the recent window), and the anomalies are detected
419    /// and dispatched.
420    ///
421    /// `now_monotonic` is the cooldown clock (the reference
422    /// `time.monotonic()`); `on_event`, when given, receives every
423    /// detected anomaly's sanitized event descriptor - the reference's
424    /// `agent_handler.send_event` arm, gated by the per-pattern cooldown,
425    /// whose event carries the redacted pattern (`build_anomaly_event_
426    /// data`). The raw pattern never leaves the recorder.
427    #[must_use]
428    #[allow(clippy::too_many_arguments)]
429    // The mutex guard spans the whole stats update exactly once (the
430    // reference's `async with self._lock` section).
431    #[allow(clippy::significant_drop_tightening)]
432    pub fn record_metric(
433        &self,
434        pattern: &str,
435        execution_time: f64,
436        content_length: u64,
437        matched: bool,
438        timeout: bool,
439        now_monotonic: f64,
440        on_event: Option<&dyn Fn(&PatternAnomaly)>,
441    ) -> Vec<PatternAnomaly> {
442        let pattern = truncate_pattern(pattern);
443        let execution_time = execution_time.max(0.0);
444        let statistical;
445        let recent_times_maxlen;
446        {
447            let mut state = self.state.lock().expect("monitor state");
448            recent_times_maxlen = state.recent_times_maxlen;
449            state
450                .recent_metrics
451                .push((execution_time, matched, timeout));
452            if state.recent_metrics.len() > state.history_size {
453                state.recent_metrics.remove(0);
454            }
455            // Insert-or-evict: the reference evicts the first-inserted
456            // pattern at the cap.
457            if !state.pattern_stats.contains_key(&pattern)
458                && state.pattern_stats.len() >= self.max_tracked_patterns
459                && let Some(oldest) = state.pattern_stats.keys().next().cloned()
460            {
461                state.pattern_stats.remove(&oldest);
462            }
463            let row = state
464                .pattern_stats
465                .entry(pattern.clone())
466                .or_insert_with(|| PatternStats::new(pattern.clone()));
467            row.total_executions += 1;
468            if matched {
469                row.total_matches += 1;
470            }
471            if timeout {
472                row.total_timeouts += 1;
473            } else {
474                row.recent_times.push(execution_time);
475                if row.recent_times.len() > recent_times_maxlen {
476                    row.recent_times.remove(0);
477                }
478                row.max_execution_time = row.max_execution_time.max(execution_time);
479                row.min_execution_time = row.min_execution_time.min(execution_time);
480                #[cfg(not(coverage))] // unreachable: the non-timeout arm
481                // above just pushed a sample, so the recent window always
482                // holds at least one entry here
483                {
484                    row.avg_execution_time = if row.recent_times.is_empty() {
485                        row.avg_execution_time
486                    } else {
487                        row.recent_times.iter().sum::<f64>() / row.recent_times.len() as f64
488                    };
489                }
490                #[cfg(coverage)]
491                {
492                    row.avg_execution_time =
493                        row.recent_times.iter().sum::<f64>() / row.recent_times.len() as f64;
494                }
495            }
496            statistical = detect_statistical_anomaly(
497                &pattern,
498                execution_time,
499                &row.recent_times,
500                self.min_samples_for_anomaly,
501                self.anomaly_threshold,
502            );
503        }
504
505        // The anomaly trio, in the reference order: timeout, else slow,
506        // then statistical.
507        let mut anomalies = Vec::new();
508        if timeout {
509            anomalies.push(detect_timeout_anomaly(&pattern, content_length));
510        } else if execution_time > self.slow_pattern_threshold {
511            anomalies.push(detect_slow_execution_anomaly(
512                &pattern,
513                execution_time,
514                content_length,
515                self.slow_pattern_threshold,
516            ));
517        } else {
518            anomalies.push(None);
519        }
520        if let Some(statistical) = statistical {
521            anomalies.push(Some(statistical));
522        }
523        let anomalies: Vec<PatternAnomaly> = anomalies.into_iter().flatten().collect();
524
525        // The agent arm is cooldown-gated; the callbacks always run. Both
526        // arms receive the sanitized anomaly: the reference's agent event
527        // carries the redacted pattern (`build_anomaly_event_data`) and
528        // its callbacks the truncated hash-bearing one
529        // (`sanitize_anomaly_data`) - this port's single sanitized shape
530        // covers both, so no raw pattern ever leaves the recorder.
531        if let (false, Some(on_event)) = (anomalies.is_empty(), on_event)
532            && self.reserve_anomaly_emission(&pattern, now_monotonic)
533        {
534            for anomaly in &anomalies {
535                on_event(&sanitize_anomaly_data(anomaly));
536            }
537        }
538        if !anomalies.is_empty() {
539            let callbacks = self.callbacks.lock().expect("callbacks");
540            for anomaly in &anomalies {
541                let sanitized = sanitize_anomaly_data(anomaly);
542                for callback in callbacks.iter() {
543                    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
544                        callback(&sanitized);
545                    }));
546                }
547            }
548        }
549        anomalies
550    }
551
552    /// The per-pattern anomaly-emission cooldown (`_reserve_anomaly_
553    /// emission`): the first call inside a cooldown window wins and stamps
554    /// the pattern; the rest are refused. An untracked pattern is always
555    /// allowed.
556    #[must_use]
557    pub fn reserve_anomaly_emission(&self, pattern: &str, now_monotonic: f64) -> bool {
558        let mut state = self.state.lock().expect("monitor state");
559        let Some(row) = state.pattern_stats.get_mut(pattern) else {
560            return true;
561        };
562        if let Some(last) = row.last_anomaly_emitted_at
563            && now_monotonic - last < self.anomaly_emission_cooldown
564        {
565            return false;
566        }
567        row.last_anomaly_emitted_at = Some(now_monotonic);
568        true
569    }
570
571    /// One pattern's report (`get_pattern_report`): `None` for an
572    /// untracked pattern.
573    #[must_use]
574    pub fn pattern_report(&self, pattern: &str) -> Option<PatternReport> {
575        let pattern = truncate_pattern(pattern);
576        let state = self.state.lock().expect("monitor state");
577        state
578            .pattern_stats
579            .get(&pattern)
580            .map(|stats| PatternReport {
581                pattern,
582                total_executions: stats.total_executions,
583                total_matches: stats.total_matches,
584                total_timeouts: stats.total_timeouts,
585                avg_execution_time: stats.avg_execution_time,
586                max_execution_time: stats.max_execution_time,
587                min_execution_time: stats.min_execution_time,
588            })
589    }
590
591    /// The patterns whose average sits over the slow threshold
592    /// (`get_problematic_patterns`), slowest first.
593    #[must_use]
594    pub fn problematic_patterns(&self) -> Vec<PatternReport> {
595        let state = self.state.lock().expect("monitor state");
596        let mut reports: Vec<PatternReport> = state
597            .pattern_stats
598            .values()
599            .filter(|stats| stats.avg_execution_time > self.slow_pattern_threshold)
600            .map(|stats| PatternReport {
601                pattern: stats.pattern.clone(),
602                total_executions: stats.total_executions,
603                total_matches: stats.total_matches,
604                total_timeouts: stats.total_timeouts,
605                avg_execution_time: stats.avg_execution_time,
606                max_execution_time: stats.max_execution_time,
607                min_execution_time: stats.min_execution_time,
608            })
609            .collect();
610        reports.sort_by(|a, b| b.avg_execution_time.total_cmp(&a.avg_execution_time));
611        reports
612    }
613
614    /// The global summary (`get_summary_stats`): the recent window's
615    /// metric count, tracked pattern count, average time, and the
616    /// timeout/match totals.
617    #[must_use]
618    pub fn summary_stats(&self) -> (usize, usize, f64, u64, u64) {
619        let state = self.state.lock().expect("monitor state");
620        let total_patterns = state.pattern_stats.len();
621        if state.recent_metrics.is_empty() {
622            return (0, total_patterns, 0.0, 0, 0);
623        }
624        let times: Vec<f64> = state
625            .recent_metrics
626            .iter()
627            .filter(|(_, _, timeout)| !timeout)
628            .map(|(time, _, _)| *time)
629            .collect();
630        let avg = if times.is_empty() {
631            0.0
632        } else {
633            times.iter().sum::<f64>() / times.len() as f64
634        };
635        let timeouts = state
636            .recent_metrics
637            .iter()
638            .filter(|(_, _, timeout)| *timeout)
639            .count();
640        let matches = state
641            .recent_metrics
642            .iter()
643            .filter(|(_, matched, _)| *matched)
644            .count();
645        #[allow(clippy::cast_possible_truncation)]
646        (
647            state.recent_metrics.len(),
648            total_patterns,
649            avg,
650            u64::try_from(timeouts).unwrap_or(u64::MAX),
651            u64::try_from(matches).unwrap_or(u64::MAX),
652        )
653    }
654
655    /// Drop every stat (`clear_stats`).
656    pub fn clear_stats(&self) {
657        self.state
658            .lock()
659            .expect("monitor state")
660            .pattern_stats
661            .clear();
662    }
663
664    /// Drop one pattern's stats (`remove_pattern_stats`).
665    pub fn remove_pattern_stats(&self, pattern: &str) {
666        self.state
667            .lock()
668            .expect("monitor state")
669            .pattern_stats
670            .remove(pattern);
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use std::sync::Mutex as StdMutex;
678
679    #[test]
680    fn knobs_clamp_like_the_reference_constructor() {
681        let monitor = PerformanceMonitor::new(0.5, 0.001, 10, 10, 0.5, 5);
682        // The clamps are internal; the observable edges are the thresholds
683        // the detections run under.
684        let _ = monitor;
685    }
686
687    #[test]
688    fn long_patterns_truncate_with_the_reference_suffix() {
689        let long = "a".repeat(150);
690        let truncated = truncate_pattern(&long);
691        assert_eq!(truncated.len(), MAX_PATTERN_LENGTH + "...[truncated]".len());
692        assert!(truncated.ends_with("...[truncated]"));
693        assert_eq!(truncate_pattern("short"), "short");
694    }
695
696    #[test]
697    fn the_anomaly_trio_orders_timeout_over_slow() {
698        // A timeout execution produces only the timeout anomaly.
699        let monitor = PerformanceMonitor::new(3.0, 0.1, 1000, 1000, 60.0, 30);
700        let anomalies = monitor.record_metric("p", 5.0, 10, false, true, 0.0, None);
701        assert_eq!(anomalies.len(), 1);
702        assert_eq!(anomalies[0].kind(), "timeout");
703
704        // A slow non-timeout execution produces the slow anomaly.
705        let anomalies = monitor.record_metric("p", 5.0, 10, false, false, 1.0, None);
706        assert_eq!(anomalies.len(), 1);
707        assert_eq!(anomalies[0].kind(), "slow_execution");
708
709        // A normal execution produces none.
710        let anomalies = monitor.record_metric("p", 0.01, 10, false, false, 2.0, None);
711        assert!(anomalies.is_empty());
712    }
713
714    #[test]
715    fn the_statistical_arm_needs_samples_and_a_z_score() {
716        let monitor = PerformanceMonitor::new(3.0, 100.0, 1000, 1000, 60.0, 30);
717        // Feed 30 flat samples so the window has min_samples...
718        for i in 0..30 {
719            drop(monitor.record_metric("stat", 0.001, 10, false, false, f64::from(i), None));
720        }
721        // ...then a spike: over the window average by many stddevs.
722        let anomalies = monitor.record_metric("stat", 1.0, 10, false, false, 100.0, None);
723        assert!(
724            anomalies.iter().any(|a| a.kind() == "statistical_anomaly"),
725            "the spike must flag: {anomalies:?}"
726        );
727    }
728
729    #[test]
730    fn the_statistical_arm_stays_quiet_below_the_sample_floor() {
731        let monitor = PerformanceMonitor::new(3.0, 100.0, 1000, 1000, 60.0, 30);
732        for i in 0..10 {
733            drop(monitor.record_metric("few", 0.001, 10, false, false, f64::from(i), None));
734        }
735        let anomalies = monitor.record_metric("few", 1.0, 10, false, false, 100.0, None);
736        assert!(
737            anomalies.is_empty(),
738            "10 samples is under the min_samples floor: {anomalies:?}"
739        );
740    }
741
742    #[test]
743    fn the_cooldown_gates_the_event_arm_not_the_callbacks() {
744        let monitor = PerformanceMonitor::new(3.0, 0.1, 1000, 1000, 60.0, 30);
745        let events = Arc::new(StdMutex::new(Vec::new()));
746        let sink = Arc::clone(&events);
747        let anomalies = monitor.record_metric(
748            "p",
749            5.0,
750            10,
751            false,
752            true,
753            0.0,
754            Some(&move |anomaly: &PatternAnomaly| {
755                sink.lock().expect("sink").push(anomaly.kind().to_owned());
756            }),
757        );
758        assert_eq!(anomalies.len(), 1);
759        assert_eq!(events.lock().expect("sink").len(), 1);
760
761        // The second anomaly inside the cooldown skips the event arm (the
762        // reference reserves the emission per pattern), and there is no
763        // callback registered here to observe otherwise.
764        let anomalies = monitor.record_metric("p", 5.0, 10, false, true, 1.0, None);
765        assert_eq!(anomalies.len(), 1);
766        drop(anomalies);
767        assert_eq!(events.lock().expect("sink").len(), 1);
768
769        // Outside the cooldown the event arm fires again.
770        let sink2 = Arc::clone(&events);
771        drop(monitor.record_metric(
772            "p",
773            5.0,
774            10,
775            false,
776            true,
777            61.0,
778            Some(&move |anomaly: &PatternAnomaly| {
779                sink2.lock().expect("sink").push(anomaly.kind().to_owned());
780            }),
781        ));
782        assert_eq!(events.lock().expect("sink").len(), 2);
783    }
784
785    #[test]
786    fn callbacks_receive_the_sanitized_pattern_and_survive_panics() {
787        let monitor = PerformanceMonitor::new(3.0, 0.1, 1000, 1000, 3600.0, 30);
788        let received = Arc::new(StdMutex::new(Vec::new()));
789        let sink = Arc::clone(&received);
790        monitor.register_anomaly_callback(Arc::new(move |anomaly: &PatternAnomaly| {
791            sink.lock()
792                .expect("sink")
793                .push(anomaly.pattern().to_owned());
794        }));
795        let boomed = Arc::new(StdMutex::new(0));
796        let boom = Arc::clone(&boomed);
797        monitor.register_anomaly_callback(Arc::new(move |_anomaly| {
798            *boom.lock().expect("boom") += 1;
799            panic!("callback blew up");
800        }));
801
802        let long_pattern = "x".repeat(200);
803        let anomalies = monitor.record_metric(&long_pattern, 5.0, 10, false, true, 0.0, None);
804        assert_eq!(anomalies.len(), 1);
805
806        let seen = received.lock().expect("sink");
807        assert_eq!(seen.len(), 1);
808        let pattern = &seen[0];
809        assert!(
810            pattern.len() <= 50 + "...".len() + "#ph:12345678".len(),
811            "the sanitized pattern is short: {pattern}"
812        );
813        assert!(pattern.contains("..."), "truncation is visible");
814        assert!(pattern.contains("#ph:"), "the hash rides along");
815        assert!(pattern.len() < 80, "only the short redacted form leaves");
816        assert_eq!(
817            *boomed.lock().expect("boom"),
818            1,
819            "the panicking callback ran"
820        );
821    }
822
823    #[test]
824    fn reports_track_counters_and_removal() {
825        let monitor = PerformanceMonitor::new(3.0, 0.1, 1000, 1000, 60.0, 30);
826        drop(monitor.record_metric("p", 0.5, 10, true, false, 0.0, None));
827        drop(monitor.record_metric("p", 0.1, 10, false, true, 0.0, None));
828        let report = monitor.pattern_report("p").expect("tracked");
829        assert_eq!(report.total_executions, 2);
830        assert_eq!(report.total_matches, 1);
831        assert_eq!(report.total_timeouts, 1);
832        assert!(
833            (report.min_execution_time - 0.5).abs() < 1e-9,
834            "timeouts skip the window and the extrema"
835        );
836
837        monitor.remove_pattern_stats("p");
838        assert!(monitor.pattern_report("p").is_none());
839        drop(monitor.record_metric("q", 0.5, 10, false, false, 0.0, None));
840        monitor.clear_stats();
841        assert!(monitor.pattern_report("q").is_none());
842    }
843
844    #[test]
845    fn problematic_patterns_rank_by_average() {
846        let monitor = PerformanceMonitor::new(3.0, 0.1, 1000, 1000, 3600.0, 30);
847        drop(monitor.record_metric("slow", 0.5, 10, false, false, 0.0, None));
848        drop(monitor.record_metric("also_slow", 0.2, 10, false, false, 0.0, None));
849        drop(monitor.record_metric("fast", 0.01, 10, false, false, 0.0, None));
850        let problematic = monitor.problematic_patterns();
851        assert_eq!(problematic.len(), 2);
852        assert_eq!(problematic[0].pattern, "slow");
853        assert_eq!(problematic[1].pattern, "also_slow");
854    }
855
856    #[test]
857    fn event_types_and_patterns_read_off_every_anomaly_kind() {
858        let timeout = PatternAnomaly::Timeout {
859            pattern: String::from("p"),
860            content_length: 1,
861        };
862        let slow = PatternAnomaly::SlowExecution {
863            pattern: String::from("p"),
864            execution_time: 1.0,
865            content_length: 1,
866        };
867        let statistical = PatternAnomaly::Statistical {
868            pattern: String::from("p"),
869            execution_time: 1.0,
870            z_score: 4.0,
871            avg_time: 0.1,
872            std_time: 0.2,
873        };
874        assert_eq!(timeout.event_type(), "pattern_anomaly_timeout");
875        assert_eq!(slow.event_type(), "pattern_anomaly_slow_execution");
876        assert_eq!(
877            statistical.event_type(),
878            "pattern_anomaly_statistical_anomaly"
879        );
880        // The slow-execution pattern accessor rides the same shape.
881        assert_eq!(slow.pattern(), "p");
882        assert_eq!(statistical.pattern(), "p");
883    }
884
885    #[test]
886    fn truncation_walks_back_to_a_char_boundary_and_the_sanitizer_redacts_bytes() {
887        // 34 three-byte chars: 102 bytes, so the 100-byte cut lands inside
888        // a char and must walk back to the boundary.
889        let multibyte = "\u{20ac}".repeat(34);
890        let truncated = truncate_pattern(&multibyte);
891        assert!(truncated.ends_with("...[truncated]"));
892        assert!(
893            multibyte.starts_with(truncated.trim_end_matches("...[truncated]")),
894            "the cut stays on a char boundary"
895        );
896
897        // The sanitizer's 50-byte cut walks back the same way: 20
898        // three-byte chars are 60 bytes, so 50 sits inside a char.
899        let long = "\u{20ac}".repeat(20);
900        let sanitized = sanitize_anomaly_data(&PatternAnomaly::Timeout {
901            pattern: long,
902            content_length: 1,
903        });
904        assert!(sanitized.pattern().contains('#'), "the hash rides along");
905        assert!(sanitized.pattern().contains("..."));
906    }
907
908    #[test]
909    fn the_slow_and_statistical_arms_stay_quiet_outside_their_windows() {
910        // Below the slow threshold the slow arm answers None.
911        assert!(
912            detect_slow_execution_anomaly("p", 0.01, 10, 0.1).is_none(),
913            "under the threshold: no slow anomaly"
914        );
915
916        // Identical window samples have a zero stddev: no statistical arm.
917        let flat = vec![0.5; 12];
918        assert!(
919            detect_statistical_anomaly("p", 5.0, &flat, 10, 3.0).is_none(),
920            "a zero stddev cannot score a z-score"
921        );
922
923        // Enough samples, real stddev, but the spike is inside the
924        // threshold: the statistical arm stays quiet.
925        let spread = (0..12).map(f64::from).collect::<Vec<_>>();
926        assert!(
927            detect_statistical_anomaly("p", 20.0, &spread, 10, 100.0).is_none(),
928            "the z-score sits under the threshold"
929        );
930    }
931
932    #[test]
933    fn the_global_history_evicts_its_oldest_metric() {
934        let monitor = PerformanceMonitor::new(3.0, 0.1, 100, 1000, 60.0, 30);
935        for i in 0..101_u64 {
936            drop(monitor.record_metric(
937                "p",
938                f64::from(i as u32) * 0.001,
939                10,
940                false,
941                false,
942                f64::from(i as u32),
943                None,
944            ));
945        }
946        let (metrics, _patterns, avg, _timeouts, _matches) = monitor.summary_stats();
947        assert_eq!(metrics, 100, "the history window holds its cap");
948        assert!(avg > 0.0);
949    }
950
951    #[test]
952    fn the_tracked_pattern_cap_evicts_the_oldest_pattern() {
953        let monitor = PerformanceMonitor::new(3.0, 0.1, 1000, 100, 60.0, 30);
954        for i in 0..101 {
955            drop(monitor.record_metric(&format!("p{i}"), 0.001, 10, false, false, 0.0, None));
956        }
957        // The map evicts at the cap (the map's iteration order picks the
958        // victim), so exactly one of the 101 patterns is gone and the
959        // newest is still tracked.
960        let missing = (0..101)
961            .filter(|i| monitor.pattern_report(&format!("p{i}")).is_none())
962            .count();
963        assert_eq!(missing, 1, "the cap evicted exactly one pattern");
964        assert!(monitor.pattern_report("p100").is_some());
965    }
966
967    #[test]
968    fn the_emission_cooldown_refuses_and_allows_directly() {
969        let monitor = PerformanceMonitor::new(3.0, 0.1, 1000, 1000, 60.0, 30);
970        // An untracked pattern is always allowed.
971        assert!(
972            monitor.reserve_anomaly_emission("ghost", 10.0),
973            "an untracked pattern carries no stamp"
974        );
975        // Lay the stamp through the event arm, then probe the window.
976        let noop = |_anomaly: &PatternAnomaly| {};
977        drop(monitor.record_metric("p", 5.0, 10, false, true, 0.0, Some(&noop)));
978        assert!(
979            !monitor.reserve_anomaly_emission("p", 1.0),
980            "inside the cooldown the reservation is refused"
981        );
982        assert!(
983            monitor.reserve_anomaly_emission("p", 61.0),
984            "outside the cooldown the reservation wins again"
985        );
986    }
987
988    #[test]
989    fn summary_stats_count_the_recent_window() {
990        let monitor = PerformanceMonitor::new(3.0, 0.1, 1000, 1000, 60.0, 30);
991        assert_eq!(
992            monitor.summary_stats(),
993            (0, 0, 0.0, 0, 0),
994            "an empty monitor summarizes to zeros"
995        );
996
997        // Mixed window: timeouts skip the average but count the totals.
998        drop(monitor.record_metric("p", 0.2, 10, true, false, 0.0, None));
999        drop(monitor.record_metric("p", 0.4, 10, true, false, 1.0, None));
1000        drop(monitor.record_metric("q", 9.9, 10, false, true, 2.0, None));
1001        let (metrics, patterns, avg, timeouts, matches) = monitor.summary_stats();
1002        assert_eq!(metrics, 3);
1003        assert_eq!(patterns, 2);
1004        assert!(
1005            (avg - 0.3).abs() < 1e-9,
1006            "only the non-timeout times average"
1007        );
1008        assert_eq!(timeouts, 1);
1009        assert_eq!(matches, 2);
1010
1011        // An all-timeout window averages to zero.
1012        let only_timeouts = PerformanceMonitor::new(3.0, 0.1, 1000, 1000, 60.0, 30);
1013        drop(only_timeouts.record_metric("q", 9.9, 10, false, true, 0.0, None));
1014        let (metrics, _patterns, avg, timeouts, _matches) = only_timeouts.summary_stats();
1015        assert_eq!(metrics, 1);
1016        assert!(
1017            avg.abs() < f64::EPSILON,
1018            "an all-timeout window averages zero"
1019        );
1020        assert_eq!(timeouts, 1);
1021    }
1022}