Skip to main content

omni_dev/
github_metrics.rs

1//! Counting every GitHub API invocation omni-dev makes (#1387).
2//!
3//! Every GitHub call funnels through the `gh` CLI subprocess (ADR-0003 /
4//! ADR-0050). Opt-in App authentication mints installation tokens in process.
5//! [`run_gh`] is the single choke point
6//! every Rust `gh` call site routes through: it spawns `gh`, records one
7//! `kind: "gh"` line to the request log ([`crate::request_log::record_gh`]), and
8//! returns the process `Output` **unchanged** so call-site behavior and exit
9//! codes are untouched.
10//!
11//! [`aggregate`] reads those records back and tallies them by category,
12//! subcommand, and source. It is the shared backend for `omni-dev log count
13//! --kind gh`, the daemon's periodic/shutdown summaries, and `daemon status`.
14
15use std::collections::BTreeMap;
16use std::io::{self, BufRead, BufReader};
17use std::path::Path;
18use std::process::{Command, Output};
19use std::time::Instant;
20
21use chrono::{DateTime, Utc};
22
23use crate::request_log::{self, GhOutcome, LogRecord, RecordKind, Source};
24
25mod app_auth;
26mod incremental;
27
28pub use incremental::IncrementalCounts;
29
30/// Whether a `gh` invocation hit the GitHub API, and how.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32pub enum Category {
33    /// A direct `gh api …` call (`api graphql`, `api rate_limit`).
34    Api,
35    /// A higher-level subcommand that hits the API indirectly (`pr list`,
36    /// `pr create`, `repo view`, …).
37    Subcommand,
38    /// A local-only `gh` call that hits no API (`gh --version`). Excluded from
39    /// the API-invocation total.
40    Local,
41}
42
43impl Category {
44    /// Derives the category from a record's `command` tokens (the split label).
45    #[must_use]
46    pub fn from_command(command: &[String]) -> Self {
47        match command.first().map(String::as_str) {
48            Some("api") => Self::Api,
49            // A leading `--flag` (only `--version` today) is a local probe.
50            Some(first) if first.starts_with('-') => Self::Local,
51            _ => Self::Subcommand,
52        }
53    }
54
55    /// Stable lowercase name, used for JSON map keys and display.
56    #[must_use]
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Self::Api => "api",
60            Self::Subcommand => "subcommand",
61            Self::Local => "local",
62        }
63    }
64}
65
66/// Lowercase name of a [`Source`] for display / JSON map keys.
67#[must_use]
68pub fn source_str(source: Source) -> &'static str {
69    match source {
70        Source::Cli => "cli",
71        Source::Mcp => "mcp",
72        Source::Daemon => "daemon",
73        Source::Unknown => "unknown",
74    }
75}
76
77/// Runs one `gh` invocation through the choke point, recording a `kind: "gh"`
78/// request-log line for it.
79///
80/// `label` is the semantic subcommand (`"api graphql"`, `"pr list"`,
81/// `"--version"`); it is split into the record's `command` for per-subcommand
82/// aggregation and category derivation. `cwd`, when given, sets the child's
83/// working directory (several call sites run `gh` inside a repo).
84///
85/// **Blocking** (`Command::output`) — daemon callers must already be on a
86/// blocking thread. The `Output` (or spawn `io::Error`) is returned verbatim, so
87/// this is a drop-in for an existing `Command::new("gh").…​.output()`; logging is
88/// best-effort and never alters the result. Source is read from the ambient
89/// [`request_log::current_context`], so no call site threads it.
90pub fn run_gh<I, S>(bin: &Path, args: I, label: &str, cwd: Option<&Path>) -> io::Result<Output>
91where
92    I: IntoIterator<Item = S>,
93    S: AsRef<str>,
94{
95    let argv: Vec<String> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
96
97    let mut cmd = Command::new(bin);
98    cmd.args(&argv);
99    if let Some(dir) = cwd {
100        cmd.current_dir(dir);
101    }
102
103    let started = Instant::now();
104    let result = app_auth::configure(&mut cmd, &argv, &crate::utils::env::SystemEnv)
105        .and_then(|()| cmd.output());
106    let duration = started.elapsed();
107
108    let (exit_code, error) = match &result {
109        Ok(output) => (output.status.code(), None),
110        Err(e) => (None, Some(e.to_string())),
111    };
112    request_log::record_gh(GhOutcome {
113        label: label.to_string(),
114        argv,
115        exit_code,
116        duration,
117        error,
118    });
119
120    result
121}
122
123/// Tallies of `gh` invocations over a time window, broken down three ways.
124#[derive(Debug, Clone, Default, PartialEq, Eq)]
125pub struct GhCounts {
126    /// Count per category (api / subcommand / local).
127    pub by_category: BTreeMap<Category, u64>,
128    /// Count per semantic subcommand (`"api graphql"`, `"pr list"`, …).
129    pub by_subcommand: BTreeMap<String, u64>,
130    /// Count per originating source (cli / daemon / mcp).
131    pub by_source: BTreeMap<Source, u64>,
132}
133
134impl GhCounts {
135    /// Every `gh` invocation counted, including local (`--version`) probes.
136    #[must_use]
137    pub fn total(&self) -> u64 {
138        self.by_category.values().copied().sum()
139    }
140
141    /// GitHub **API** invocations — everything except local probes. This is the
142    /// headline "calls omni-dev made" figure.
143    #[must_use]
144    pub fn api_total(&self) -> u64 {
145        self.by_category
146            .iter()
147            .filter(|(cat, _)| **cat != Category::Local)
148            .map(|(_, n)| *n)
149            .sum()
150    }
151
152    /// A single compact line for the daemon's `tracing` summaries.
153    #[must_use]
154    pub fn summary_line(&self) -> String {
155        let sources: Vec<String> = self
156            .by_source
157            .iter()
158            .map(|(s, n)| format!("{}={n}", source_str(*s)))
159            .collect();
160        let subs: Vec<String> = self
161            .by_subcommand
162            .iter()
163            .map(|(name, n)| format!("{name}={n}"))
164            .collect();
165        let local = self.by_category.get(&Category::Local).copied().unwrap_or(0);
166        format!(
167            "{} api call(s) (total {}, local {local}); by source: [{}]; by subcommand: [{}]",
168            self.api_total(),
169            self.total(),
170            sources.join(" "),
171            subs.join(" "),
172        )
173    }
174
175    /// Machine-readable form with string map keys (composes with `jq`). Shared
176    /// by `omni-dev github count --json` and the daemon's `summary` op.
177    #[must_use]
178    pub fn to_json(&self) -> serde_json::Value {
179        let by_category: BTreeMap<&str, u64> = self
180            .by_category
181            .iter()
182            .map(|(c, n)| (c.as_str(), *n))
183            .collect();
184        let by_source: BTreeMap<&str, u64> = self
185            .by_source
186            .iter()
187            .map(|(s, n)| (source_str(*s), *n))
188            .collect();
189        serde_json::json!({
190            "api_total": self.api_total(),
191            "total": self.total(),
192            "by_category": by_category,
193            "by_subcommand": self.by_subcommand,
194            "by_source": by_source,
195        })
196    }
197
198    fn tally(&mut self, command: &[String], source: Source) {
199        *self
200            .by_category
201            .entry(Category::from_command(command))
202            .or_default() += 1;
203        *self.by_subcommand.entry(command.join(" ")).or_default() += 1;
204        *self.by_source.entry(source).or_default() += 1;
205    }
206}
207
208/// Aggregates `gh` records from the request log at `path` within the optional
209/// `[since, until]` window and (optionally) restricted to one `source`.
210///
211/// Best-effort and tolerant like the rest of the log stack: a missing file or
212/// malformed/partial lines yield empty/skipped tallies rather than an error, so
213/// callers (CLI, daemon) never fail on a bad log.
214#[must_use]
215pub fn aggregate(
216    path: &Path,
217    since: Option<DateTime<Utc>>,
218    until: Option<DateTime<Utc>>,
219    source: Option<Source>,
220) -> GhCounts {
221    let mut counts = GhCounts::default();
222    let Ok(file) = std::fs::File::open(path) else {
223        return counts; // no log yet → zero counts
224    };
225    let filter = Filter {
226        since,
227        until,
228        source,
229    };
230    for line in BufReader::new(file).lines() {
231        let Ok(line) = line else { break };
232        if line.is_empty() {
233            continue;
234        }
235        let Ok(rec) = serde_json::from_str::<LogRecord>(&line) else {
236            continue; // skip malformed/partial lines, as the stream reader does
237        };
238        filter.tally(&mut counts, &rec);
239    }
240    counts
241}
242
243/// What a record must be to be tallied: a `gh` record, optionally from one
244/// `source`, optionally inside a `[since, until]` window.
245///
246/// Shared by [`aggregate`] (an arbitrary window over the whole log) and
247/// [`IncrementalCounts`] (one fixed window over the records appended since it
248/// started), so the two cannot disagree about what counts.
249#[derive(Debug, Clone, Copy)]
250struct Filter {
251    since: Option<DateTime<Utc>>,
252    until: Option<DateTime<Utc>>,
253    source: Option<Source>,
254}
255
256impl Filter {
257    /// Tallies `rec` into `counts` when it passes the filter.
258    fn tally(&self, counts: &mut GhCounts, rec: &LogRecord) {
259        if rec.kind != RecordKind::Gh {
260            return;
261        }
262        let rec_source = rec.source.unwrap_or(Source::Unknown);
263        if self.source.is_some_and(|want| rec_source != want) {
264            return;
265        }
266        if self.since.is_some() || self.until.is_some() {
267            let Some(ts) = parse_timestamp(&rec.timestamp) else {
268                return; // undateable record can't be windowed → drop it
269            };
270            if self.since.is_some_and(|s| ts < s) || self.until.is_some_and(|u| ts > u) {
271                return;
272            }
273        }
274        counts.tally(&rec.command, rec_source);
275    }
276}
277
278/// Parses a record's RFC3339 timestamp to UTC, or `None` when unparseable.
279/// Shared with `omni-dev log count`'s generic aggregator so both windows records
280/// identically.
281pub(crate) fn parse_timestamp(ts: &str) -> Option<DateTime<Utc>> {
282    DateTime::parse_from_rfc3339(ts)
283        .ok()
284        .map(|dt| dt.with_timezone(&Utc))
285}
286
287#[cfg(test)]
288#[allow(clippy::unwrap_used, clippy::expect_used)]
289mod tests {
290    use super::*;
291    use std::io::Write;
292
293    fn cmd(parts: &[&str]) -> Vec<String> {
294        parts.iter().copied().map(String::from).collect()
295    }
296
297    #[test]
298    fn category_from_command_classifies_api_subcommand_and_local() {
299        assert_eq!(
300            Category::from_command(&cmd(&["api", "graphql"])),
301            Category::Api
302        );
303        assert_eq!(
304            Category::from_command(&cmd(&["api", "rate_limit"])),
305            Category::Api
306        );
307        assert_eq!(
308            Category::from_command(&cmd(&["pr", "list"])),
309            Category::Subcommand
310        );
311        assert_eq!(
312            Category::from_command(&cmd(&["repo", "view"])),
313            Category::Subcommand
314        );
315        assert_eq!(
316            Category::from_command(&cmd(&["--version"])),
317            Category::Local
318        );
319        assert_eq!(Category::from_command(&[]), Category::Subcommand);
320    }
321
322    /// Five `gh` records (2 api, 2 `pr list`, 1 local) plus a non-gh record and
323    /// two malformed lines the aggregator must ignore.
324    const SAMPLE: &[&str] = &[
325        r#"{"kind":"gh","timestamp":"2026-07-21T10:00:00.000Z","command":["api","graphql"],"source":"daemon"}"#,
326        r#"{"kind":"gh","timestamp":"2026-07-21T10:01:00.000Z","command":["api","rate_limit"],"source":"daemon"}"#,
327        r#"{"kind":"gh","timestamp":"2026-07-21T10:02:00.000Z","command":["pr","list"],"source":"cli"}"#,
328        r#"{"kind":"gh","timestamp":"2026-07-21T10:03:00.000Z","command":["pr","list"],"source":"cli"}"#,
329        r#"{"kind":"gh","timestamp":"2026-07-21T10:04:00.000Z","command":["--version"],"source":"cli"}"#,
330        r#"{"kind":"http","timestamp":"2026-07-21T10:05:00.000Z","service":"jira"}"#,
331        "not json",
332        "",
333    ];
334
335    fn write_log(lines: &[&str]) -> tempfile::NamedTempFile {
336        let mut f = tempfile::NamedTempFile::new().unwrap();
337        for line in lines {
338            writeln!(f, "{line}").unwrap();
339        }
340        f.flush().unwrap();
341        f
342    }
343
344    fn utc(ts: &str) -> DateTime<Utc> {
345        DateTime::parse_from_rfc3339(ts)
346            .unwrap()
347            .with_timezone(&Utc)
348    }
349
350    #[test]
351    fn aggregate_tallies_by_category_subcommand_and_source_ignoring_non_gh() {
352        let f = write_log(SAMPLE);
353        let counts = aggregate(f.path(), None, None, None);
354        assert_eq!(counts.total(), 5); // 5 gh records; http + junk ignored
355        assert_eq!(counts.api_total(), 4); // excludes the 1 local `--version`
356        assert_eq!(counts.by_category[&Category::Api], 2);
357        assert_eq!(counts.by_category[&Category::Subcommand], 2);
358        assert_eq!(counts.by_category[&Category::Local], 1);
359        assert_eq!(counts.by_subcommand["pr list"], 2);
360        assert_eq!(counts.by_subcommand["api graphql"], 1);
361        assert_eq!(counts.by_source[&Source::Daemon], 2);
362        assert_eq!(counts.by_source[&Source::Cli], 3);
363    }
364
365    #[test]
366    fn aggregate_filters_by_source() {
367        let f = write_log(SAMPLE);
368        let counts = aggregate(f.path(), None, None, Some(Source::Daemon));
369        assert_eq!(counts.total(), 2);
370        assert_eq!(counts.api_total(), 2);
371        assert!(!counts.by_source.contains_key(&Source::Cli));
372    }
373
374    #[test]
375    fn aggregate_filters_by_time_window() {
376        let f = write_log(SAMPLE);
377        // Only the two `pr list` records fall in [10:02, 10:03:30].
378        let counts = aggregate(
379            f.path(),
380            Some(utc("2026-07-21T10:02:00.000Z")),
381            Some(utc("2026-07-21T10:03:30.000Z")),
382            None,
383        );
384        assert_eq!(counts.total(), 2);
385        assert_eq!(counts.by_subcommand["pr list"], 2);
386    }
387
388    #[test]
389    fn aggregate_missing_file_is_empty() {
390        let counts = aggregate(
391            std::path::Path::new("/nonexistent/omni-dev/log.jsonl"),
392            None,
393            None,
394            None,
395        );
396        assert_eq!(counts, GhCounts::default());
397        assert_eq!(counts.total(), 0);
398    }
399
400    #[test]
401    fn source_str_covers_every_source() {
402        assert_eq!(source_str(Source::Cli), "cli");
403        assert_eq!(source_str(Source::Mcp), "mcp");
404        assert_eq!(source_str(Source::Daemon), "daemon");
405        assert_eq!(source_str(Source::Unknown), "unknown");
406    }
407
408    #[test]
409    fn aggregate_drops_undateable_record_when_windowed() {
410        // A gh record whose timestamp cannot be parsed can't be placed in or out
411        // of a window, so a windowed query drops it — while an unwindowed query
412        // still counts it.
413        let lines = &[
414            r#"{"kind":"gh","timestamp":"not-a-timestamp","command":["pr","list"],"source":"cli"}"#,
415            r#"{"kind":"gh","timestamp":"2026-07-21T10:02:00.000Z","command":["pr","list"],"source":"cli"}"#,
416        ];
417        let f = write_log(lines);
418        let windowed = aggregate(
419            f.path(),
420            Some(utc("2026-07-21T00:00:00.000Z")),
421            Some(utc("2026-07-22T00:00:00.000Z")),
422            None,
423        );
424        assert_eq!(windowed.total(), 1); // only the dateable record survives
425        assert_eq!(aggregate(f.path(), None, None, None).total(), 2);
426    }
427
428    #[test]
429    fn to_json_has_string_keys_and_totals() {
430        let f = write_log(SAMPLE);
431        let v = aggregate(f.path(), None, None, None).to_json();
432        assert_eq!(v["api_total"], 4);
433        assert_eq!(v["total"], 5);
434        assert_eq!(v["by_category"]["api"], 2);
435        assert_eq!(v["by_category"]["local"], 1);
436        assert_eq!(v["by_subcommand"]["pr list"], 2);
437        assert_eq!(v["by_source"]["daemon"], 2);
438    }
439
440    #[test]
441    fn summary_line_mentions_api_total() {
442        let f = write_log(SAMPLE);
443        let s = aggregate(f.path(), None, None, None).summary_line();
444        assert!(s.contains("4 api call"), "summary was: {s}");
445    }
446}