Skip to main content

max / makenotwork

11.9 KB · 351 lines History Blame Raw
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 {recent_count} runs avg {recent_avg:.0}s (baseline avg {baseline_avg:.0}s, threshold {drift_threshold:.0}s)",
67 ))
68 } else {
69 None
70 }
71 }
72
73 /// Compute test staleness from version and timing data.
74 ///
75 /// A target's tests are considered stale when:
76 /// 1. No tests have ever been run
77 /// 2. Tests are older than `staleness_days`
78 /// 3. The deployed version has changed since the last test run
79 pub fn compute_test_staleness(
80 current_version: Option<&str>,
81 tested_version: Option<&str>,
82 last_test_at: Option<&str>,
83 staleness_days: u64,
84 ) -> crate::types::TestStaleness {
85 let Some(last_test_at) = last_test_at else {
86 return crate::types::TestStaleness {
87 stale: true,
88 reason: Some("no tests have been run".to_string()),
89 current_version: current_version.map(String::from),
90 tested_version: None,
91 last_test_at: None,
92 days_since_test: None,
93 };
94 };
95
96 let days_since = chrono::DateTime::parse_from_rfc3339(last_test_at)
97 .ok()
98 .map(|dt| {
99 let now = chrono::Utc::now();
100 (now - dt.with_timezone(&chrono::Utc)).num_days()
101 });
102
103 if let Some(days) = days_since
104 && days >= staleness_days as i64
105 {
106 return crate::types::TestStaleness {
107 stale: true,
108 reason: Some(format!(
109 "tests are {days} days old (threshold: {staleness_days}d)"
110 )),
111 current_version: current_version.map(String::from),
112 tested_version: tested_version.map(String::from),
113 last_test_at: Some(last_test_at.to_string()),
114 days_since_test: Some(days),
115 };
116 }
117
118 if let (Some(current), Some(tested)) = (current_version, tested_version)
119 && current != tested
120 {
121 return crate::types::TestStaleness {
122 stale: true,
123 reason: Some(format!("version changed: {tested} -> {current}")),
124 current_version: Some(current.to_string()),
125 tested_version: Some(tested.to_string()),
126 last_test_at: Some(last_test_at.to_string()),
127 days_since_test: days_since,
128 };
129 }
130
131 crate::types::TestStaleness {
132 stale: false,
133 reason: None,
134 current_version: current_version.map(String::from),
135 tested_version: tested_version.map(String::from),
136 last_test_at: Some(last_test_at.to_string()),
137 days_since_test: days_since,
138 }
139 }
140
141 #[cfg(test)]
142 mod tests {
143 use super::*;
144
145 fn baseline(avg: f64, count: i64) -> crate::types::LatencyStats {
146 crate::types::LatencyStats {
147 min_ms: avg as i64 / 2,
148 max_ms: avg as i64 * 2,
149 avg_ms: avg,
150 p95_ms: (avg * 1.5) as i64,
151 sample_count: count,
152 }
153 }
154
155 fn dur(name: &str, secs: i64) -> (String, i64) {
156 (name.to_string(), secs)
157 }
158
159 #[test]
160 fn drift_all_over_threshold() {
161 let bl = baseline(100.0, 100);
162 let recent = vec![250, 260, 270]; // all > 200 (100 * 2.0)
163 let result = detect_latency_drift(&recent, &bl, 2.0);
164 assert!(result.is_some());
165 assert!(result.unwrap().contains("latency drift"));
166 }
167
168 #[test]
169 fn drift_one_under_threshold() {
170 let bl = baseline(100.0, 100);
171 let recent = vec![250, 150, 270]; // 150 < 200
172 let result = detect_latency_drift(&recent, &bl, 2.0);
173 assert!(result.is_none());
174 }
175
176 #[test]
177 fn drift_insufficient_baseline() {
178 let bl = baseline(100.0, 5); // < 10 samples
179 let recent = vec![250, 260, 270];
180 let result = detect_latency_drift(&recent, &bl, 2.0);
181 assert!(result.is_none());
182 }
183
184 #[test]
185 fn drift_empty_recent() {
186 let bl = baseline(100.0, 100);
187 let result = detect_latency_drift(&[], &bl, 2.0);
188 assert!(result.is_none());
189 }
190
191 #[test]
192 fn drift_threshold_edge() {
193 let bl = baseline(100.0, 100);
194 // Exactly at threshold (200): not strictly over
195 let recent = vec![200, 200, 200];
196 let result = detect_latency_drift(&recent, &bl, 2.0);
197 assert!(result.is_none()); // must be strictly greater
198 }
199
200 #[test]
201 fn drift_just_over_threshold() {
202 let bl = baseline(100.0, 100);
203 let recent = vec![201, 201, 201];
204 let result = detect_latency_drift(&recent, &bl, 2.0);
205 assert!(result.is_some());
206 }
207
208 #[test]
209 fn staleness_no_test_run() {
210 let result = compute_test_staleness(Some("1.0.0"), None, None, 7);
211 assert!(result.stale);
212 assert_eq!(result.reason.as_deref(), Some("no tests have been run"));
213 assert!(result.last_test_at.is_none());
214 }
215
216 #[test]
217 fn staleness_stale_by_age() {
218 let old = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
219 let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&old), 7);
220 assert!(result.stale);
221 let reason = result.reason.unwrap();
222 assert!(reason.contains("days old"), "reason was: {reason}");
223 assert!(reason.contains("threshold: 7d"), "reason was: {reason}");
224 }
225
226 #[test]
227 fn staleness_stale_by_version() {
228 let recent = chrono::Utc::now().to_rfc3339();
229 let result = compute_test_staleness(Some("1.1.0"), Some("1.0.0"), Some(&recent), 7);
230 assert!(result.stale);
231 let reason = result.reason.unwrap();
232 assert!(
233 reason.contains("version changed: 1.0.0 -> 1.1.0"),
234 "reason was: {reason}"
235 );
236 }
237
238 #[test]
239 fn staleness_fresh() {
240 let recent = chrono::Utc::now().to_rfc3339();
241 let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&recent), 7);
242 assert!(!result.stale);
243 assert!(result.reason.is_none());
244 }
245
246 #[test]
247 fn staleness_missing_versions_not_stale() {
248 let recent = chrono::Utc::now().to_rfc3339();
249 let result = compute_test_staleness(None, None, Some(&recent), 7);
250 assert!(!result.stale);
251 }
252
253 #[test]
254 fn staleness_at_threshold_is_stale() {
255 // Pins `days >= staleness_days` (vs `>`). days == 7, threshold == 7
256 // must report stale.
257 let exactly = (chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339();
258 let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&exactly), 7);
259 assert!(result.stale, "days == threshold must be stale");
260 }
261
262 #[test]
263 fn staleness_one_day_under_threshold_is_fresh() {
264 let just_under =
265 (chrono::Utc::now() - chrono::Duration::days(6) - chrono::Duration::hours(20))
266 .to_rfc3339();
267 let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&just_under), 7);
268 assert!(!result.stale, "days < threshold must not be stale");
269 }
270
271 #[test]
272 fn duration_drift_all_recent_over_threshold() {
273 // 3 recent + 4 baseline, all recent > 2x baseline avg → drift detected.
274 // baseline_count=4, recent_count=3, total=7
275 let durations = vec![
276 dur("a", 200),
277 dur("b", 210),
278 dur("c", 220), // recent (most recent first)
279 dur("d", 80),
280 dur("e", 100),
281 dur("f", 90),
282 dur("g", 110), // baseline avg = 95
283 ];
284 let result = detect_test_duration_drift(&durations, 4, 3, 2.0);
285 let msg = result.expect("drift should be detected");
286 assert!(msg.contains("test duration drift"));
287 assert!(msg.contains("last 3 runs"));
288 }
289
290 #[test]
291 fn duration_drift_one_recent_under_threshold_no_drift() {
292 // One recent (105) is below 2x baseline avg (95 * 2 = 190).
293 let durations = vec![
294 dur("a", 250),
295 dur("b", 105),
296 dur("c", 270),
297 dur("d", 80),
298 dur("e", 100),
299 dur("f", 90),
300 dur("g", 110),
301 ];
302 assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
303 }
304
305 #[test]
306 fn duration_drift_insufficient_samples() {
307 // Total samples < baseline_count + recent_count → no drift, return None.
308 let durations = vec![dur("a", 500), dur("b", 500), dur("c", 100)];
309 assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
310 }
311
312 #[test]
313 fn duration_drift_at_threshold_is_not_drift() {
314 // Pins `*d as f64 > drift_threshold` (strictly greater). At exactly
315 // 2x baseline avg, must NOT report drift.
316 // baseline avg = 100, threshold = 100 * 2.0 = 200. Recent values == 200.
317 let durations = vec![
318 dur("a", 200),
319 dur("b", 200),
320 dur("c", 200),
321 dur("d", 100),
322 dur("e", 100),
323 dur("f", 100),
324 dur("g", 100),
325 ];
326 assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
327 }
328
329 #[test]
330 fn duration_drift_just_over_threshold_detects() {
331 let durations = vec![
332 dur("a", 201),
333 dur("b", 201),
334 dur("c", 201),
335 dur("d", 100),
336 dur("e", 100),
337 dur("f", 100),
338 dur("g", 100),
339 ];
340 assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_some());
341 }
342
343 #[test]
344 fn duration_drift_zero_baseline_count_returns_none() {
345 // Edge: baseline_count=0 means `baseline` slice is empty; should None
346 // (pins the `if baseline.is_empty()` early return).
347 let durations = vec![dur("a", 100), dur("b", 200)];
348 assert!(detect_test_duration_drift(&durations, 0, 2, 2.0).is_none());
349 }
350 }
351