1use serde::Serialize;
19use std::collections::VecDeque;
20use std::sync::{Mutex, OnceLock};
21use std::time::{SystemTime, UNIX_EPOCH};
22
23const 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
42pub struct TeamSloStats {
44 inner: Mutex<Inner>,
45}
46
47static STORE: OnceLock<TeamSloStats> = OnceLock::new();
48
49pub 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 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 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 pub fn record_index_write(&self) {
97 self.lock().last_index_write_unix = Some(now_unix());
98 }
99
100 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 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 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 #[cfg(test)]
144 fn reset(&self) {
145 *self.lock() = Inner::default();
146 }
147}
148
149#[derive(Debug, Clone, Serialize)]
151pub struct TeamSloSnapshot {
152 pub requests_total: u64,
153 pub errors_total: u64,
154 pub window_len: usize,
156 pub p50_ms: f64,
157 pub p95_ms: f64,
158 pub p99_ms: f64,
159 pub availability_pct: f64,
161 pub index_lag_seconds: Option<f64>,
163 pub uptime_seconds: Option<u64>,
165}
166
167impl TeamSloSnapshot {
168 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 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 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}