//! Trend/staleness analysis over stored check history. //! //! Extracted from the HTTP probe (`checks/http.rs`): latency-drift, test-duration- //! drift, and test-staleness are computed over recorded data, not part of issuing //! a request. Kept here so the HTTP module is only the request/response path. /// Detect sustained latency drift by checking if all recent response times /// exceed the baseline average by the given threshold multiplier. /// /// Returns a description string if drift is detected, `None` otherwise. /// Requires at least 10 baseline samples to avoid false positives. pub fn detect_latency_drift( recent_times: &[i64], baseline: &crate::types::LatencyStats, threshold: f64, ) -> Option { if baseline.sample_count < 10 || recent_times.is_empty() { return None; } let drift_threshold = baseline.avg_ms * threshold; let all_over = recent_times.iter().all(|&t| t as f64 > drift_threshold); if all_over { let avg_recent: f64 = recent_times.iter().sum::() as f64 / recent_times.len() as f64; Some(format!( "latency drift: last {} checks avg {:.0}ms (baseline avg {:.0}ms, threshold {:.0}ms)", recent_times.len(), avg_recent, baseline.avg_ms, drift_threshold, )) } else { None } } /// Detect sustained test duration drift by checking if all recent durations /// exceed the baseline average by the given threshold multiplier. /// /// Returns a description string if drift is detected, `None` otherwise. /// Requires at least `baseline_count` samples for the baseline window. pub fn detect_test_duration_drift( durations: &[(String, i64)], baseline_count: usize, recent_count: usize, threshold: f64, ) -> Option { if durations.len() < baseline_count + recent_count { return None; } // durations are ordered most recent first from get_test_durations let recent = &durations[..recent_count]; let baseline = &durations[recent_count..]; if baseline.is_empty() { return None; } let baseline_avg = baseline.iter().map(|(_, d)| *d).sum::() as f64 / baseline.len() as f64; let drift_threshold = baseline_avg * threshold; let all_over = recent.iter().all(|(_, d)| *d as f64 > drift_threshold); if all_over { let recent_avg = recent.iter().map(|(_, d)| *d).sum::() as f64 / recent.len() as f64; Some(format!( "test duration drift: last {recent_count} runs avg {recent_avg:.0}s (baseline avg {baseline_avg:.0}s, threshold {drift_threshold:.0}s)", )) } else { None } } /// Compute test staleness from version and timing data. /// /// A target's tests are considered stale when: /// 1. No tests have ever been run /// 2. Tests are older than `staleness_days` /// 3. The deployed version has changed since the last test run pub fn compute_test_staleness( current_version: Option<&str>, tested_version: Option<&str>, last_test_at: Option<&str>, staleness_days: u64, ) -> crate::types::TestStaleness { let Some(last_test_at) = last_test_at else { return crate::types::TestStaleness { stale: true, reason: Some("no tests have been run".to_string()), current_version: current_version.map(String::from), tested_version: None, last_test_at: None, days_since_test: None, }; }; let days_since = chrono::DateTime::parse_from_rfc3339(last_test_at) .ok() .map(|dt| { let now = chrono::Utc::now(); (now - dt.with_timezone(&chrono::Utc)).num_days() }); if let Some(days) = days_since && days >= staleness_days as i64 { return crate::types::TestStaleness { stale: true, reason: Some(format!( "tests are {days} days old (threshold: {staleness_days}d)" )), current_version: current_version.map(String::from), tested_version: tested_version.map(String::from), last_test_at: Some(last_test_at.to_string()), days_since_test: Some(days), }; } if let (Some(current), Some(tested)) = (current_version, tested_version) && current != tested { return crate::types::TestStaleness { stale: true, reason: Some(format!("version changed: {tested} -> {current}")), current_version: Some(current.to_string()), tested_version: Some(tested.to_string()), last_test_at: Some(last_test_at.to_string()), days_since_test: days_since, }; } crate::types::TestStaleness { stale: false, reason: None, current_version: current_version.map(String::from), tested_version: tested_version.map(String::from), last_test_at: Some(last_test_at.to_string()), days_since_test: days_since, } } #[cfg(test)] mod tests { use super::*; fn baseline(avg: f64, count: i64) -> crate::types::LatencyStats { crate::types::LatencyStats { min_ms: avg as i64 / 2, max_ms: avg as i64 * 2, avg_ms: avg, p95_ms: (avg * 1.5) as i64, sample_count: count, } } fn dur(name: &str, secs: i64) -> (String, i64) { (name.to_string(), secs) } #[test] fn drift_all_over_threshold() { let bl = baseline(100.0, 100); let recent = vec![250, 260, 270]; // all > 200 (100 * 2.0) let result = detect_latency_drift(&recent, &bl, 2.0); assert!(result.is_some()); assert!(result.unwrap().contains("latency drift")); } #[test] fn drift_one_under_threshold() { let bl = baseline(100.0, 100); let recent = vec![250, 150, 270]; // 150 < 200 let result = detect_latency_drift(&recent, &bl, 2.0); assert!(result.is_none()); } #[test] fn drift_insufficient_baseline() { let bl = baseline(100.0, 5); // < 10 samples let recent = vec![250, 260, 270]; let result = detect_latency_drift(&recent, &bl, 2.0); assert!(result.is_none()); } #[test] fn drift_empty_recent() { let bl = baseline(100.0, 100); let result = detect_latency_drift(&[], &bl, 2.0); assert!(result.is_none()); } #[test] fn drift_threshold_edge() { let bl = baseline(100.0, 100); // Exactly at threshold (200): not strictly over let recent = vec![200, 200, 200]; let result = detect_latency_drift(&recent, &bl, 2.0); assert!(result.is_none()); // must be strictly greater } #[test] fn drift_just_over_threshold() { let bl = baseline(100.0, 100); let recent = vec![201, 201, 201]; let result = detect_latency_drift(&recent, &bl, 2.0); assert!(result.is_some()); } #[test] fn staleness_no_test_run() { let result = compute_test_staleness(Some("1.0.0"), None, None, 7); assert!(result.stale); assert_eq!(result.reason.as_deref(), Some("no tests have been run")); assert!(result.last_test_at.is_none()); } #[test] fn staleness_stale_by_age() { let old = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339(); let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&old), 7); assert!(result.stale); let reason = result.reason.unwrap(); assert!(reason.contains("days old"), "reason was: {reason}"); assert!(reason.contains("threshold: 7d"), "reason was: {reason}"); } #[test] fn staleness_stale_by_version() { let recent = chrono::Utc::now().to_rfc3339(); let result = compute_test_staleness(Some("1.1.0"), Some("1.0.0"), Some(&recent), 7); assert!(result.stale); let reason = result.reason.unwrap(); assert!( reason.contains("version changed: 1.0.0 -> 1.1.0"), "reason was: {reason}" ); } #[test] fn staleness_fresh() { let recent = chrono::Utc::now().to_rfc3339(); let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&recent), 7); assert!(!result.stale); assert!(result.reason.is_none()); } #[test] fn staleness_missing_versions_not_stale() { let recent = chrono::Utc::now().to_rfc3339(); let result = compute_test_staleness(None, None, Some(&recent), 7); assert!(!result.stale); } #[test] fn staleness_at_threshold_is_stale() { // Pins `days >= staleness_days` (vs `>`). days == 7, threshold == 7 // must report stale. let exactly = (chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339(); let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&exactly), 7); assert!(result.stale, "days == threshold must be stale"); } #[test] fn staleness_one_day_under_threshold_is_fresh() { let just_under = (chrono::Utc::now() - chrono::Duration::days(6) - chrono::Duration::hours(20)) .to_rfc3339(); let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&just_under), 7); assert!(!result.stale, "days < threshold must not be stale"); } #[test] fn duration_drift_all_recent_over_threshold() { // 3 recent + 4 baseline, all recent > 2x baseline avg → drift detected. // baseline_count=4, recent_count=3, total=7 let durations = vec![ dur("a", 200), dur("b", 210), dur("c", 220), // recent (most recent first) dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110), // baseline avg = 95 ]; let result = detect_test_duration_drift(&durations, 4, 3, 2.0); let msg = result.expect("drift should be detected"); assert!(msg.contains("test duration drift")); assert!(msg.contains("last 3 runs")); } #[test] fn duration_drift_one_recent_under_threshold_no_drift() { // One recent (105) is below 2x baseline avg (95 * 2 = 190). let durations = vec![ dur("a", 250), dur("b", 105), dur("c", 270), dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110), ]; assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none()); } #[test] fn duration_drift_insufficient_samples() { // Total samples < baseline_count + recent_count → no drift, return None. let durations = vec![dur("a", 500), dur("b", 500), dur("c", 100)]; assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none()); } #[test] fn duration_drift_at_threshold_is_not_drift() { // Pins `*d as f64 > drift_threshold` (strictly greater). At exactly // 2x baseline avg, must NOT report drift. // baseline avg = 100, threshold = 100 * 2.0 = 200. Recent values == 200. let durations = vec![ dur("a", 200), dur("b", 200), dur("c", 200), dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100), ]; assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none()); } #[test] fn duration_drift_just_over_threshold_detects() { let durations = vec![ dur("a", 201), dur("b", 201), dur("c", 201), dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100), ]; assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_some()); } #[test] fn duration_drift_zero_baseline_count_returns_none() { // Edge: baseline_count=0 means `baseline` slice is empty; should None // (pins the `if baseline.is_empty()` early return). let durations = vec![dur("a", 100), dur("b", 200)]; assert!(detect_test_duration_drift(&durations, 0, 2, 2.0).is_none()); } }