1#![allow(clippy::significant_drop_tightening)]
30use std::collections::HashMap;
48use std::sync::{Arc, Mutex};
49
50pub const DEFAULT_RECENT_TIMES_WINDOW: usize = 100;
53
54pub const MAX_PATTERN_LENGTH: usize = 100;
57
58#[derive(Debug, Clone, PartialEq)]
60pub enum PatternAnomaly {
61 Timeout {
63 pattern: String,
65 content_length: u64,
67 },
68 SlowExecution {
71 pattern: String,
73 execution_time: f64,
75 content_length: u64,
77 },
78 Statistical {
81 pattern: String,
83 execution_time: f64,
85 z_score: f64,
87 avg_time: f64,
89 std_time: f64,
91 },
92}
93
94impl PatternAnomaly {
95 #[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 #[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 #[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#[derive(Debug, Clone)]
129pub struct PatternStats {
130 pub pattern: String,
132 pub total_executions: u64,
134 pub total_matches: u64,
136 pub total_timeouts: u64,
138 pub avg_execution_time: f64,
140 pub max_execution_time: f64,
142 pub min_execution_time: f64,
144 pub recent_times: Vec<f64>,
147 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#[derive(Debug, Clone, PartialEq)]
169pub struct PatternReport {
170 pub pattern: String,
172 pub total_executions: u64,
174 pub total_matches: u64,
176 pub total_timeouts: u64,
178 pub avg_execution_time: f64,
180 pub max_execution_time: f64,
182 pub min_execution_time: f64,
184}
185
186#[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#[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#[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#[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#[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 recent_metrics: Vec<(f64, bool, bool)>,
355 history_size: usize,
356}
357
358pub 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
377type LockedCallback = Arc<dyn Fn(&PatternAnomaly) + Send + Sync>;
379
380impl PerformanceMonitor {
381 #[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 pub fn register_anomaly_callback(&self, callback: Arc<dyn Fn(&PatternAnomaly) + Send + Sync>) {
413 self.callbacks.lock().expect("callbacks").push(callback);
414 }
415
416 #[must_use]
428 #[allow(clippy::too_many_arguments)]
429 #[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 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))] {
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 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 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 #[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 #[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 #[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 #[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 pub fn clear_stats(&self) {
657 self.state
658 .lock()
659 .expect("monitor state")
660 .pattern_stats
661 .clear();
662 }
663
664 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 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 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 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 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 for i in 0..30 {
719 drop(monitor.record_metric("stat", 0.001, 10, false, false, f64::from(i), None));
720 }
721 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 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 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 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 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 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 assert!(
912 detect_slow_execution_anomaly("p", 0.01, 10, 0.1).is_none(),
913 "under the threshold: no slow anomaly"
914 );
915
916 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 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 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 assert!(
972 monitor.reserve_anomaly_emission("ghost", 10.0),
973 "an untracked pattern carries no stamp"
974 );
975 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 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 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}