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