1use std::path::Path;
18
19use keel_journal::{DailyStats, MS_PER_DAY, TargetStats};
20use rusqlite::{Connection, OpenFlags};
21use serde::Serialize;
22
23use crate::render::to_json;
24use crate::{EXIT_FAILURE, Rendered, evidence};
25
26const WINDOW_DAYS: i64 = 7;
28
29#[derive(Debug, Serialize)]
31struct TargetLine {
32 breaker_opens: i64,
33 cache_hits: i64,
34 calls: i64,
35 failures: i64,
36 not_retried: i64,
37 retries: i64,
38 successes: i64,
39 target: String,
40 throttled: i64,
41 unwrapped_calls: i64,
42}
43
44#[derive(Debug, Default, Serialize)]
46struct FlowSummary {
47 completed: i64,
48 dead: i64,
49 failed: i64,
50 resumable: i64,
51 running: i64,
52 total: i64,
53}
54
55#[derive(Debug, Default, Serialize)]
59struct WeekSummary {
60 breaker_opens: i64,
61 cache_hits: i64,
62 calls: i64,
63 failures: i64,
64 not_retried: i64,
65 retries: i64,
66 successes: i64,
67 throttled: i64,
68 unwrapped_calls: i64,
69}
70
71#[derive(Debug, Serialize)]
74struct StatusReport {
75 breaker_opens: i64,
76 cache_hit_rate: f64,
77 cache_hits: i64,
78 calls: i64,
79 discovery_present: bool,
80 failures: i64,
81 flows: FlowSummary,
82 journal_present: bool,
83 not_retried: i64,
84 retries: i64,
85 success_rate: f64,
86 successes: i64,
87 targets: Vec<TargetLine>,
88 targets_wrapped: usize,
89 throttled: i64,
90 unwrapped_calls: i64,
91 week: WeekSummary,
92 wrapped_coverage: f64,
93}
94
95pub fn run(project: &Path, now_ms: i64) -> Rendered {
99 let discovery = match evidence::read_discovery(project) {
100 Ok(d) => d,
101 Err(e) => return soft_error(&e),
102 };
103 let daily = match evidence::read_discovery_daily(project) {
104 Ok(d) => d,
105 Err(e) => return soft_error(&e),
106 };
107 let discovery_present = evidence::discovery_db(project).exists();
108 let journal_path = evidence::resolved_journal(project).path;
110 let journal_present = journal_path.exists();
111
112 let flows = if journal_present {
113 match read_flows(&journal_path) {
114 Ok(f) => f,
115 Err(e) => return soft_error(&e),
116 }
117 } else {
118 FlowSummary::default()
119 };
120
121 let report = aggregate(
122 discovery,
123 &daily,
124 flows,
125 discovery_present,
126 journal_present,
127 now_ms,
128 );
129 let human = human(&report);
130 Rendered::ok(human, to_json(&report))
131}
132
133fn aggregate(
136 discovery: Vec<TargetStats>,
137 daily: &[DailyStats],
138 flows: FlowSummary,
139 discovery_present: bool,
140 journal_present: bool,
141 now_ms: i64,
142) -> StatusReport {
143 let mut r = StatusReport {
144 breaker_opens: 0,
145 cache_hit_rate: 0.0,
146 cache_hits: 0,
147 calls: 0,
148 discovery_present,
149 failures: 0,
150 flows,
151 journal_present,
152 not_retried: 0,
153 retries: 0,
154 success_rate: 0.0,
155 successes: 0,
156 targets: Vec::new(),
157 targets_wrapped: discovery.len(),
158 throttled: 0,
159 unwrapped_calls: 0,
160 week: WeekSummary::default(),
161 wrapped_coverage: 0.0,
162 };
163 for s in discovery {
164 r.calls += s.calls;
165 r.retries += s.retries;
166 r.successes += s.successes;
167 r.failures += s.failures;
168 r.cache_hits += s.cache_hits;
169 r.throttled += s.throttled;
170 r.breaker_opens += s.breaker_opens;
171 r.not_retried += s.not_retried;
172 r.unwrapped_calls += s.unwrapped_calls;
173 r.targets.push(TargetLine {
174 breaker_opens: s.breaker_opens,
175 cache_hits: s.cache_hits,
176 calls: s.calls,
177 failures: s.failures,
178 not_retried: s.not_retried,
179 retries: s.retries,
180 successes: s.successes,
181 target: s.target,
182 throttled: s.throttled,
183 unwrapped_calls: s.unwrapped_calls,
184 });
185 }
186 r.cache_hit_rate = ratio(r.cache_hits, r.calls);
187 r.success_rate = ratio(r.successes, r.successes + r.failures);
188 r.wrapped_coverage = ratio(r.calls - r.unwrapped_calls, r.calls);
189 r.week = week_window(daily, now_ms);
190 r
191}
192
193fn week_window(daily: &[DailyStats], now_ms: i64) -> WeekSummary {
198 let end_day = now_ms.div_euclid(MS_PER_DAY);
199 let start_day = end_day - (WINDOW_DAYS - 1);
200 let mut w = WeekSummary::default();
201 for d in daily {
202 if d.day < start_day || d.day > end_day {
203 continue;
204 }
205 w.calls += d.calls;
206 w.retries += d.retries;
207 w.successes += d.successes;
208 w.failures += d.failures;
209 w.cache_hits += d.cache_hits;
210 w.throttled += d.throttled;
211 w.breaker_opens += d.breaker_opens;
212 w.not_retried += d.not_retried;
213 w.unwrapped_calls += d.unwrapped_calls;
214 }
215 w
216}
217
218fn ratio(num: i64, denom: i64) -> f64 {
220 if denom <= 0 {
221 return 0.0;
222 }
223 #[expect(clippy::cast_precision_loss, reason = "counts are small; 4-dp rounded")]
224 let raw = num as f64 / denom as f64;
225 (raw * 10_000.0).round() / 10_000.0
226}
227
228fn read_flows(path: &Path) -> Result<FlowSummary, String> {
231 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
232 .map_err(|e| format!("could not open {}: {e}", path.display()))?;
233 let mut stmt = conn
234 .prepare("SELECT status, COUNT(*) FROM flows GROUP BY status")
235 .map_err(|e| format!("could not read flows: {e}"))?;
236 let rows = stmt
237 .query_map([], |row| {
238 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
239 })
240 .map_err(|e| format!("could not read flows: {e}"))?;
241 let mut f = FlowSummary::default();
242 for row in rows {
243 let (status, count) = row.map_err(|e| format!("could not read flows: {e}"))?;
244 match status.as_str() {
245 "running" => f.running = count,
246 "completed" => f.completed = count,
247 "failed" => f.failed = count,
248 "dead" => f.dead = count,
249 _ => {}
250 }
251 f.total += count;
252 }
253 f.resumable = f.running;
254 Ok(f)
255}
256
257fn human(r: &StatusReport) -> String {
260 if !r.discovery_present && !r.journal_present {
261 return "keel \u{25b8} no evidence yet.\n Run `keel run <script>` to start recording coverage and flows.".to_owned();
262 }
263 let mut lines: Vec<String> = vec![
264 "keel \u{25b8} status\n".to_owned(),
265 format!(
266 " wrapped targets: {} ({:.1}% of calls covered by policy)\n calls: {} ({} ok, {} failed, {} cached)\n retries: {} ({} saved this week)\n",
267 r.targets_wrapped,
268 r.wrapped_coverage * 100.0,
269 r.calls,
270 r.successes,
271 r.failures,
272 r.cache_hits,
273 r.retries,
274 r.week.retries,
275 ),
276 format!(
277 " success rate: {:.1}%\n cache hit rate: {:.1}%\n",
278 r.success_rate * 100.0,
279 r.cache_hit_rate * 100.0,
280 ),
281 format!(
282 " breaker events: {}\n throttled: {}\n",
283 r.breaker_opens, r.throttled,
284 ),
285 format!(
286 " flows: {} total ({} completed, {} running, {} failed, {} dead)\n resumable: {}\n",
287 r.flows.total,
288 r.flows.completed,
289 r.flows.running,
290 r.flows.failed,
291 r.flows.dead,
292 r.flows.resumable,
293 ),
294 ];
295 if r.unwrapped_calls > 0 {
296 lines.push(format!(
297 " coverage gap: {} call(s) observed on targets with no policy entry — run `keel init` to add them.\n",
298 r.unwrapped_calls,
299 ));
300 }
301 let not_retried_targets: Vec<&str> = r
302 .targets
303 .iter()
304 .filter(|t| t.not_retried > 0)
305 .map(|t| t.target.as_str())
306 .collect();
307 if !not_retried_targets.is_empty() {
308 lines.push(format!(
309 " observed, not retried: {} call(s) on {} — non-idempotent (no idempotency key), so Keel will not retry them by default; add `idempotency.header` in keel.toml to allow it.\n",
310 r.not_retried,
311 not_retried_targets.join(", "),
312 ));
313 }
314 if !r.targets.is_empty() {
315 lines.push(" by target:\n".to_owned());
316 for t in &r.targets {
317 lines.push(format!(
318 " {} ({} calls, {} retries, {} cache hits)\n",
319 t.target, t.calls, t.retries, t.cache_hits,
320 ));
321 }
322 }
323 lines.concat()
324}
325
326fn soft_error(message: &str) -> Rendered {
328 #[derive(Serialize)]
329 struct ErrReport<'a> {
330 error: &'a str,
331 }
332 Rendered {
333 human: format!("keel \u{25b8} status unavailable: {message}"),
334 json: to_json(&ErrReport { error: message }),
335 exit: EXIT_FAILURE,
336 to_stderr: true,
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use keel_journal::{CallObservation, CallResult, DiscoveryStore, ManualClock, ObservedError};
343
344 use super::*;
345
346 const T0: i64 = 1_783_728_000_000; #[test]
349 fn empty_project_nudges_to_run() {
350 let dir = tempfile::TempDir::new().unwrap();
351 let r = run(dir.path(), T0);
352 assert_eq!(r.exit, crate::EXIT_OK);
353 assert!(r.human.contains("no evidence yet"));
354 assert_eq!(r.json["discovery_present"], false);
355 assert_eq!(r.json["journal_present"], false);
356 }
357
358 #[test]
359 fn ratio_rounds_and_guards_zero_denominator() {
360 assert!((ratio(1, 3) - 0.3333).abs() < f64::EPSILON);
361 assert!((ratio(0, 0) - 0.0).abs() < f64::EPSILON);
362 assert!((ratio(1, 2) - 0.5).abs() < f64::EPSILON);
363 }
364
365 fn observation(target: &str, not_retried: bool, wrapped: bool) -> CallObservation {
366 CallObservation {
367 target: target.to_owned(),
368 result: if not_retried {
369 CallResult::Failure
370 } else {
371 CallResult::Success
372 },
373 attempts: if not_retried { 1 } else { 2 },
374 latency_ms: 10,
375 throttled: false,
376 breaker_opened: false,
377 not_retried,
378 wrapped,
379 error: not_retried.then_some(ObservedError {
380 class: keel_journal::ErrorClass::Http,
381 http_status: Some(500),
382 }),
383 }
384 }
385
386 #[test]
387 fn coverage_gap_and_not_retried_surface_in_the_report() {
388 let dir = tempfile::TempDir::new().unwrap();
389 std::fs::create_dir_all(dir.path().join(".keel")).unwrap();
390 let clock = ManualClock::new(T0);
391 {
392 let store =
393 DiscoveryStore::open(dir.path().join(".keel").join("discovery.db"), clock).unwrap();
394 store
395 .record(&observation("api.stripe.com", true, true))
396 .unwrap();
397 store
398 .record(&observation("api.unconfigured.com", false, false))
399 .unwrap();
400 }
401
402 let r = run(dir.path(), T0);
403 assert_eq!(r.exit, crate::EXIT_OK);
404 assert_eq!(r.json["not_retried"], 1);
405 assert_eq!(r.json["unwrapped_calls"], 1);
406 assert!(
407 (r.json["wrapped_coverage"].as_f64().unwrap() - 0.5).abs() < f64::EPSILON,
408 "1 of 2 calls came from a target with no policy entry"
409 );
410 assert!(r.human.contains("observed, not retried"));
411 assert!(r.human.contains("api.stripe.com"));
412 assert!(r.human.contains("coverage gap"));
413 }
414
415 #[test]
416 fn week_window_sums_only_the_trailing_seven_stored_days() {
417 let dir = tempfile::TempDir::new().unwrap();
418 std::fs::create_dir_all(dir.path().join(".keel")).unwrap();
419 let clock = ManualClock::new(T0);
420 let db = dir.path().join(".keel").join("discovery.db");
421 {
422 let store = DiscoveryStore::open(&db, clock.clone()).unwrap();
423 store.record(&observation("api.x", false, true)).unwrap(); clock.advance(6 * MS_PER_DAY);
425 store.record(&observation("api.x", false, true)).unwrap(); clock.advance(MS_PER_DAY); store.record(&observation("api.x", false, true)).unwrap(); }
430
431 let as_of_day_6 = T0 + 6 * MS_PER_DAY;
432 let r = run(dir.path(), as_of_day_6);
433 assert_eq!(r.json["week"]["calls"], 2);
435 assert_eq!(
436 r.json["calls"], 3,
437 "lifetime total is unaffected by windowing"
438 );
439
440 let as_of_day_7 = T0 + 7 * MS_PER_DAY;
441 let r7 = run(dir.path(), as_of_day_7);
442 assert_eq!(r7.json["week"]["calls"], 2);
444 }
445}