omni_dev/daemon/services/
github_counters.rs1use std::path::PathBuf;
35use std::sync::{Arc, Mutex, PoisonError, TryLockError};
36use std::time::{Duration, Instant};
37
38use anyhow::{bail, Result};
39use async_trait::async_trait;
40use chrono::{DateTime, Utc};
41use serde_json::{json, Value};
42use tokio::task::JoinHandle;
43use tokio_util::sync::CancellationToken;
44
45use crate::daemon::service::{DaemonService, MenuItem, MenuSnapshot, ServiceStatus};
46use crate::github_metrics::{GhCounts, IncrementalCounts};
47use crate::request_log;
48
49const STARTUP_DELAY: Duration = Duration::from_secs(5);
51const PERIODIC_INTERVAL: Duration = Duration::from_secs(10 * 60);
53const SHUTDOWN_SCAN_BUDGET: Duration = Duration::from_secs(2);
57
58type SharedTally = Arc<Mutex<IncrementalCounts>>;
66
67pub struct GithubCountersService {
69 started_at: DateTime<Utc>,
72 tally: Option<SharedTally>,
75 stopping: CancellationToken,
78 logger: Mutex<Option<JoinHandle<()>>>,
80}
81
82impl Default for GithubCountersService {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88impl GithubCountersService {
89 #[must_use]
94 pub fn new() -> Self {
95 Self::with_log_path(request_log::log_file_path())
96 }
97
98 fn with_log_path(path: Option<PathBuf>) -> Self {
101 let (started_at, tally) = match path {
102 Some(path) => {
103 let tally = IncrementalCounts::start(path);
104 (tally.since(), Some(Arc::new(Mutex::new(tally))))
105 }
106 None => (Utc::now(), None),
107 };
108 Self {
109 started_at,
110 tally,
111 stopping: CancellationToken::new(),
112 logger: Mutex::new(None),
113 }
114 }
115
116 pub fn start_counter_logger(&self) {
119 self.start_counter_logger_with(STARTUP_DELAY, PERIODIC_INTERVAL);
120 }
121
122 fn start_counter_logger_with(&self, startup_delay: Duration, interval: Duration) {
125 if tokio::runtime::Handle::try_current().is_err() {
126 tracing::debug!("no tokio runtime; github counter logger not started");
127 return;
128 }
129 let mut guard = self.logger.lock().unwrap_or_else(PoisonError::into_inner);
130 if guard.is_some() {
131 return;
132 }
133 let stopping = self.stopping.clone();
134 let tally = self.tally.clone();
135 *guard = Some(tokio::spawn(async move {
136 tokio::select! {
139 () = stopping.cancelled() => return,
140 () = tokio::time::sleep(startup_delay) => {}
141 }
142 emit_background(&tally, "startup", &stopping).await;
143 loop {
144 tokio::select! {
145 () = stopping.cancelled() => break,
146 () = tokio::time::sleep(interval) => {
147 emit_background(&tally, "periodic", &stopping).await;
148 }
149 }
150 }
151 }));
152 }
153}
154
155fn cancelled_by(token: &CancellationToken) -> impl FnMut() -> bool + Send + 'static {
158 let token = token.clone();
159 move || token.is_cancelled()
160}
161
162async fn counts_since_boot(
168 tally: &Option<SharedTally>,
169 stop: impl FnMut() -> bool + Send + 'static,
170) -> (GhCounts, bool) {
171 let Some(tally) = tally else {
172 return (GhCounts::default(), true);
173 };
174 let reader = Arc::clone(tally);
175 let read = tokio::task::spawn_blocking(move || {
176 let mut tally = reader.lock().unwrap_or_else(PoisonError::into_inner);
177 let current = tally.refresh(stop);
178 (tally.counts().clone(), current)
179 })
180 .await;
181 match read {
182 Ok(counts) => counts,
183 Err(e) => {
184 tracing::warn!("github counters: the log read did not complete: {e}");
185 (last_known(tally), false)
186 }
187 }
188}
189
190fn last_known(tally: &SharedTally) -> GhCounts {
194 match tally.try_lock() {
195 Ok(tally) => tally.counts().clone(),
196 Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner().counts().clone(),
197 Err(TryLockError::WouldBlock) => GhCounts::default(),
198 }
199}
200
201fn log_summary(phase: &str, counts: &GhCounts, current: bool) {
203 let mut summary = counts.summary_line();
206 if !current {
207 summary.push_str(" (incomplete: the log read was cut short)");
208 }
209 tracing::info!("github api calls ({phase}): {summary}");
210}
211
212async fn emit_background(tally: &Option<SharedTally>, phase: &str, stopping: &CancellationToken) {
215 let (counts, current) = counts_since_boot(tally, cancelled_by(stopping)).await;
216 if !stopping.is_cancelled() {
217 log_summary(phase, &counts, current);
218 }
219}
220
221#[async_trait]
222impl DaemonService for GithubCountersService {
223 fn name(&self) -> &'static str {
224 "github"
225 }
226
227 async fn handle(&self, op: &str, _payload: Value) -> Result<Value> {
228 match op {
229 "summary" => {
231 let (counts, current) =
232 counts_since_boot(&self.tally, cancelled_by(&self.stopping)).await;
233 let mut value = counts.to_json();
234 if let Value::Object(map) = &mut value {
235 map.insert("since".to_string(), json!(self.started_at.to_rfc3339()));
236 if !current {
237 map.insert("incomplete".to_string(), json!(true));
238 }
239 }
240 Ok(value)
241 }
242 other => bail!("unknown github op: {other}"),
243 }
244 }
245
246 fn menu(&self) -> MenuSnapshot {
247 MenuSnapshot {
250 title: "GitHub API".to_string(),
251 items: vec![MenuItem::Label(
252 "call counters logged to daemon.log".to_string(),
253 )],
254 }
255 }
256
257 async fn menu_action(&self, _action_id: &str) -> Result<()> {
258 Ok(())
259 }
260
261 async fn status(&self) -> ServiceStatus {
262 let (counts, current) = counts_since_boot(&self.tally, cancelled_by(&self.stopping)).await;
263 let mut summary = format!("{} GitHub API call(s) since start", counts.api_total());
264 if !current {
265 summary.push_str(" (incomplete)");
266 }
267 ServiceStatus {
268 name: self.name().to_string(),
269 healthy: true,
270 summary,
271 detail: counts.to_json(),
272 }
273 }
274
275 async fn shutdown(&self) {
276 self.stopping.cancel();
281 let logger = self
282 .logger
283 .lock()
284 .unwrap_or_else(PoisonError::into_inner)
285 .take();
286 if let Some(logger) = logger {
287 let _ = logger.await;
288 }
289 let deadline = Instant::now() + SHUTDOWN_SCAN_BUDGET;
293 let (counts, current) =
294 counts_since_boot(&self.tally, move || Instant::now() >= deadline).await;
295 log_summary("shutdown", &counts, current);
296 }
297}
298
299#[cfg(test)]
300#[allow(clippy::unwrap_used, clippy::expect_used)]
301mod tests {
302 use super::*;
303 use crate::github_metrics::aggregate;
304 use chrono::{Duration as ChronoDuration, SecondsFormat};
305 use std::io::Write;
306 use std::path::Path;
307
308 fn gh_line(since: DateTime<Utc>, secs: i64, command: &[&str]) -> String {
310 format!(
311 "{{\"kind\":\"gh\",\"timestamp\":\"{}\",\"command\":{},\"source\":\"daemon\"}}\n",
312 (since + ChronoDuration::seconds(secs)).to_rfc3339_opts(SecondsFormat::Millis, true),
313 serde_json::to_string(command).unwrap(),
314 )
315 }
316
317 fn append(path: &Path, text: &str) {
318 std::fs::OpenOptions::new()
319 .append(true)
320 .create(true)
321 .open(path)
322 .unwrap()
323 .write_all(text.as_bytes())
324 .unwrap();
325 }
326
327 fn tallied(svc: &GithubCountersService) -> u64 {
329 svc.tally.as_ref().unwrap().lock().unwrap().counts().total()
330 }
331
332 #[tokio::test]
333 async fn service_reports_status_and_handles_summary() {
334 let dir = tempfile::tempdir().unwrap();
335 let svc = GithubCountersService::with_log_path(Some(dir.path().join("log.jsonl")));
336 assert_eq!(svc.name(), "github");
337
338 let status = svc.status().await;
341 assert_eq!(status.name, "github");
342 assert!(status.healthy);
343 assert!(status.detail.get("api_total").is_some());
344
345 let summary = svc.handle("summary", Value::Null).await.unwrap();
347 assert!(summary.get("since").is_some());
348 assert!(summary.get("by_source").is_some());
349 assert!(summary.get("incomplete").is_none());
350 assert!(svc.handle("bogus", Value::Null).await.is_err());
351
352 assert_eq!(svc.menu().title, "GitHub API");
355 svc.menu_action("x").await.unwrap();
356 svc.shutdown().await;
357 }
358
359 #[tokio::test]
360 async fn without_a_log_path_there_is_nothing_to_count() {
361 let svc = GithubCountersService::with_log_path(None);
362 let status = svc.status().await;
363 assert!(status.healthy);
364 assert_eq!(status.summary, "0 GitHub API call(s) since start");
365 svc.shutdown().await;
366 }
367
368 #[tokio::test]
369 async fn counts_only_the_records_appended_after_start() {
370 let dir = tempfile::tempdir().unwrap();
371 let log = dir.path().join("log.jsonl");
372 let future = Utc::now() + ChronoDuration::hours(1);
376 let pre: String = (0..50)
377 .map(|_| gh_line(future, 0, &["pr", "list"]))
378 .collect();
379 append(&log, &pre);
380
381 let svc = GithubCountersService::with_log_path(Some(log.clone()));
382 assert_eq!(
383 aggregate(&log, Some(svc.started_at), None, None).total(),
384 50
385 );
386 assert_eq!(
387 svc.status().await.summary,
388 "0 GitHub API call(s) since start"
389 );
390
391 append(&log, &gh_line(svc.started_at, 1, &["api", "graphql"]));
392 append(&log, &gh_line(svc.started_at, 2, &["pr", "view"]));
393 let status = svc.status().await;
394 assert_eq!(status.summary, "2 GitHub API call(s) since start");
395
396 let summary = svc.handle("summary", Value::Null).await.unwrap();
397 assert_eq!(summary["api_total"], 2);
398 assert_eq!(summary["by_subcommand"]["pr view"], 1);
399 assert_eq!(summary["since"], svc.started_at.to_rfc3339());
400 }
401
402 #[tokio::test]
403 async fn a_scan_is_abandoned_once_shutdown_begins() {
404 let dir = tempfile::tempdir().unwrap();
405 let log = dir.path().join("log.jsonl");
406 let svc = GithubCountersService::with_log_path(Some(log.clone()));
407 for n in 1..=3 {
408 append(&log, &gh_line(svc.started_at, n, &["pr", "list"]));
409 }
410
411 svc.stopping.cancel();
412 let status = svc.status().await;
413 assert_eq!(
414 status.summary,
415 "0 GitHub API call(s) since start (incomplete)"
416 );
417 let summary = svc.handle("summary", Value::Null).await.unwrap();
418 assert_eq!(summary["incomplete"], true);
419
420 assert_eq!(tallied(&svc), 0);
423 svc.shutdown().await;
424 assert_eq!(tallied(&svc), 3);
425 }
426
427 #[tokio::test]
428 async fn a_read_that_fails_outright_reports_what_the_tally_last_held() {
429 let dir = tempfile::tempdir().unwrap();
430 let log = dir.path().join("log.jsonl");
431 let svc = GithubCountersService::with_log_path(Some(log.clone()));
432 append(&log, &gh_line(svc.started_at, 1, &["pr", "list"]));
433 append(&log, &gh_line(svc.started_at, 2, &["pr", "view"]));
434 assert_eq!(
435 svc.status().await.summary,
436 "2 GitHub API call(s) since start"
437 );
438
439 let (counts, current) = counts_since_boot(&svc.tally, || panic!("induced")).await;
443 assert!(!current);
444 assert_eq!(counts.total(), 2);
445
446 append(&log, &gh_line(svc.started_at, 3, &["pr", "list"]));
448 assert_eq!(
449 svc.status().await.summary,
450 "3 GitHub API call(s) since start"
451 );
452 }
453
454 #[tokio::test]
455 async fn the_logger_keeps_the_tally_current_and_stops_on_shutdown() {
456 let dir = tempfile::tempdir().unwrap();
457 let log = dir.path().join("log.jsonl");
458 let svc = GithubCountersService::with_log_path(Some(log.clone()));
459 svc.start_counter_logger_with(Duration::from_millis(1), Duration::from_millis(5));
460 svc.start_counter_logger_with(Duration::from_millis(1), Duration::from_millis(5));
462
463 append(&log, &gh_line(svc.started_at, 1, &["pr", "list"]));
464 let deadline = Instant::now() + Duration::from_secs(10);
465 while tallied(&svc) == 0 {
466 assert!(
467 Instant::now() < deadline,
468 "the logger never read the record"
469 );
470 tokio::time::sleep(Duration::from_millis(10)).await;
471 }
472
473 svc.shutdown().await;
474 assert!(svc.logger.lock().unwrap().is_none());
475 assert!(svc.stopping.is_cancelled());
476 }
477}