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