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 bounded (a small local log read, no
21//! network), so it never delays shutdown.
22
23use std::sync::{Mutex, PoisonError};
24use std::time::Duration;
25
26use anyhow::{bail, Result};
27use async_trait::async_trait;
28use chrono::{DateTime, Utc};
29use serde_json::{json, Value};
30use tokio::task::JoinHandle;
31use tokio_util::sync::CancellationToken;
32
33use crate::daemon::service::{DaemonService, MenuItem, MenuSnapshot, ServiceStatus};
34use crate::github_metrics::{self, GhCounts};
35use crate::request_log;
36
37/// Delay before the first ("startup") summary, so the pollers have come up.
38const STARTUP_DELAY: Duration = Duration::from_secs(5);
39/// Interval between periodic summaries after the first one.
40const PERIODIC_INTERVAL: Duration = Duration::from_secs(10 * 60);
41
42/// The periodic-summary task and the token that stops it.
43struct LoggerTask {
44    /// Cancelled by [`shutdown`](DaemonService::shutdown) to end the loop.
45    token: CancellationToken,
46    /// The spawned loop, awaited on shutdown so it fully unwinds.
47    handle: JoinHandle<()>,
48}
49
50/// Periodically logs, and reports on demand, the GitHub API-call counters.
51pub struct GithubCountersService {
52    /// When the daemon started, so every summary reports calls **since boot** —
53    /// a clean before/after marker that resets across restarts.
54    started_at: DateTime<Utc>,
55    /// The periodic-summary task (idempotent start; `None` until started).
56    logger: Mutex<Option<LoggerTask>>,
57}
58
59impl Default for GithubCountersService {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl GithubCountersService {
66    /// Cheap construction (like the worktrees/sessions services): captures the
67    /// start time and persists nothing. Call [`Self::start_counter_logger`] to
68    /// spawn the periodic task once inside the tokio runtime.
69    #[must_use]
70    pub fn new() -> Self {
71        Self {
72            started_at: Utc::now(),
73            logger: Mutex::new(None),
74        }
75    }
76
77    /// Spawns the ~5s-then-every-10-min summary task. Idempotent and a no-op
78    /// outside a tokio runtime (unit tests), mirroring the worktrees pollers.
79    pub fn start_counter_logger(&self) {
80        self.start_counter_logger_with(STARTUP_DELAY, PERIODIC_INTERVAL);
81    }
82
83    /// [`start_counter_logger`](Self::start_counter_logger) with explicit
84    /// cadences, so tests can drive it at millisecond speed.
85    fn start_counter_logger_with(&self, startup_delay: Duration, interval: Duration) {
86        if tokio::runtime::Handle::try_current().is_err() {
87            tracing::debug!("no tokio runtime; github counter logger not started");
88            return;
89        }
90        let mut guard = self.logger.lock().unwrap_or_else(PoisonError::into_inner);
91        if guard.is_some() {
92            return;
93        }
94        let token = CancellationToken::new();
95        let loop_token = token.clone();
96        let started_at = self.started_at;
97        let handle = tokio::spawn(async move {
98            // Wait for the baseline delay, but exit immediately if the daemon is
99            // already shutting down (shutdown() emits the final summary itself).
100            tokio::select! {
101                () = loop_token.cancelled() => return,
102                () = tokio::time::sleep(startup_delay) => {}
103            }
104            emit(started_at, "startup").await;
105            loop {
106                tokio::select! {
107                    () = loop_token.cancelled() => break,
108                    () = tokio::time::sleep(interval) => emit(started_at, "periodic").await,
109                }
110            }
111        });
112        *guard = Some(LoggerTask { token, handle });
113    }
114}
115
116/// Reads and tallies the `gh` records logged since `started_at` (all sources).
117/// Best-effort: a missing/unreadable log yields zero counts. Offloaded to a
118/// blocking thread by callers, since it reads a file.
119fn counts_since(started_at: DateTime<Utc>) -> GhCounts {
120    match request_log::log_file_path() {
121        Some(path) => github_metrics::aggregate(&path, Some(started_at), None, None),
122        None => GhCounts::default(),
123    }
124}
125
126/// Emits one `tracing::info` summary line, aggregating on a blocking thread so
127/// the log read never stalls the async executor.
128async fn emit(started_at: DateTime<Utc>, phase: &str) {
129    let counts = tokio::task::spawn_blocking(move || counts_since(started_at))
130        .await
131        .unwrap_or_default();
132    // Bind before the macro so the summary is computed whenever this runs, not
133    // only when an info-level subscriber is installed (the poller idiom).
134    let summary = counts.summary_line();
135    tracing::info!("github api calls ({phase}): {summary}");
136}
137
138#[async_trait]
139impl DaemonService for GithubCountersService {
140    fn name(&self) -> &'static str {
141        "github"
142    }
143
144    async fn handle(&self, op: &str, _payload: Value) -> Result<Value> {
145        match op {
146            // A live socket query of the current counters (since daemon start).
147            "summary" => {
148                let started_at = self.started_at;
149                let counts = tokio::task::spawn_blocking(move || counts_since(started_at)).await?;
150                let mut value = counts.to_json();
151                if let Value::Object(map) = &mut value {
152                    map.insert("since".to_string(), json!(started_at.to_rfc3339()));
153                }
154                Ok(value)
155            }
156            other => bail!("unknown github op: {other}"),
157        }
158    }
159
160    fn menu(&self) -> MenuSnapshot {
161        // Kept cheap and non-blocking (polled ~1 Hz): no file read here. The live
162        // numbers live in `daemon status` and the daemon log.
163        MenuSnapshot {
164            title: "GitHub API".to_string(),
165            items: vec![MenuItem::Label(
166                "call counters logged to daemon.log".to_string(),
167            )],
168        }
169    }
170
171    async fn menu_action(&self, _action_id: &str) -> Result<()> {
172        Ok(())
173    }
174
175    async fn status(&self) -> ServiceStatus {
176        let started_at = self.started_at;
177        let counts = tokio::task::spawn_blocking(move || counts_since(started_at))
178            .await
179            .unwrap_or_default();
180        ServiceStatus {
181            name: self.name().to_string(),
182            healthy: true,
183            summary: format!("{} GitHub API call(s) since start", counts.api_total()),
184            detail: counts.to_json(),
185        }
186    }
187
188    async fn shutdown(&self) {
189        // Stop the periodic task (take it from under the lock before awaiting, so
190        // the std::Mutex is never held across the `.await`), then emit the final
191        // summary. This is the deterministic, awaited flush for every termination
192        // path (SIGTERM/SIGINT/SIGHUP + the `shutdown` op).
193        let task = self
194            .logger
195            .lock()
196            .unwrap_or_else(PoisonError::into_inner)
197            .take();
198        if let Some(task) = task {
199            task.token.cancel();
200            let _ = task.handle.await;
201        }
202        emit(self.started_at, "shutdown").await;
203    }
204}
205
206#[cfg(test)]
207#[allow(clippy::unwrap_used, clippy::expect_used)]
208mod tests {
209    use super::*;
210
211    #[tokio::test]
212    async fn service_reports_status_and_handles_summary() {
213        let svc = GithubCountersService::new();
214        assert_eq!(svc.name(), "github");
215
216        // status() reads the log read-only and must be healthy with a well-formed
217        // detail object (counts may be empty/absent — we assert shape, not values).
218        let status = svc.status().await;
219        assert_eq!(status.name, "github");
220        assert!(status.healthy);
221        assert!(status.detail.get("api_total").is_some());
222
223        // `summary` returns the counts plus a `since` marker; unknown ops error.
224        let summary = svc.handle("summary", Value::Null).await.unwrap();
225        assert!(summary.get("since").is_some());
226        assert!(summary.get("by_source").is_some());
227        assert!(svc.handle("bogus", Value::Null).await.is_err());
228
229        // menu()/menu_action()/shutdown() are inert but must not panic; shutdown()
230        // emits the final summary even though no logger task was started.
231        assert_eq!(svc.menu().title, "GitHub API");
232        svc.menu_action("x").await.unwrap();
233        svc.shutdown().await;
234    }
235}