1use inspector_core::{Diagnosis, FindingStatus, MetricKind, Snapshot};
3
4pub const DEFAULT_THRESHOLD_RATIO: f64 = 1.5;
7pub const MIN_SAMPLES: u64 = 30;
8
9pub fn investigate_latency(
10 base: &Snapshot,
11 current: &Snapshot,
12 base_id: &str,
13 current_id: &str,
14) -> Diagnosis {
15 investigate_latency_with_threshold(base, current, base_id, current_id, DEFAULT_THRESHOLD_RATIO)
16}
17
18pub fn investigate_latency_with_threshold(
19 base: &Snapshot,
20 current: &Snapshot,
21 base_id: &str,
22 current_id: &str,
23 threshold_ratio: f64,
24) -> Diagnosis {
25 let evidence = vec![base_id.to_string(), current_id.to_string()];
26 let (bp50, bcount) = latency_p50_count(base);
27 let (cp50, ccount) = latency_p50_count(current);
28
29 match (bp50, cp50) {
30 (Some(b), Some(c)) if b > 0.0 => {
31 if ccount < MIN_SAMPLES {
32 return Diagnosis {
33 status: FindingStatus::Observed,
34 finding: format!(
35 "insufficient samples for verdict (current count {ccount} < {MIN_SAMPLES})"
36 ),
37 hypothesis: None,
38 confidence: 0.4,
39 evidence,
40 verification: Some("capture a longer window, then re-compare".into()),
41 };
42 }
43 let ratio = c / b;
44 let _ = bcount;
45 if ratio >= threshold_ratio {
46 Diagnosis {
47 status: FindingStatus::Hypothesis,
48 finding: format!("request latency rose {b:.0}ms -> {c:.0}ms (x{ratio:.1})"),
49 hypothesis: Some(
50 "increase may be associated with resource contention or blocking (see task/resource evidence)".into(),
51 ),
52 confidence: 0.72,
53 evidence,
54 verification: Some(
55 "reproduce with controlled workload; remove suspected bottleneck and re-capture".into(),
56 ),
57 }
58 } else {
59 Diagnosis {
60 status: FindingStatus::NoIssue,
61 finding: format!(
62 "no latency regression (ratio x{ratio:.2}, threshold x{threshold_ratio})"
63 ),
64 hypothesis: None,
65 confidence: 0.9,
66 evidence,
67 verification: None,
68 }
69 }
70 }
71 _ => Diagnosis {
72 status: FindingStatus::Observed,
73 finding: "latency data missing in one or both snapshots; no verdict possible".into(),
74 hypothesis: None,
75 confidence: 0.3,
76 evidence,
77 verification: Some(
78 "check completeness.missing_fields for histogram availability".into(),
79 ),
80 },
81 }
82}
83
84fn latency_p50_count(snap: &Snapshot) -> (Option<f64>, u64) {
85 for m in &snap.metrics {
86 if m.name == "request_latency_ms" {
87 if let MetricKind::Histogram { p50, count, .. } = &m.kind {
88 return (*p50, *count);
89 }
90 }
91 }
92 (None, 0)
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use inspector_core::{MetricObservation, RuntimeOverview};
99
100 fn snap_with(p50: Option<f64>, count: u64) -> Snapshot {
101 Snapshot {
102 overview: RuntimeOverview {
103 service_name: None,
104 console_addr: "t".into(),
105 active_tasks: Some(10),
106 completed_tasks: None,
107 tasks_truncated: false,
108 metric_names: vec![],
109 },
110 tasks: vec![],
111 resources: vec![],
112 metrics: p50
113 .map(|p| {
114 vec![MetricObservation {
115 name: "request_latency_ms".into(),
116 kind: MetricKind::Histogram {
117 count,
118 sum: p * count as f64,
119 p50: Some(p),
120 p95: None,
121 p99: None,
122 },
123 unit: "ms".into(),
124 observed_at_unix_ms: 0,
125 }]
126 })
127 .unwrap_or_default(),
128 }
129 }
130
131 #[test]
132 fn fires_on_regression() {
133 let d = investigate_latency(
134 &snap_with(Some(40.0), 100),
135 &snap_with(Some(310.0), 100),
136 "a",
137 "b",
138 );
139 assert_eq!(d.status, FindingStatus::Hypothesis);
140 assert!(d.is_valid());
141 }
142
143 #[test]
144 fn quiet_on_normal() {
145 let d = investigate_latency(
146 &snap_with(Some(40.0), 100),
147 &snap_with(Some(42.0), 100),
148 "a",
149 "b",
150 );
151 assert_eq!(d.status, FindingStatus::NoIssue);
152 }
153
154 #[test]
155 fn missing_has_no_verdict() {
156 let d = investigate_latency(&snap_with(None, 0), &snap_with(Some(40.0), 100), "a", "b");
157 assert_eq!(d.status, FindingStatus::Observed);
158 }
159}