Skip to main content

inspector_analysis/
baselines.rs

1//! Snapshot comparison with explicit counter-reset and instrumentation-change handling.
2use inspector_core::{CoreError, MetricKind, Observation, Snapshot, SnapshotDelta};
3
4/// Compare two snapshots. Never invents data: `None` stays `None`.
5/// Marks comparison unreliable on counter reset or completeness drift.
6pub 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    // Counter-reset detection.
15    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    // Instrumentation drift: different missing-field sets mean different visibility.
38    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(&current.data, "request_latency_ms"),
47    ) {
48        (Some(b), Some(c)) if b > 0.0 => Some(c / b),
49        _ => None, // missing is not zero
50    };
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}