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}