Skip to main content

lean_ctx/core/
team_slo.rs

1//! Team-server SLO instrumentation — the measurement half of the hosted-index
2//! reliability gate (GL #391).
3//!
4//! A process-global rolling window of request samples feeds three derived
5//! signals:
6//!
7//! * `p50/p95/p99` request latency (ms) across team `/v1/*` routes
8//! * availability — share of requests that did **not** end in a server error
9//!   (5xx). Client errors (4xx, e.g. `tool_error`, scope denials) are the
10//!   caller's problem and intentionally do not count against availability.
11//! * index freshness — seconds since the last successful index-mutating tool
12//!   call. This is a *staleness indicator*, not the end-to-end push→query lag
13//!   (the control-plane probe measures that); see `docs/runbooks/hosted-index.md`.
14//!
15//! The store lives in `core` so `core::slo::read_metric` can consume it
16//! without a dependency cycle (`http_server` already depends on `core`).
17
18use serde::Serialize;
19use std::collections::VecDeque;
20use std::sync::{Mutex, OnceLock};
21use std::time::{SystemTime, UNIX_EPOCH};
22
23/// Rolling window size. 4096 samples ≈ hours of traffic on a typical team
24/// server while staying trivially cheap to sort for percentiles.
25const WINDOW: usize = 4096;
26
27#[derive(Debug, Clone, Copy)]
28struct Sample {
29    duration_ms: u32,
30    ok: bool,
31}
32
33#[derive(Debug, Default)]
34struct Inner {
35    samples: VecDeque<Sample>,
36    requests_total: u64,
37    errors_total: u64,
38    last_index_write_unix: Option<u64>,
39    started_unix: Option<u64>,
40}
41
42/// Process-global team SLO statistics store.
43pub struct TeamSloStats {
44    inner: Mutex<Inner>,
45}
46
47static STORE: OnceLock<TeamSloStats> = OnceLock::new();
48
49/// The process-global store. Cheap to call; lazily initialised.
50pub fn global() -> &'static TeamSloStats {
51    STORE.get_or_init(|| TeamSloStats {
52        inner: Mutex::new(Inner::default()),
53    })
54}
55
56fn now_unix() -> u64 {
57    SystemTime::now()
58        .duration_since(UNIX_EPOCH)
59        .map_or(0, |d| d.as_secs())
60}
61
62impl TeamSloStats {
63    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
64        self.inner
65            .lock()
66            .unwrap_or_else(std::sync::PoisonError::into_inner)
67    }
68
69    /// Marks the server as started (uptime baseline). Idempotent — the first
70    /// call wins so restarts inside one process (tests) keep a stable origin.
71    pub fn mark_started(&self) {
72        let mut g = self.lock();
73        if g.started_unix.is_none() {
74            g.started_unix = Some(now_unix());
75        }
76    }
77
78    /// Records one finished request. `ok == false` means a *server* failure
79    /// (5xx); client errors must be recorded with `ok == true`.
80    pub fn record_request(&self, duration_ms: u64, ok: bool) {
81        let mut g = self.lock();
82        if g.samples.len() == WINDOW {
83            g.samples.pop_front();
84        }
85        g.samples.push_back(Sample {
86            duration_ms: duration_ms.min(u64::from(u32::MAX)) as u32,
87            ok,
88        });
89        g.requests_total += 1;
90        if !ok {
91            g.errors_total += 1;
92        }
93    }
94
95    /// Records a successful index-mutating operation (freshness baseline).
96    pub fn record_index_write(&self) {
97        self.lock().last_index_write_unix = Some(now_unix());
98    }
99
100    /// Current derived snapshot.
101    pub fn snapshot(&self) -> TeamSloSnapshot {
102        let g = self.lock();
103        let now = now_unix();
104
105        let mut durations: Vec<u32> = g.samples.iter().map(|s| s.duration_ms).collect();
106        durations.sort_unstable();
107        let pct = |p: f64| -> f64 {
108            if durations.is_empty() {
109                return 0.0;
110            }
111            // Nearest-rank percentile on the sorted window.
112            let rank = ((p / 100.0) * durations.len() as f64).ceil() as usize;
113            let idx = rank.clamp(1, durations.len()) - 1;
114            f64::from(durations[idx])
115        };
116
117        let window_len = g.samples.len();
118        let ok_in_window = g.samples.iter().filter(|s| s.ok).count();
119        // No traffic means no observed failures: report full availability
120        // rather than a false alarm on idle servers.
121        let availability_pct = if window_len == 0 {
122            100.0
123        } else {
124            (ok_in_window as f64 / window_len as f64) * 100.0
125        };
126
127        TeamSloSnapshot {
128            requests_total: g.requests_total,
129            errors_total: g.errors_total,
130            window_len,
131            p50_ms: pct(50.0),
132            p95_ms: pct(95.0),
133            p99_ms: pct(99.0),
134            availability_pct,
135            index_lag_seconds: g
136                .last_index_write_unix
137                .map(|t| now.saturating_sub(t) as f64),
138            uptime_seconds: g.started_unix.map(|t| now.saturating_sub(t)),
139        }
140    }
141
142    /// Test-only: reset all state so unit tests stay order-independent.
143    #[cfg(test)]
144    fn reset(&self) {
145        *self.lock() = Inner::default();
146    }
147}
148
149/// Point-in-time view of the team server's SLO signals.
150#[derive(Debug, Clone, Serialize)]
151pub struct TeamSloSnapshot {
152    pub requests_total: u64,
153    pub errors_total: u64,
154    /// Number of samples currently in the rolling window.
155    pub window_len: usize,
156    pub p50_ms: f64,
157    pub p95_ms: f64,
158    pub p99_ms: f64,
159    /// Percentage (0–100) of non-5xx requests in the rolling window.
160    pub availability_pct: f64,
161    /// Seconds since the last successful index write; `None` until one happened.
162    pub index_lag_seconds: Option<f64>,
163    /// Seconds since `mark_started`; `None` outside a serving process.
164    pub uptime_seconds: Option<u64>,
165}
166
167impl TeamSloSnapshot {
168    /// Prometheus text exposition (format 0.0.4) under the `leanctx_team_*`
169    /// namespace, scrapeable by any Prometheus-compatible agent.
170    pub fn to_prometheus(&self) -> String {
171        let mut out = String::with_capacity(640);
172        let mut gauge = |name: &str, help: &str, value: f64| {
173            out.push_str(&format!(
174                "# HELP {name} {help}\n# TYPE {name} gauge\n{name} {value}\n"
175            ));
176        };
177        gauge(
178            "leanctx_team_request_duration_p50_ms",
179            "Rolling p50 request latency over team /v1 routes",
180            self.p50_ms,
181        );
182        gauge(
183            "leanctx_team_request_duration_p95_ms",
184            "Rolling p95 request latency over team /v1 routes",
185            self.p95_ms,
186        );
187        gauge(
188            "leanctx_team_request_duration_p99_ms",
189            "Rolling p99 request latency over team /v1 routes",
190            self.p99_ms,
191        );
192        gauge(
193            "leanctx_team_availability_pct",
194            "Share of non-5xx requests in the rolling window (percent)",
195            self.availability_pct,
196        );
197        if let Some(lag) = self.index_lag_seconds {
198            gauge(
199                "leanctx_team_index_lag_seconds",
200                "Seconds since the last successful index-mutating tool call",
201                lag,
202            );
203        }
204        if let Some(up) = self.uptime_seconds {
205            gauge(
206                "leanctx_team_uptime_seconds",
207                "Seconds since the team server started",
208                up as f64,
209            );
210        }
211        // Counters last: they use a different TYPE.
212        out.push_str(&format!(
213            "# HELP leanctx_team_requests_total Total requests observed on team /v1 routes\n# TYPE leanctx_team_requests_total counter\nleanctx_team_requests_total {}\n",
214            self.requests_total
215        ));
216        out.push_str(&format!(
217            "# HELP leanctx_team_errors_total Total 5xx responses on team /v1 routes\n# TYPE leanctx_team_errors_total counter\nleanctx_team_errors_total {}\n",
218            self.errors_total
219        ));
220        out
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    /// Tests share the process-global store and cargo runs them in parallel.
229    /// A test-local mutex serialises them; each holder starts from a clean
230    /// slate. The guard must stay alive for the whole test body.
231    static TEST_LOCK: Mutex<()> = Mutex::new(());
232
233    fn fresh() -> (&'static TeamSloStats, std::sync::MutexGuard<'static, ()>) {
234        let guard = TEST_LOCK
235            .lock()
236            .unwrap_or_else(std::sync::PoisonError::into_inner);
237        let s = global();
238        s.reset();
239        (s, guard)
240    }
241
242    #[test]
243    fn empty_store_reports_idle_healthy() {
244        let (s, _guard) = fresh();
245        let snap = s.snapshot();
246        assert_eq!(snap.window_len, 0);
247        assert_eq!(snap.availability_pct, 100.0);
248        assert_eq!(snap.p95_ms, 0.0);
249        assert!(snap.index_lag_seconds.is_none());
250    }
251
252    #[test]
253    fn percentiles_use_nearest_rank() {
254        let (s, _guard) = fresh();
255        for ms in 1..=100u64 {
256            s.record_request(ms, true);
257        }
258        let snap = s.snapshot();
259        assert_eq!(snap.p50_ms, 50.0);
260        assert_eq!(snap.p95_ms, 95.0);
261        assert_eq!(snap.p99_ms, 99.0);
262        assert_eq!(snap.window_len, 100);
263    }
264
265    #[test]
266    fn availability_counts_only_server_errors() {
267        let (s, _guard) = fresh();
268        for _ in 0..98 {
269            s.record_request(10, true);
270        }
271        s.record_request(10, false);
272        s.record_request(10, false);
273        let snap = s.snapshot();
274        assert_eq!(snap.requests_total, 100);
275        assert_eq!(snap.errors_total, 2);
276        assert!((snap.availability_pct - 98.0).abs() < f64::EPSILON);
277    }
278
279    #[test]
280    fn window_is_bounded() {
281        let (s, _guard) = fresh();
282        for _ in 0..(WINDOW + 500) {
283            s.record_request(5, true);
284        }
285        let snap = s.snapshot();
286        assert_eq!(snap.window_len, WINDOW);
287        assert_eq!(snap.requests_total, (WINDOW + 500) as u64);
288    }
289
290    #[test]
291    fn index_write_resets_lag() {
292        let (s, _guard) = fresh();
293        assert!(s.snapshot().index_lag_seconds.is_none());
294        s.record_index_write();
295        let lag = s.snapshot().index_lag_seconds.expect("lag after write");
296        assert!(
297            lag < 5.0,
298            "fresh write must report near-zero lag, got {lag}"
299        );
300    }
301
302    #[test]
303    fn prometheus_exposition_contains_all_series() {
304        let (s, _guard) = fresh();
305        s.record_request(42, true);
306        s.record_index_write();
307        s.mark_started();
308        let text = s.snapshot().to_prometheus();
309        for series in [
310            "leanctx_team_request_duration_p95_ms",
311            "leanctx_team_availability_pct",
312            "leanctx_team_index_lag_seconds",
313            "leanctx_team_uptime_seconds",
314            "leanctx_team_requests_total",
315            "leanctx_team_errors_total",
316        ] {
317            assert!(text.contains(series), "missing series {series}:\n{text}");
318        }
319        assert!(text.contains("# TYPE leanctx_team_requests_total counter"));
320    }
321}