max / makenotwork
8 files changed,
+339 insertions,
-334 deletions
| @@ -11,7 +11,7 @@ use axum::routing::get; | |||
| 11 | 11 | use axum::{Json, Router}; | |
| 12 | 12 | use serde::Serialize; | |
| 13 | 13 | ||
| 14 | - | use crate::checks::http::{compute_test_staleness, detect_test_duration_drift}; | |
| 14 | + | use crate::checks::drift::{compute_test_staleness, detect_test_duration_drift}; | |
| 15 | 15 | use crate::config::Config; | |
| 16 | 16 | use crate::db; | |
| 17 | 17 | use crate::peer::SharedMeshState; |
| @@ -0,0 +1,327 @@ | |||
| 1 | + | //! Trend/staleness analysis over stored check history. | |
| 2 | + | //! | |
| 3 | + | //! Extracted from the HTTP probe (`checks/http.rs`): latency-drift, test-duration- | |
| 4 | + | //! drift, and test-staleness are computed over recorded data, not part of issuing | |
| 5 | + | //! a request. Kept here so the HTTP module is only the request/response path. | |
| 6 | + | ||
| 7 | + | /// Detect sustained latency drift by checking if all recent response times | |
| 8 | + | /// exceed the baseline average by the given threshold multiplier. | |
| 9 | + | /// | |
| 10 | + | /// Returns a description string if drift is detected, `None` otherwise. | |
| 11 | + | /// Requires at least 10 baseline samples to avoid false positives. | |
| 12 | + | pub fn detect_latency_drift( | |
| 13 | + | recent_times: &[i64], | |
| 14 | + | baseline: &crate::types::LatencyStats, | |
| 15 | + | threshold: f64, | |
| 16 | + | ) -> Option<String> { | |
| 17 | + | if baseline.sample_count < 10 || recent_times.is_empty() { | |
| 18 | + | return None; | |
| 19 | + | } | |
| 20 | + | let drift_threshold = baseline.avg_ms * threshold; | |
| 21 | + | let all_over = recent_times.iter().all(|&t| t as f64 > drift_threshold); | |
| 22 | + | if all_over { | |
| 23 | + | let avg_recent: f64 = recent_times.iter().sum::<i64>() as f64 / recent_times.len() as f64; | |
| 24 | + | Some(format!( | |
| 25 | + | "latency drift: last {} checks avg {:.0}ms (baseline avg {:.0}ms, threshold {:.0}ms)", | |
| 26 | + | recent_times.len(), | |
| 27 | + | avg_recent, | |
| 28 | + | baseline.avg_ms, | |
| 29 | + | drift_threshold, | |
| 30 | + | )) | |
| 31 | + | } else { | |
| 32 | + | None | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// Detect sustained test duration drift by checking if all recent durations | |
| 37 | + | /// exceed the baseline average by the given threshold multiplier. | |
| 38 | + | /// | |
| 39 | + | /// Returns a description string if drift is detected, `None` otherwise. | |
| 40 | + | /// Requires at least `baseline_count` samples for the baseline window. | |
| 41 | + | pub fn detect_test_duration_drift( | |
| 42 | + | durations: &[(String, i64)], | |
| 43 | + | baseline_count: usize, | |
| 44 | + | recent_count: usize, | |
| 45 | + | threshold: f64, | |
| 46 | + | ) -> Option<String> { | |
| 47 | + | if durations.len() < baseline_count + recent_count { | |
| 48 | + | return None; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // durations are ordered most recent first from get_test_durations | |
| 52 | + | let recent = &durations[..recent_count]; | |
| 53 | + | let baseline = &durations[recent_count..]; | |
| 54 | + | ||
| 55 | + | if baseline.is_empty() { | |
| 56 | + | return None; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | let baseline_avg = baseline.iter().map(|(_, d)| *d).sum::<i64>() as f64 / baseline.len() as f64; | |
| 60 | + | let drift_threshold = baseline_avg * threshold; | |
| 61 | + | ||
| 62 | + | let all_over = recent.iter().all(|(_, d)| *d as f64 > drift_threshold); | |
| 63 | + | if all_over { | |
| 64 | + | let recent_avg = recent.iter().map(|(_, d)| *d).sum::<i64>() as f64 / recent.len() as f64; | |
| 65 | + | Some(format!( | |
| 66 | + | "test duration drift: last {} runs avg {:.0}s (baseline avg {:.0}s, threshold {:.0}s)", | |
| 67 | + | recent_count, | |
| 68 | + | recent_avg, | |
| 69 | + | baseline_avg, | |
| 70 | + | drift_threshold, | |
| 71 | + | )) | |
| 72 | + | } else { | |
| 73 | + | None | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Compute test staleness from version and timing data. | |
| 78 | + | /// | |
| 79 | + | /// A target's tests are considered stale when: | |
| 80 | + | /// 1. No tests have ever been run | |
| 81 | + | /// 2. Tests are older than `staleness_days` | |
| 82 | + | /// 3. The deployed version has changed since the last test run | |
| 83 | + | pub fn compute_test_staleness( | |
| 84 | + | current_version: Option<&str>, | |
| 85 | + | tested_version: Option<&str>, | |
| 86 | + | last_test_at: Option<&str>, | |
| 87 | + | staleness_days: u64, | |
| 88 | + | ) -> crate::types::TestStaleness { | |
| 89 | + | let Some(last_test_at) = last_test_at else { | |
| 90 | + | return crate::types::TestStaleness { | |
| 91 | + | stale: true, | |
| 92 | + | reason: Some("no tests have been run".to_string()), | |
| 93 | + | current_version: current_version.map(String::from), | |
| 94 | + | tested_version: None, | |
| 95 | + | last_test_at: None, | |
| 96 | + | days_since_test: None, | |
| 97 | + | }; | |
| 98 | + | }; | |
| 99 | + | ||
| 100 | + | let days_since = chrono::DateTime::parse_from_rfc3339(last_test_at) | |
| 101 | + | .ok() | |
| 102 | + | .map(|dt| { | |
| 103 | + | let now = chrono::Utc::now(); | |
| 104 | + | (now - dt.with_timezone(&chrono::Utc)).num_days() | |
| 105 | + | }); | |
| 106 | + | ||
| 107 | + | if let Some(days) = days_since | |
| 108 | + | && days >= staleness_days as i64 | |
| 109 | + | { | |
| 110 | + | return crate::types::TestStaleness { | |
| 111 | + | stale: true, | |
| 112 | + | reason: Some(format!("tests are {days} days old (threshold: {staleness_days}d)")), | |
| 113 | + | current_version: current_version.map(String::from), | |
| 114 | + | tested_version: tested_version.map(String::from), | |
| 115 | + | last_test_at: Some(last_test_at.to_string()), | |
| 116 | + | days_since_test: Some(days), | |
| 117 | + | }; | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | if let (Some(current), Some(tested)) = (current_version, tested_version) | |
| 121 | + | && current != tested | |
| 122 | + | { | |
| 123 | + | return crate::types::TestStaleness { | |
| 124 | + | stale: true, | |
| 125 | + | reason: Some(format!("version changed: {tested} -> {current}")), | |
| 126 | + | current_version: Some(current.to_string()), | |
| 127 | + | tested_version: Some(tested.to_string()), | |
| 128 | + | last_test_at: Some(last_test_at.to_string()), | |
| 129 | + | days_since_test: days_since, | |
| 130 | + | }; | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | crate::types::TestStaleness { | |
| 134 | + | stale: false, | |
| 135 | + | reason: None, | |
| 136 | + | current_version: current_version.map(String::from), | |
| 137 | + | tested_version: tested_version.map(String::from), | |
| 138 | + | last_test_at: Some(last_test_at.to_string()), | |
| 139 | + | days_since_test: days_since, | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[cfg(test)] | |
| 144 | + | mod tests { | |
| 145 | + | use super::*; | |
| 146 | + | ||
| 147 | + | fn baseline(avg: f64, count: i64) -> crate::types::LatencyStats { | |
| 148 | + | crate::types::LatencyStats { | |
| 149 | + | min_ms: avg as i64 / 2, | |
| 150 | + | max_ms: avg as i64 * 2, | |
| 151 | + | avg_ms: avg, | |
| 152 | + | p95_ms: (avg * 1.5) as i64, | |
| 153 | + | sample_count: count, | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | fn dur(name: &str, secs: i64) -> (String, i64) { | |
| 158 | + | (name.to_string(), secs) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | #[test] | |
| 162 | + | fn drift_all_over_threshold() { | |
| 163 | + | let bl = baseline(100.0, 100); | |
| 164 | + | let recent = vec![250, 260, 270]; // all > 200 (100 * 2.0) | |
| 165 | + | let result = detect_latency_drift(&recent, &bl, 2.0); | |
| 166 | + | assert!(result.is_some()); | |
| 167 | + | assert!(result.unwrap().contains("latency drift")); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | #[test] | |
| 171 | + | fn drift_one_under_threshold() { | |
| 172 | + | let bl = baseline(100.0, 100); | |
| 173 | + | let recent = vec![250, 150, 270]; // 150 < 200 | |
| 174 | + | let result = detect_latency_drift(&recent, &bl, 2.0); | |
| 175 | + | assert!(result.is_none()); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[test] | |
| 179 | + | fn drift_insufficient_baseline() { | |
| 180 | + | let bl = baseline(100.0, 5); // < 10 samples | |
| 181 | + | let recent = vec![250, 260, 270]; | |
| 182 | + | let result = detect_latency_drift(&recent, &bl, 2.0); | |
| 183 | + | assert!(result.is_none()); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | #[test] | |
| 187 | + | fn drift_empty_recent() { | |
| 188 | + | let bl = baseline(100.0, 100); | |
| 189 | + | let result = detect_latency_drift(&[], &bl, 2.0); | |
| 190 | + | assert!(result.is_none()); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | #[test] | |
| 194 | + | fn drift_threshold_edge() { | |
| 195 | + | let bl = baseline(100.0, 100); | |
| 196 | + | // Exactly at threshold (200): not strictly over | |
| 197 | + | let recent = vec![200, 200, 200]; | |
| 198 | + | let result = detect_latency_drift(&recent, &bl, 2.0); | |
| 199 | + | assert!(result.is_none()); // must be strictly greater | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | #[test] | |
| 203 | + | fn drift_just_over_threshold() { | |
| 204 | + | let bl = baseline(100.0, 100); | |
| 205 | + | let recent = vec![201, 201, 201]; | |
| 206 | + | let result = detect_latency_drift(&recent, &bl, 2.0); | |
| 207 | + | assert!(result.is_some()); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | #[test] | |
| 211 | + | fn staleness_no_test_run() { | |
| 212 | + | let result = compute_test_staleness(Some("1.0.0"), None, None, 7); | |
| 213 | + | assert!(result.stale); | |
| 214 | + | assert_eq!(result.reason.as_deref(), Some("no tests have been run")); | |
| 215 | + | assert!(result.last_test_at.is_none()); | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | #[test] | |
| 219 | + | fn staleness_stale_by_age() { | |
| 220 | + | let old = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339(); | |
| 221 | + | let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&old), 7); | |
| 222 | + | assert!(result.stale); | |
| 223 | + | let reason = result.reason.unwrap(); | |
| 224 | + | assert!(reason.contains("days old"), "reason was: {reason}"); | |
| 225 | + | assert!(reason.contains("threshold: 7d"), "reason was: {reason}"); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn staleness_stale_by_version() { | |
| 230 | + | let recent = chrono::Utc::now().to_rfc3339(); | |
| 231 | + | let result = compute_test_staleness(Some("1.1.0"), Some("1.0.0"), Some(&recent), 7); | |
| 232 | + | assert!(result.stale); | |
| 233 | + | let reason = result.reason.unwrap(); | |
| 234 | + | assert!(reason.contains("version changed: 1.0.0 -> 1.1.0"), "reason was: {reason}"); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | #[test] | |
| 238 | + | fn staleness_fresh() { | |
| 239 | + | let recent = chrono::Utc::now().to_rfc3339(); | |
| 240 | + | let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&recent), 7); | |
| 241 | + | assert!(!result.stale); | |
| 242 | + | assert!(result.reason.is_none()); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[test] | |
| 246 | + | fn staleness_missing_versions_not_stale() { | |
| 247 | + | let recent = chrono::Utc::now().to_rfc3339(); | |
| 248 | + | let result = compute_test_staleness(None, None, Some(&recent), 7); | |
| 249 | + | assert!(!result.stale); | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | #[test] | |
| 253 | + | fn staleness_at_threshold_is_stale() { | |
| 254 | + | // Pins `days >= staleness_days` (vs `>`). days == 7, threshold == 7 | |
| 255 | + | // must report stale. | |
| 256 | + | let exactly = (chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339(); | |
| 257 | + | let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&exactly), 7); | |
| 258 | + | assert!(result.stale, "days == threshold must be stale"); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | #[test] | |
| 262 | + | fn staleness_one_day_under_threshold_is_fresh() { | |
| 263 | + | let just_under = (chrono::Utc::now() - chrono::Duration::days(6) - chrono::Duration::hours(20)).to_rfc3339(); | |
| 264 | + | let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&just_under), 7); | |
| 265 | + | assert!(!result.stale, "days < threshold must not be stale"); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | #[test] | |
| 269 | + | fn duration_drift_all_recent_over_threshold() { | |
| 270 | + | // 3 recent + 4 baseline, all recent > 2x baseline avg → drift detected. | |
| 271 | + | // baseline_count=4, recent_count=3, total=7 | |
| 272 | + | let durations = vec![ | |
| 273 | + | dur("a", 200), dur("b", 210), dur("c", 220), // recent (most recent first) | |
| 274 | + | dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110), // baseline avg = 95 | |
| 275 | + | ]; | |
| 276 | + | let result = detect_test_duration_drift(&durations, 4, 3, 2.0); | |
| 277 | + | let msg = result.expect("drift should be detected"); | |
| 278 | + | assert!(msg.contains("test duration drift")); | |
| 279 | + | assert!(msg.contains("last 3 runs")); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn duration_drift_one_recent_under_threshold_no_drift() { | |
| 284 | + | // One recent (105) is below 2x baseline avg (95 * 2 = 190). | |
| 285 | + | let durations = vec![ | |
| 286 | + | dur("a", 250), dur("b", 105), dur("c", 270), | |
| 287 | + | dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110), | |
| 288 | + | ]; | |
| 289 | + | assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none()); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | #[test] | |
| 293 | + | fn duration_drift_insufficient_samples() { | |
| 294 | + | // Total samples < baseline_count + recent_count → no drift, return None. | |
| 295 | + | let durations = vec![dur("a", 500), dur("b", 500), dur("c", 100)]; | |
| 296 | + | assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none()); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | #[test] | |
| 300 | + | fn duration_drift_at_threshold_is_not_drift() { | |
| 301 | + | // Pins `*d as f64 > drift_threshold` (strictly greater). At exactly | |
| 302 | + | // 2x baseline avg, must NOT report drift. | |
| 303 | + | // baseline avg = 100, threshold = 100 * 2.0 = 200. Recent values == 200. | |
| 304 | + | let durations = vec![ | |
| 305 | + | dur("a", 200), dur("b", 200), dur("c", 200), | |
| 306 | + | dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100), | |
| 307 | + | ]; | |
| 308 | + | assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none()); | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | #[test] | |
| 312 | + | fn duration_drift_just_over_threshold_detects() { | |
| 313 | + | let durations = vec![ | |
| 314 | + | dur("a", 201), dur("b", 201), dur("c", 201), | |
| 315 | + | dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100), | |
| 316 | + | ]; | |
| 317 | + | assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_some()); | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | #[test] | |
| 321 | + | fn duration_drift_zero_baseline_count_returns_none() { | |
| 322 | + | // Edge: baseline_count=0 means `baseline` slice is empty; should None | |
| 323 | + | // (pins the `if baseline.is_empty()` early return). | |
| 324 | + | let durations = vec![dur("a", 100), dur("b", 200)]; | |
| 325 | + | assert!(detect_test_duration_drift(&durations, 0, 2, 2.0).is_none()); | |
| 326 | + | } | |
| 327 | + | } |
| @@ -212,142 +212,6 @@ pub fn classify_non_json(status_code: u16) -> HealthStatus { | |||
| 212 | 212 | } | |
| 213 | 213 | } | |
| 214 | 214 | ||
| 215 | - | /// Detect sustained latency drift by checking if all recent response times | |
| 216 | - | /// exceed the baseline average by the given threshold multiplier. | |
| 217 | - | /// | |
| 218 | - | /// Returns a description string if drift is detected, `None` otherwise. | |
| 219 | - | /// Requires at least 10 baseline samples to avoid false positives. | |
| 220 | - | pub fn detect_latency_drift( | |
| 221 | - | recent_times: &[i64], | |
| 222 | - | baseline: &crate::types::LatencyStats, | |
| 223 | - | threshold: f64, | |
| 224 | - | ) -> Option<String> { | |
| 225 | - | if baseline.sample_count < 10 || recent_times.is_empty() { | |
| 226 | - | return None; | |
| 227 | - | } | |
| 228 | - | let drift_threshold = baseline.avg_ms * threshold; | |
| 229 | - | let all_over = recent_times.iter().all(|&t| t as f64 > drift_threshold); | |
| 230 | - | if all_over { | |
| 231 | - | let avg_recent: f64 = recent_times.iter().sum::<i64>() as f64 / recent_times.len() as f64; | |
| 232 | - | Some(format!( | |
| 233 | - | "latency drift: last {} checks avg {:.0}ms (baseline avg {:.0}ms, threshold {:.0}ms)", | |
| 234 | - | recent_times.len(), | |
| 235 | - | avg_recent, | |
| 236 | - | baseline.avg_ms, | |
| 237 | - | drift_threshold, | |
| 238 | - | )) | |
| 239 | - | } else { | |
| 240 | - | None | |
| 241 | - | } | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | /// Detect sustained test duration drift by checking if all recent durations | |
| 245 | - | /// exceed the baseline average by the given threshold multiplier. | |
| 246 | - | /// | |
| 247 | - | /// Returns a description string if drift is detected, `None` otherwise. | |
| 248 | - | /// Requires at least `baseline_count` samples for the baseline window. | |
| 249 | - | pub fn detect_test_duration_drift( | |
| 250 | - | durations: &[(String, i64)], | |
| 251 | - | baseline_count: usize, | |
| 252 | - | recent_count: usize, | |
| 253 | - | threshold: f64, | |
| 254 | - | ) -> Option<String> { | |
| 255 | - | if durations.len() < baseline_count + recent_count { | |
| 256 | - | return None; | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | // durations are ordered most recent first from get_test_durations | |
| 260 | - | let recent = &durations[..recent_count]; | |
| 261 | - | let baseline = &durations[recent_count..]; | |
| 262 | - | ||
| 263 | - | if baseline.is_empty() { | |
| 264 | - | return None; | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | let baseline_avg = baseline.iter().map(|(_, d)| *d).sum::<i64>() as f64 / baseline.len() as f64; | |
| 268 | - | let drift_threshold = baseline_avg * threshold; | |
| 269 | - | ||
| 270 | - | let all_over = recent.iter().all(|(_, d)| *d as f64 > drift_threshold); | |
| 271 | - | if all_over { | |
| 272 | - | let recent_avg = recent.iter().map(|(_, d)| *d).sum::<i64>() as f64 / recent.len() as f64; | |
| 273 | - | Some(format!( | |
| 274 | - | "test duration drift: last {} runs avg {:.0}s (baseline avg {:.0}s, threshold {:.0}s)", | |
| 275 | - | recent_count, | |
| 276 | - | recent_avg, | |
| 277 | - | baseline_avg, | |
| 278 | - | drift_threshold, | |
| 279 | - | )) | |
| 280 | - | } else { | |
| 281 | - | None | |
| 282 | - | } | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | /// Compute test staleness from version and timing data. | |
| 286 | - | /// | |
| 287 | - | /// A target's tests are considered stale when: | |
| 288 | - | /// 1. No tests have ever been run | |
| 289 | - | /// 2. Tests are older than `staleness_days` | |
| 290 | - | /// 3. The deployed version has changed since the last test run | |
| 291 | - | pub fn compute_test_staleness( | |
| 292 | - | current_version: Option<&str>, | |
| 293 | - | tested_version: Option<&str>, | |
| 294 | - | last_test_at: Option<&str>, | |
| 295 | - | staleness_days: u64, | |
| 296 | - | ) -> crate::types::TestStaleness { | |
| 297 | - | let Some(last_test_at) = last_test_at else { | |
| 298 | - | return crate::types::TestStaleness { | |
| 299 | - | stale: true, | |
| 300 | - | reason: Some("no tests have been run".to_string()), | |
| 301 | - | current_version: current_version.map(String::from), | |
| 302 | - | tested_version: None, | |
| 303 | - | last_test_at: None, | |
| 304 | - | days_since_test: None, | |
| 305 | - | }; | |
| 306 | - | }; | |
| 307 | - | ||
| 308 | - | let days_since = chrono::DateTime::parse_from_rfc3339(last_test_at) | |
| 309 | - | .ok() | |
| 310 | - | .map(|dt| { | |
| 311 | - | let now = chrono::Utc::now(); | |
| 312 | - | (now - dt.with_timezone(&chrono::Utc)).num_days() | |
| 313 | - | }); | |
| 314 | - | ||
| 315 | - | if let Some(days) = days_since | |
| 316 | - | && days >= staleness_days as i64 | |
| 317 | - | { | |
| 318 | - | return crate::types::TestStaleness { | |
| 319 | - | stale: true, | |
| 320 | - | reason: Some(format!("tests are {days} days old (threshold: {staleness_days}d)")), | |
| 321 | - | current_version: current_version.map(String::from), | |
| 322 | - | tested_version: tested_version.map(String::from), | |
| 323 | - | last_test_at: Some(last_test_at.to_string()), | |
| 324 | - | days_since_test: Some(days), | |
| 325 | - | }; | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | if let (Some(current), Some(tested)) = (current_version, tested_version) | |
| 329 | - | && current != tested | |
| 330 | - | { | |
| 331 | - | return crate::types::TestStaleness { | |
| 332 | - | stale: true, | |
| 333 | - | reason: Some(format!("version changed: {tested} -> {current}")), | |
| 334 | - | current_version: Some(current.to_string()), | |
| 335 | - | tested_version: Some(tested.to_string()), | |
| 336 | - | last_test_at: Some(last_test_at.to_string()), | |
| 337 | - | days_since_test: days_since, | |
| 338 | - | }; | |
| 339 | - | } | |
| 340 | - | ||
| 341 | - | crate::types::TestStaleness { | |
| 342 | - | stale: false, | |
| 343 | - | reason: None, | |
| 344 | - | current_version: current_version.map(String::from), | |
| 345 | - | tested_version: tested_version.map(String::from), | |
| 346 | - | last_test_at: Some(last_test_at.to_string()), | |
| 347 | - | days_since_test: days_since, | |
| 348 | - | } | |
| 349 | - | } | |
| 350 | - | ||
| 351 | 215 | #[cfg(test)] | |
| 352 | 216 | mod tests { | |
| 353 | 217 | use super::*; | |
| @@ -576,127 +440,6 @@ mod tests { | |||
| 576 | 440 | assert!(failures.is_empty()); | |
| 577 | 441 | } | |
| 578 | 442 | ||
| 579 | - | // --- detect_latency_drift --- | |
| 580 | - | ||
| 581 | - | fn baseline(avg: f64, count: i64) -> crate::types::LatencyStats { | |
| 582 | - | crate::types::LatencyStats { | |
| 583 | - | min_ms: avg as i64 / 2, | |
| 584 | - | max_ms: avg as i64 * 2, | |
| 585 | - | avg_ms: avg, | |
| 586 | - | p95_ms: (avg * 1.5) as i64, | |
| 587 | - | sample_count: count, | |
| 588 | - | } | |
| 589 | - | } | |
| 590 | - | ||
| 591 | - | #[test] | |
| 592 | - | fn drift_all_over_threshold() { | |
| 593 | - | let bl = baseline(100.0, 100); | |
| 594 | - | let recent = vec![250, 260, 270]; // all > 200 (100 * 2.0) | |
| 595 | - | let result = detect_latency_drift(&recent, &bl, 2.0); | |
| 596 | - | assert!(result.is_some()); | |
| 597 | - | assert!(result.unwrap().contains("latency drift")); | |
| 598 | - | } | |
| 599 | - | ||
| 600 | - | #[test] | |
| 601 | - | fn drift_one_under_threshold() { | |
| 602 | - | let bl = baseline(100.0, 100); | |
| 603 | - | let recent = vec![250, 150, 270]; // 150 < 200 | |
| 604 | - | let result = detect_latency_drift(&recent, &bl, 2.0); | |
| 605 | - | assert!(result.is_none()); | |
| 606 | - | } | |
| 607 | - | ||
| 608 | - | #[test] | |
| 609 | - | fn drift_insufficient_baseline() { | |
| 610 | - | let bl = baseline(100.0, 5); // < 10 samples | |
| 611 | - | let recent = vec![250, 260, 270]; | |
| 612 | - | let result = detect_latency_drift(&recent, &bl, 2.0); | |
| 613 | - | assert!(result.is_none()); | |
| 614 | - | } | |
| 615 | - | ||
| 616 | - | #[test] | |
| 617 | - | fn drift_empty_recent() { | |
| 618 | - | let bl = baseline(100.0, 100); | |
| 619 | - | let result = detect_latency_drift(&[], &bl, 2.0); | |
| 620 | - | assert!(result.is_none()); | |
| 621 | - | } | |
| 622 | - | ||
| 623 | - | #[test] | |
| 624 | - | fn drift_threshold_edge() { | |
| 625 | - | let bl = baseline(100.0, 100); | |
| 626 | - | // Exactly at threshold (200): not strictly over | |
| 627 | - | let recent = vec![200, 200, 200]; | |
| 628 | - | let result = detect_latency_drift(&recent, &bl, 2.0); | |
| 629 | - | assert!(result.is_none()); // must be strictly greater | |
| 630 | - | } | |
| 631 | - | ||
| 632 | - | #[test] | |
| 633 | - | fn drift_just_over_threshold() { | |
| 634 | - | let bl = baseline(100.0, 100); | |
| 635 | - | let recent = vec![201, 201, 201]; | |
| 636 | - | let result = detect_latency_drift(&recent, &bl, 2.0); | |
| 637 | - | assert!(result.is_some()); | |
| 638 | - | } | |
| 639 | - | ||
| 640 | - | // --- compute_test_staleness --- | |
| 641 | - | ||
| 642 | - | #[test] | |
| 643 | - | fn staleness_no_test_run() { | |
| 644 | - | let result = compute_test_staleness(Some("1.0.0"), None, None, 7); | |
| 645 | - | assert!(result.stale); | |
| 646 | - | assert_eq!(result.reason.as_deref(), Some("no tests have been run")); | |
| 647 | - | assert!(result.last_test_at.is_none()); | |
| 648 | - | } | |
| 649 | - | ||
| 650 | - | #[test] | |
| 651 | - | fn staleness_stale_by_age() { | |
| 652 | - | let old = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339(); | |
| 653 | - | let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&old), 7); | |
| 654 | - | assert!(result.stale); | |
| 655 | - | let reason = result.reason.unwrap(); | |
| 656 | - | assert!(reason.contains("days old"), "reason was: {reason}"); | |
| 657 | - | assert!(reason.contains("threshold: 7d"), "reason was: {reason}"); | |
| 658 | - | } | |
| 659 | - | ||
| 660 | - | #[test] | |
| 661 | - | fn staleness_stale_by_version() { | |
| 662 | - | let recent = chrono::Utc::now().to_rfc3339(); | |
| 663 | - | let result = compute_test_staleness(Some("1.1.0"), Some("1.0.0"), Some(&recent), 7); | |
| 664 | - | assert!(result.stale); | |
| 665 | - | let reason = result.reason.unwrap(); | |
| 666 | - | assert!(reason.contains("version changed: 1.0.0 -> 1.1.0"), "reason was: {reason}"); | |
| 667 | - | } | |
| 668 | - | ||
| 669 | - | #[test] | |
| 670 | - | fn staleness_fresh() { | |
| 671 | - | let recent = chrono::Utc::now().to_rfc3339(); | |
| 672 | - | let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&recent), 7); | |
| 673 | - | assert!(!result.stale); | |
| 674 | - | assert!(result.reason.is_none()); | |
| 675 | - | } | |
| 676 | - | ||
| 677 | - | #[test] | |
| 678 | - | fn staleness_missing_versions_not_stale() { | |
| 679 | - | let recent = chrono::Utc::now().to_rfc3339(); | |
| 680 | - | let result = compute_test_staleness(None, None, Some(&recent), 7); | |
| 681 | - | assert!(!result.stale); | |
| 682 | - | } | |
| 683 | - | ||
| 684 | - | #[test] | |
| 685 | - | fn staleness_at_threshold_is_stale() { | |
| 686 | - | // Pins `days >= staleness_days` (vs `>`). days == 7, threshold == 7 | |
| 687 | - | // must report stale. | |
| 688 | - | let exactly = (chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339(); | |
| 689 | - | let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&exactly), 7); | |
| 690 | - | assert!(result.stale, "days == threshold must be stale"); | |
| 691 | - | } | |
| 692 | - | ||
| 693 | - | #[test] | |
| 694 | - | fn staleness_one_day_under_threshold_is_fresh() { | |
| 695 | - | let just_under = (chrono::Utc::now() - chrono::Duration::days(6) - chrono::Duration::hours(20)).to_rfc3339(); | |
| 696 | - | let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&just_under), 7); | |
| 697 | - | assert!(!result.stale, "days < threshold must not be stale"); | |
| 698 | - | } | |
| 699 | - | ||
| 700 | 443 | // --- classify_non_json: 200..300 range boundaries --- | |
| 701 | 444 | ||
| 702 | 445 | #[test] | |
| @@ -718,72 +461,6 @@ mod tests { | |||
| 718 | 461 | assert_eq!(classify_json_response(199, &json).0, HealthStatus::Error); | |
| 719 | 462 | } | |
| 720 | 463 | ||
| 721 | - | // --- detect_test_duration_drift --- | |
| 722 | - | ||
| 723 | - | fn dur(name: &str, secs: i64) -> (String, i64) { | |
| 724 | - | (name.to_string(), secs) | |
| 725 | - | } | |
| 726 | - | ||
| 727 | - | #[test] | |
| 728 | - | fn duration_drift_all_recent_over_threshold() { | |
| 729 | - | // 3 recent + 4 baseline, all recent > 2x baseline avg → drift detected. | |
| 730 | - | // baseline_count=4, recent_count=3, total=7 | |
| 731 | - | let durations = vec![ | |
| 732 | - | dur("a", 200), dur("b", 210), dur("c", 220), // recent (most recent first) | |
| 733 | - | dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110), // baseline avg = 95 | |
| 734 | - | ]; | |
| 735 | - | let result = detect_test_duration_drift(&durations, 4, 3, 2.0); | |
| 736 | - | let msg = result.expect("drift should be detected"); | |
| 737 | - | assert!(msg.contains("test duration drift")); | |
| 738 | - | assert!(msg.contains("last 3 runs")); | |
| 739 | - | } | |
| 740 | - | ||
| 741 | - | #[test] | |
| 742 | - | fn duration_drift_one_recent_under_threshold_no_drift() { | |
| 743 | - | // One recent (105) is below 2x baseline avg (95 * 2 = 190). | |
| 744 | - | let durations = vec![ | |
| 745 | - | dur("a", 250), dur("b", 105), dur("c", 270), | |
| 746 | - | dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110), | |
| 747 | - | ]; | |
| 748 | - | assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none()); | |
| 749 | - | } | |
| 750 | - | ||
| 751 | - | #[test] | |
| 752 | - | fn duration_drift_insufficient_samples() { | |
| 753 | - | // Total samples < baseline_count + recent_count → no drift, return None. | |
| 754 | - | let durations = vec![dur("a", 500), dur("b", 500), dur("c", 100)]; | |
| 755 | - | assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none()); | |
| 756 | - | } | |
| 757 | - | ||
| 758 | - | #[test] | |
| 759 | - | fn duration_drift_at_threshold_is_not_drift() { | |
| 760 | - | // Pins `*d as f64 > drift_threshold` (strictly greater). At exactly | |
| 761 | - | // 2x baseline avg, must NOT report drift. | |
| 762 | - | // baseline avg = 100, threshold = 100 * 2.0 = 200. Recent values == 200. | |
| 763 | - | let durations = vec![ | |
| 764 | - | dur("a", 200), dur("b", 200), dur("c", 200), | |
| 765 | - | dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100), | |
| 766 | - | ]; | |
| 767 | - | assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none()); | |
| 768 | - | } | |
| 769 | - | ||
| 770 | - | #[test] | |
| 771 | - | fn duration_drift_just_over_threshold_detects() { | |
| 772 | - | let durations = vec![ | |
| 773 | - | dur("a", 201), dur("b", 201), dur("c", 201), | |
| 774 | - | dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100), | |
| 775 | - | ]; | |
| 776 | - | assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_some()); | |
| 777 | - | } | |
| 778 | - | ||
| 779 | - | #[test] | |
| 780 | - | fn duration_drift_zero_baseline_count_returns_none() { | |
| 781 | - | // Edge: baseline_count=0 means `baseline` slice is empty; should None | |
| 782 | - | // (pins the `if baseline.is_empty()` early return). | |
| 783 | - | let durations = vec![dur("a", 100), dur("b", 200)]; | |
| 784 | - | assert!(detect_test_duration_drift(&durations, 0, 2, 2.0).is_none()); | |
| 785 | - | } | |
| 786 | - | ||
| 787 | 464 | // --- resolve_json_path edge cases --- | |
| 788 | 465 | ||
| 789 | 466 | #[test] |
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | pub mod backup; | |
| 2 | 2 | pub mod cors; | |
| 3 | 3 | pub mod dns; | |
| 4 | + | pub mod drift; | |
| 4 | 5 | pub mod http; | |
| 5 | 6 | pub mod parse; | |
| 6 | 7 | pub mod routes; |
| @@ -1,4 +1,4 @@ | |||
| 1 | - | use pom::checks::http; | |
| 1 | + | use pom::checks::drift; | |
| 2 | 2 | use pom::config::Config; | |
| 3 | 3 | use pom::db; | |
| 4 | 4 | use pom::display; | |
| @@ -45,7 +45,7 @@ pub(crate) async fn cmd_status( | |||
| 45 | 45 | None | |
| 46 | 46 | }; | |
| 47 | 47 | ||
| 48 | - | Some(http::compute_test_staleness( | |
| 48 | + | Some(drift::compute_test_staleness( | |
| 49 | 49 | current_version.as_deref(), | |
| 50 | 50 | tested_version.as_deref(), | |
| 51 | 51 | test.as_ref().map(|t| t.started_at.as_str()), | |
| @@ -62,7 +62,7 @@ pub(crate) async fn cmd_status( | |||
| 62 | 62 | vec![] | |
| 63 | 63 | }; | |
| 64 | 64 | let duration_drift = if !test_durations.is_empty() { | |
| 65 | - | http::detect_test_duration_drift(&test_durations, 10, 3, 1.5) | |
| 65 | + | drift::detect_test_duration_drift(&test_durations, 10, 3, 1.5) | |
| 66 | 66 | } else { | |
| 67 | 67 | None | |
| 68 | 68 | }; |
| @@ -2,7 +2,7 @@ use tokio::task::JoinHandle; | |||
| 2 | 2 | use tracing::info; | |
| 3 | 3 | ||
| 4 | 4 | use pom::alerts::Alerter; | |
| 5 | - | use pom::checks::{http, ssh_banner}; | |
| 5 | + | use pom::checks::{drift, http, ssh_banner}; | |
| 6 | 6 | use pom::config::Config; | |
| 7 | 7 | use pom::db; | |
| 8 | 8 | use pom::types::{HealthStatus, LatencyStats}; | |
| @@ -123,7 +123,7 @@ pub(crate) fn spawn_health_tasks( | |||
| 123 | 123 | .unwrap_or_default(); | |
| 124 | 124 | ||
| 125 | 125 | if let Some(ref bl) = baseline { | |
| 126 | - | if let Some(msg) = http::detect_latency_drift(&recent, bl, trending.spike_threshold) { | |
| 126 | + | if let Some(msg) = drift::detect_latency_drift(&recent, bl, trending.spike_threshold) { | |
| 127 | 127 | if !in_drift { | |
| 128 | 128 | info!("{name}: {msg}"); | |
| 129 | 129 | if let Some(ref alerter) = alerter { |
| @@ -2,7 +2,7 @@ use schemars::JsonSchema; | |||
| 2 | 2 | use serde::Deserialize; | |
| 3 | 3 | use tracing::instrument; | |
| 4 | 4 | ||
| 5 | - | use crate::checks::http; | |
| 5 | + | use crate::checks::{drift, http}; | |
| 6 | 6 | use crate::db; | |
| 7 | 7 | use crate::types::{LatencyStats, TargetInfo}; | |
| 8 | 8 | ||
| @@ -129,7 +129,7 @@ impl PomServer { | |||
| 129 | 129 | None | |
| 130 | 130 | }; | |
| 131 | 131 | ||
| 132 | - | let staleness = http::compute_test_staleness( | |
| 132 | + | let staleness = drift::compute_test_staleness( | |
| 133 | 133 | current_version.as_deref(), | |
| 134 | 134 | tested_version.as_deref(), | |
| 135 | 135 | latest_test.as_ref().map(|t| t.started_at.as_str()), |
| @@ -3013,7 +3013,7 @@ async fn regression_ignores_already_failing_tests() { | |||
| 3013 | 3013 | ||
| 3014 | 3014 | #[tokio::test] | |
| 3015 | 3015 | async fn test_duration_drift_detected() { | |
| 3016 | - | use pom::checks::http::detect_test_duration_drift; | |
| 3016 | + | use pom::checks::drift::detect_test_duration_drift; | |
| 3017 | 3017 | ||
| 3018 | 3018 | // 10 baseline runs at 60s, 3 recent runs at 120s (2x baseline > 1.5x threshold) | |
| 3019 | 3019 | let mut durations: Vec<(String, i64)> = Vec::new(); | |
| @@ -3033,7 +3033,7 @@ async fn test_duration_drift_detected() { | |||
| 3033 | 3033 | ||
| 3034 | 3034 | #[tokio::test] | |
| 3035 | 3035 | async fn test_duration_no_drift_when_stable() { | |
| 3036 | - | use pom::checks::http::detect_test_duration_drift; | |
| 3036 | + | use pom::checks::drift::detect_test_duration_drift; | |
| 3037 | 3037 | ||
| 3038 | 3038 | // All runs at ~60s | |
| 3039 | 3039 | let mut durations: Vec<(String, i64)> = Vec::new(); | |
| @@ -3047,7 +3047,7 @@ async fn test_duration_no_drift_when_stable() { | |||
| 3047 | 3047 | ||
| 3048 | 3048 | #[tokio::test] | |
| 3049 | 3049 | async fn test_duration_drift_not_enough_data() { | |
| 3050 | - | use pom::checks::http::detect_test_duration_drift; | |
| 3050 | + | use pom::checks::drift::detect_test_duration_drift; | |
| 3051 | 3051 | ||
| 3052 | 3052 | // Only 5 runs (need 13 for baseline 10 + recent 3) | |
| 3053 | 3053 | let durations: Vec<(String, i64)> = (0..5) |