Skip to main content

rac_engine/
usage.rs

1//! CLI usage telemetry (`src/asdecided/usage.py`) — ADR-046, content-free,
2//! consent-gated, local-only.
3//!
4//! Two halves, both consent-shaped:
5//! - the READ-BACK (`decided usage`): a unified summary over the CLI-usage log
6//!   (`$XDG_STATE_HOME/decisions/decided-usage.jsonl`) and the Guide log (via
7//!   `telemetry::summarize`), with no consent gate on reads;
8//! - the RECORDER: one content-free event appended after every dispatched
9//!   command, if and only if consent is recorded (`decided telemetry on`).
10//!   Write-only observability: silent on every failure path, never alters
11//!   output or exit codes, and skipped entirely for parse-level exits
12//!   (argparse errors, `--version`/`-h`) exactly like the oracle's
13//!   `cli.main`, which computes the command name only after `parse_args`
14//!   returns.
15//!
16//! The recorder's bytes (wall-clock ts, per-process random session id,
17//! measured duration) are nondeterministic by design and never
18//! byte-refereed; the read-back over SEEDED logs is what parity pins.
19
20use std::collections::HashSet;
21
22use serde_json::{Map, Value};
23
24use crate::consent::{load_consent, now_epoch, token_hex, utc_isoformat_micros, xdg_rac_file};
25use crate::pycompat::{py_splitlines, py_strip, quote_plus_urlencode};
26use crate::pyjson;
27use crate::telemetry::{summary_value, LogNotUtf8, TelemetrySummary};
28
29pub const SCHEMA_VERSION: &str = "1";
30const USAGE_FILENAME: &str = "decided-usage.jsonl";
31pub const OUTCOME_OK: &str = "ok";
32pub const OUTCOME_ERROR: &str = "error";
33
34/// `recent` keeps the last N distinct UTC dates; the oracle's default.
35const RECENT_DAYS: usize = 7;
36
37pub const SHARE_ISSUE_URL: &str = "https://github.com/asdecided/core/issues/new";
38pub const SHARE_TEMPLATE: &str = "guide-usage-report.yml";
39pub const SHARE_FIELD: &str = "report";
40
41pub struct CommandUsage {
42    pub command: String,
43    pub calls: i64,
44    pub errors: i64,
45}
46
47pub struct UsageSummary {
48    pub total: i64,
49    pub sessions: i64,
50    pub commands: Vec<CommandUsage>,
51    /// date (YYYY-MM-DD, UTC) -> event count, ascending, last N days.
52    pub recent: Vec<(String, i64)>,
53}
54
55/// Location of the CLI-usage log (separate from the Guide log, ADR-046).
56pub fn usage_path() -> String {
57    xdg_rac_file("XDG_STATE_HOME", &[".local", "state"], USAGE_FILENAME)
58}
59
60/// Read usage events; a missing or malformed log yields what is parseable
61/// (malformed lines are skipped WITHOUT counting, unlike the Guide log).
62/// Non-UTF-8 mirrors the oracle's `UnicodeDecodeError` crash.
63fn read_usage(path: &str) -> Result<Vec<Map<String, Value>>, LogNotUtf8> {
64    let Ok(bytes) = std::fs::read(path) else {
65        return Ok(Vec::new());
66    };
67    let Ok(text) = String::from_utf8(bytes) else {
68        return Err(LogNotUtf8);
69    };
70    let mut events = Vec::new();
71    for line in py_splitlines(&text) {
72        let line = py_strip(line);
73        if line.is_empty() {
74            continue;
75        }
76        if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(line) {
77            events.push(map);
78        }
79    }
80    Ok(events)
81}
82
83/// Per-command counts, session count, and a recent-activity trend.
84pub fn summarize_usage() -> Result<UsageSummary, LogNotUtf8> {
85    let events = read_usage(&usage_path())?;
86    let sessions: HashSet<&str> = events
87        .iter()
88        .filter_map(|ev| ev.get("session").and_then(Value::as_str))
89        .collect();
90    let mut by_command: std::collections::BTreeMap<&str, Vec<&Map<String, Value>>> =
91        std::collections::BTreeMap::new();
92    for ev in &events {
93        if let Some(command) = ev.get("command").and_then(Value::as_str) {
94            by_command.entry(command).or_default().push(ev);
95        }
96    }
97    let commands = by_command
98        .iter()
99        .map(|(command, rows)| CommandUsage {
100            command: (*command).to_string(),
101            calls: rows.len() as i64,
102            errors: rows
103                .iter()
104                .filter(|ev| {
105                    matches!(
106                        ev.get("outcome").and_then(Value::as_str),
107                        Some("error") | Some("exception")
108                    )
109                })
110                .count() as i64,
111        })
112        .collect();
113    // `ts[:10]` buckets by CODE POINTS with a `len(ts) >= 10` guard; the
114    // Counter is then sorted by date and truncated to the trailing window.
115    let mut day_counts: std::collections::BTreeMap<String, i64> = std::collections::BTreeMap::new();
116    for ev in &events {
117        if let Some(ts) = ev.get("ts").and_then(Value::as_str) {
118            if ts.chars().count() >= 10 {
119                let day: String = ts.chars().take(10).collect();
120                *day_counts.entry(day).or_insert(0) += 1;
121            }
122        }
123    }
124    let skip = day_counts.len().saturating_sub(RECENT_DAYS);
125    let recent = day_counts.into_iter().skip(skip).collect();
126    Ok(UsageSummary {
127        total: events.len() as i64,
128        sessions: sessions.len() as i64,
129        commands,
130        recent,
131    })
132}
133
134/// `UsageSummary.to_dict()` — pinned key order.
135pub fn cli_value(summary: &UsageSummary) -> Value {
136    let mut m = Map::new();
137    m.insert("schema_version".into(), Value::String(SCHEMA_VERSION.into()));
138    m.insert("total".into(), Value::from(summary.total));
139    m.insert("sessions".into(), Value::from(summary.sessions));
140    m.insert(
141        "commands".into(),
142        Value::Array(
143            summary
144                .commands
145                .iter()
146                .map(|c| {
147                    let mut cm = Map::new();
148                    cm.insert("command".into(), Value::String(c.command.clone()));
149                    cm.insert("calls".into(), Value::from(c.calls));
150                    cm.insert("errors".into(), Value::from(c.errors));
151                    Value::Object(cm)
152                })
153                .collect(),
154        ),
155    );
156    let mut recent = Map::new();
157    for (day, count) in &summary.recent {
158        recent.insert(day.clone(), Value::from(*count));
159    }
160    m.insert("recent".into(), Value::Object(recent));
161    Value::Object(m)
162}
163
164/// `_combined(summary, guide)` — the `usage --json`/`--share` payload.
165/// Unlike mcp-stats' share report, the guide dict keeps its `path`.
166pub fn combined_value(cli: &UsageSummary, guide: &TelemetrySummary) -> Value {
167    let mut m = Map::new();
168    m.insert("schema_version".into(), Value::String(SCHEMA_VERSION.into()));
169    m.insert("cli".into(), cli_value(cli));
170    m.insert("guide".into(), summary_value(guide));
171    Value::Object(m)
172}
173
174/// The prefilled GitHub issue URL — the FULL combined report, including
175/// `guide.path` (usage does not strip the path; mcp-stats does).
176pub fn share_url(cli: &UsageSummary, guide: &TelemetrySummary) -> String {
177    let report = pyjson::dumps_indent2_no_ascii(&combined_value(cli, guide));
178    let query = quote_plus_urlencode(&[("template", SHARE_TEMPLATE), (SHARE_FIELD, &report)]);
179    format!("{SHARE_ISSUE_URL}?{query}")
180}
181
182// ---------------------------------------------------------------------------
183// Recorder (write side)
184// ---------------------------------------------------------------------------
185
186/// One random session id per process (`secrets.token_hex(8)`), never
187/// persisted to config.
188fn session_id() -> &'static str {
189    static SESSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
190    SESSION.get_or_init(|| token_hex(8))
191}
192
193/// Append one content-free usage event, if consent is recorded (ADR-046).
194/// Silent on every failure path — no consent, no command name, or an
195/// unwritable log all mean "record nothing".
196pub fn record_command(command: &str, outcome: &str, duration_ms: i64) {
197    if command.is_empty() {
198        return;
199    }
200    if !load_consent().share_usage {
201        return;
202    }
203    let path = usage_path();
204    if let Some(parent) = std::path::Path::new(&path).parent() {
205        if std::fs::create_dir_all(parent).is_err() {
206            return;
207        }
208    }
209    let (secs, micros) = now_epoch();
210    let mut event = Map::new();
211    event.insert("schema_version".into(), Value::String(SCHEMA_VERSION.into()));
212    event.insert(
213        "ts".into(),
214        Value::String(utc_isoformat_micros(secs, micros)),
215    );
216    event.insert("session".into(), Value::String(session_id().to_string()));
217    event.insert("command".into(), Value::String(command.to_string()));
218    event.insert("outcome".into(), Value::String(outcome.to_string()));
219    event.insert("duration_ms".into(), Value::from(duration_ms));
220    let line = pyjson::dumps_compact(&Value::Object(event)) + "\n";
221    use std::io::Write as _;
222    if let Ok(mut handle) = std::fs::OpenOptions::new()
223        .append(true)
224        .create(true)
225        .open(&path)
226    {
227        let _ = handle.write_all(line.as_bytes());
228    }
229}