Skip to main content

omni_dev/daemon/services/
github_counters.rs

1//! A [`DaemonService`] that periodically logs a summary of omni-dev's GitHub
2//! API-call counters (#1387), and surfaces them in `daemon status`.
3//!
4//! The counters themselves are recorded by `crate::github_metrics::run_gh` at
5//! every `gh` call site (across the daemon *and* one-shot CLI processes) as
6//! `kind: "gh"` request-log records. This service just reads them back and emits
7//! a `tracing` summary at three points, so there is a periodic footprint in the
8//! log and a clean before/after marker across restarts without anyone running a
9//! command:
10//!
11//! - **~5s after startup** — a baseline once the pollers have come up.
12//! - **every 10 minutes** thereafter — a background task on a private
13//!   [`CancellationToken`], mirroring the worktrees poller shape.
14//! - **once on shutdown** — from [`shutdown`](DaemonService::shutdown), the
15//!   deterministic, awaited flush that `registry.shutdown_all()` drives after the
16//!   accept loop drains. That single hook covers SIGTERM / SIGINT / SIGHUP and
17//!   the built-in `shutdown` op, since all of them funnel through the one shared
18//!   token that ends the accept loop.
19//!
20//! Every emission is best-effort and cheap, however large the request log has
21//! grown (it is unbounded unless rotation or `prune` is opted into). The service
22//! keeps a running [`IncrementalCounts`] from the moment it starts, so each
23//! summary reads only the records appended since the previous one, never the
24//! log's history (#2132). A log that is replaced or truncated underneath it
25//! (`prune`, rotation) is rescanned once, from its first byte.
26//!
27//! Shutdown never waits on a scan beyond `SHUTDOWN_SCAN_BUDGET`. A blocking
28//! thread cannot be cancelled from outside, and the runtime waits for one before
29//! the process may exit, so the scan itself polls a stop condition before every
30//! line: shutdown cancels it for every scan already in flight, and gives the
31//! final summary a deadline. A summary cut short says so rather than reporting a
32//! partial count as complete.
33
34use 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
49/// Delay before the first ("startup") summary, so the pollers have come up.
50const STARTUP_DELAY: Duration = Duration::from_secs(5);
51/// Interval between periodic summaries after the first one.
52const PERIODIC_INTERVAL: Duration = Duration::from_secs(10 * 60);
53/// The longest the final summary may spend reading the log. Normally it reads a
54/// few minutes of records and finishes in milliseconds; this only bites when the
55/// log was replaced and has to be rescanned.
56const SHUTDOWN_SCAN_BUDGET: Duration = Duration::from_secs(2);
57
58/// The running tally, shared by the logger task, `status`, `summary` and
59/// `shutdown`. Locked only on a blocking thread, never across an `.await`.
60///
61/// A caller that arrives while another is reading waits for it, which is cheap
62/// unless the log was just replaced: that read rebuilds the tally from the new
63/// file and so can take a while. Shutdown is not held up by it, since cancelling
64/// `stopping` makes the reader in flight stop at its next line.
65type SharedTally = Arc<Mutex<IncrementalCounts>>;
66
67/// Periodically logs, and reports on demand, the GitHub API-call counters.
68pub struct GithubCountersService {
69    /// When the daemon started, so every summary reports calls **since boot** —
70    /// a clean before/after marker that resets across restarts.
71    started_at: DateTime<Utc>,
72    /// Counts of the `gh` records logged since `started_at`; `None` when no log
73    /// path resolves, so there is nothing to count.
74    tally: Option<SharedTally>,
75    /// Cancelled when shutdown begins: ends the logger and abandons every scan in
76    /// flight, so none of them delays the final summary.
77    stopping: CancellationToken,
78    /// The periodic-summary task (idempotent start; `None` until started).
79    logger: Mutex<Option<JoinHandle<()>>>,
80}
81
82impl Default for GithubCountersService {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl GithubCountersService {
89    /// Cheap construction (like the worktrees/sessions services): captures the
90    /// start time and the request log's current extent, reads none of its
91    /// records, and persists nothing. Call [`Self::start_counter_logger`] to
92    /// spawn the periodic task once inside the tokio runtime.
93    #[must_use]
94    pub fn new() -> Self {
95        Self::with_log_path(request_log::log_file_path())
96    }
97
98    /// [`new`](Self::new) over an explicit request log, so tests do not read the
99    /// developer's real one.
100    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    /// Spawns the ~5s-then-every-10-min summary task. Idempotent and a no-op
117    /// outside a tokio runtime (unit tests), mirroring the worktrees pollers.
118    pub fn start_counter_logger(&self) {
119        self.start_counter_logger_with(STARTUP_DELAY, PERIODIC_INTERVAL);
120    }
121
122    /// [`start_counter_logger`](Self::start_counter_logger) with explicit
123    /// cadences, so tests can drive it at millisecond speed.
124    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            // Wait for the baseline delay, but exit immediately if the daemon is
137            // already shutting down (shutdown() emits the final summary itself).
138            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
155/// A [`refresh`](IncrementalCounts::refresh) stop condition that fires once
156/// `token` is cancelled.
157fn cancelled_by(token: &CancellationToken) -> impl FnMut() -> bool + Send + 'static {
158    let token = token.clone();
159    move || token.is_cancelled()
160}
161
162/// Brings the tally up to date and returns the counts since boot, and whether
163/// they are current — `false` when `stop` cut the read short or the log could
164/// not be read. Best-effort: with no tally (no log path) the counts are zero.
165///
166/// The read runs on a blocking thread so it never stalls the async executor.
167async 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
190/// What the tally last held, for when a read of it failed outright. The tally
191/// outlives the failed task, so zeros would be a plausible-looking wrong answer;
192/// should the lock still be held, there is nothing better than zeros.
193fn 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
201/// Emits one `tracing::info` summary line for `phase`.
202fn log_summary(phase: &str, counts: &GhCounts, current: bool) {
203    // Bind before the macro so the summary is computed whenever this runs, not
204    // only when an info-level subscriber is installed (the poller idiom).
205    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
212/// The logger task's emission: a summary of the counters, dropped if shutdown
213/// began while it was reading, since shutdown emits the final summary itself.
214async 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            // A live socket query of the current counters (since daemon start).
230            "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        // Kept cheap and non-blocking (polled ~1 Hz): no file read here. The live
248        // numbers live in `daemon status` and the daemon log.
249        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        // Stop first: this ends the logger loop and abandons any scan in flight
277        // (periodic, `status`, `summary`), so nothing ahead of the final summary
278        // holds up the exit. Take the logger from under the lock before awaiting,
279        // so the std::Mutex is never held across the `.await`.
280        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        // The deterministic, awaited flush for every termination path
290        // (SIGTERM/SIGINT/SIGHUP + the `shutdown` op), under a deadline of its
291        // own: `stopping` is already cancelled and must not cut it short.
292        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    /// A `gh` record line stamped `secs` seconds after `since`.
309    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    /// The tally's total, read straight from the shared state.
328    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        // status() reads the log read-only and must be healthy with a well-formed
339        // detail object (counts may be empty/absent — we assert shape, not values).
340        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        // `summary` returns the counts plus a `since` marker; unknown ops error.
346        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        // menu()/menu_action()/shutdown() are inert but must not panic; shutdown()
353        // emits the final summary even though no logger task was started.
354        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        // Records already in the log at boot, stamped *inside* the window (the
373        // future): a full scan counts every one, so the service reporting none of
374        // them means it never read them.
375        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        // The abandoned scan lost nothing: the final summary, under its own
421        // deadline, reads what was left and the tally is whole.
422        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        // A stop condition that panics takes its blocking task down, and poisons
440        // the lock, mid-read. The tally is intact, so the answer is its last
441        // contents, marked incomplete, not zeros that read as complete.
442        let (counts, current) = counts_since_boot(&svc.tally, || panic!("induced")).await;
443        assert!(!current);
444        assert_eq!(counts.total(), 2);
445
446        // And the service goes on working: the poisoned lock is recovered.
447        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        // A second start is a no-op.
461        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}