inspector_analysis/
baselines.rs1use inspector_core::{CoreError, MetricKind, Observation, Snapshot, SnapshotDelta};
3
4pub fn compare_snapshots(
7 base: &Observation<Snapshot>,
8 current: &Observation<Snapshot>,
9) -> Result<SnapshotDelta, CoreError> {
10 let mut notes = Vec::new();
11 let mut unreliable = false;
12 let mut reason: Option<String> = None;
13
14 for b in &base.data.metrics {
16 if let Some(c) = current.data.metrics.iter().find(|m| m.name == b.name) {
17 if let (
18 MetricKind::Counter {
19 total: bt,
20 reset_detected: _,
21 },
22 MetricKind::Counter {
23 total: ct,
24 reset_detected: _,
25 },
26 ) = (&b.kind, &c.kind)
27 {
28 if ct < bt {
29 unreliable = true;
30 reason = Some(format!("counter '{}' reset: {bt} -> {ct}", b.name));
31 notes.push(reason.clone().unwrap());
32 }
33 }
34 }
35 }
36
37 if base.data.overview.metric_names != current.data.overview.metric_names
39 || base.completeness.missing_fields != current.completeness.missing_fields
40 {
41 notes.push("instrumentation/visibility changed between snapshots".to_string());
42 }
43
44 let latency_delta_ratio = match (
45 histogram_p50(&base.data, "request_latency_ms"),
46 histogram_p50(¤t.data, "request_latency_ms"),
47 ) {
48 (Some(b), Some(c)) if b > 0.0 => Some(c / b),
49 _ => None, };
51
52 let task_growth = match (
53 base.data.overview.active_tasks,
54 current.data.overview.active_tasks,
55 ) {
56 (Some(b), Some(c)) => Some(c as i64 - b as i64),
57 _ => None,
58 };
59
60 Ok(SnapshotDelta {
61 base_id: base.observation_id.clone(),
62 current_id: current.observation_id.clone(),
63 latency_delta_ratio,
64 task_growth,
65 unreliable,
66 reason,
67 notes,
68 })
69}
70
71fn histogram_p50(snap: &Snapshot, name: &str) -> Option<f64> {
72 snap.metrics
73 .iter()
74 .find(|m| m.name == name)
75 .and_then(|m| match &m.kind {
76 MetricKind::Histogram { p50, .. } => *p50,
77 MetricKind::Gauge { value } => Some(*value),
78 MetricKind::Counter { total, .. } => Some(*total),
79 })
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use inspector_core::{Completeness, MetricObservation, RuntimeOverview, SourceId};
86
87 fn snap(p50: Option<f64>, tasks: Option<u64>) -> Observation<Snapshot> {
88 let metrics = p50
89 .map(|p| {
90 vec![MetricObservation {
91 name: "request_latency_ms".into(),
92 kind: MetricKind::Histogram {
93 count: 100,
94 sum: p * 100.0,
95 p50: Some(p),
96 p95: Some(p * 1.5),
97 p99: Some(p * 2.0),
98 },
99 unit: "ms".into(),
100 observed_at_unix_ms: 0,
101 }]
102 })
103 .unwrap_or_default();
104 Observation::new(
105 SourceId {
106 console_addr: "test".into(),
107 service_name: None,
108 },
109 Some(5000),
110 0,
111 Snapshot {
112 overview: RuntimeOverview {
113 service_name: None,
114 console_addr: "test".into(),
115 active_tasks: tasks,
116 completed_tasks: None,
117 tasks_truncated: false,
118 metric_names: vec!["request_latency_ms".into()],
119 },
120 tasks: vec![],
121 resources: vec![],
122 metrics,
123 },
124 Completeness::complete(),
125 )
126 }
127
128 #[test]
129 fn detects_regression_ratio() {
130 let d =
131 compare_snapshots(&snap(Some(40.0), Some(12)), &snap(Some(310.0), Some(142))).unwrap();
132 assert!((d.latency_delta_ratio.unwrap() - 7.75).abs() < 0.01);
133 assert_eq!(d.task_growth, Some(130));
134 assert!(!d.unreliable);
135 }
136
137 #[test]
138 fn missing_is_not_zero() {
139 let d = compare_snapshots(&snap(None, None), &snap(Some(40.0), None)).unwrap();
140 assert_eq!(d.latency_delta_ratio, None);
141 assert_eq!(d.task_growth, None);
142 }
143}