Skip to main content

rac_engine/
telemetry.rs

1//! Guide telemetry read-back (`src/asdecided/mcp/telemetry.py`) — ADR-040.
2//!
3//! Read side only: `decided mcp-stats` (and the guide half of `decided usage`)
4//! summarizes the append-only JSONL log under
5//! `$XDG_STATE_HOME/decisions/guide-telemetry.jsonl`. The recorder itself lives
6//! in the MCP serving path and stays a documented no-op seam in the Rust
7//! sidecar (`rust/decided-mcp/src/sidecar.rs`).
8//!
9//! Corruption posture is pinned: a missing log is an empty log; a garbled
10//! line (non-JSON, or JSON that is not an object) is skipped and COUNTED;
11//! a blank line is skipped silently. A log that is not valid UTF-8 makes
12//! the oracle crash (`read_text` raises `UnicodeDecodeError`, which its
13//! `except OSError` does not catch): traceback to stderr, empty stdout,
14//! exit 1 — mirrored here as [`LogNotUtf8`].
15
16use std::collections::HashSet;
17
18use serde_json::{Map, Value};
19
20use crate::consent::xdg_rac_file;
21use crate::pycompat::{py_round, py_splitlines, py_strip, quote_plus_urlencode};
22use crate::pyjson;
23
24pub const SCHEMA_VERSION: &str = "1";
25const TELEMETRY_FILENAME: &str = "guide-telemetry.jsonl";
26
27pub const SHARE_ISSUE_URL: &str = "https://github.com/asdecided/core/issues/new";
28pub const SHARE_TEMPLATE: &str = "guide-usage-report.yml";
29pub const SHARE_FIELD: &str = "report";
30
31/// The oracle's `UnicodeDecodeError` crash on a non-UTF-8 log: empty
32/// stdout, exit 1 (the reader catches only `OSError`).
33pub struct LogNotUtf8;
34
35/// Aggregated usage for one tool, ordered by tool name in the summary.
36pub struct ToolUsage {
37    pub tool: String,
38    pub calls: i64,
39    pub errors: i64,
40    pub truncated: i64,
41    pub avg_duration_ms: i64,
42}
43
44/// What the local log says about Guide usage (the `mcp-stats` payload).
45pub struct TelemetrySummary {
46    pub path: String,
47    pub event_count: i64,
48    pub session_count: i64,
49    pub first_ts: Option<String>,
50    pub last_ts: Option<String>,
51    pub skipped_lines: i64,
52    pub tools: Vec<ToolUsage>,
53}
54
55/// The local telemetry log path under the XDG state directory.
56pub fn telemetry_path() -> String {
57    xdg_rac_file("XDG_STATE_HOME", &[".local", "state"], TELEMETRY_FILENAME)
58}
59
60/// Events plus the count of skipped unreadable lines. Missing file ->
61/// `([], 0)`; non-UTF-8 -> the oracle-crash mirror.
62pub(crate) fn read_events(path: &str) -> Result<(Vec<Map<String, Value>>, i64), LogNotUtf8> {
63    let Ok(bytes) = std::fs::read(path) else {
64        return Ok((Vec::new(), 0));
65    };
66    let Ok(text) = String::from_utf8(bytes) else {
67        return Err(LogNotUtf8);
68    };
69    let mut events = Vec::new();
70    let mut skipped = 0i64;
71    for line in py_splitlines(&text) {
72        if py_strip(line).is_empty() {
73            continue;
74        }
75        match serde_json::from_str::<Value>(line) {
76            Ok(Value::Object(map)) => events.push(map),
77            Ok(_) => skipped += 1,
78            Err(_) => skipped += 1,
79        }
80    }
81    Ok((events, skipped))
82}
83
84/// `isinstance(value, int)` for the duration average — CPython counts
85/// bools as ints (`True` averages as 1), floats never.
86fn py_int_like(v: &Value) -> Option<i128> {
87    match v {
88        Value::Bool(b) => Some(i128::from(*b)),
89        Value::Number(n) => {
90            if let Some(i) = n.as_i64() {
91                Some(i128::from(i))
92            } else {
93                n.as_u64().map(i128::from)
94            }
95        }
96        _ => None,
97    }
98}
99
100/// `round(mean)` over the int durations; empty -> 0. `round()` is CPython
101/// half-to-even over the exact double, via `pycompat::py_round`.
102fn average_duration(rows: &[&Map<String, Value>]) -> i64 {
103    let durations: Vec<i128> = rows
104        .iter()
105        .filter_map(|ev| ev.get("duration_ms").and_then(py_int_like))
106        .collect();
107    if durations.is_empty() {
108        return 0;
109    }
110    let sum: i128 = durations.iter().sum();
111    py_round(sum as f64 / durations.len() as f64, 0) as i64
112}
113
114/// Summarize the telemetry log; an empty or missing log is a valid answer.
115pub fn summarize() -> Result<TelemetrySummary, LogNotUtf8> {
116    let log = telemetry_path();
117    let (events, skipped) = read_events(&log)?;
118    let sessions: HashSet<&str> = events
119        .iter()
120        .filter_map(|ev| ev.get("session").and_then(Value::as_str))
121        .collect();
122    let mut stamps: Vec<&str> = events
123        .iter()
124        .filter_map(|ev| ev.get("ts").and_then(Value::as_str))
125        .collect();
126    stamps.sort_unstable();
127    let mut by_tool: std::collections::BTreeMap<&str, Vec<&Map<String, Value>>> =
128        std::collections::BTreeMap::new();
129    for ev in &events {
130        if let Some(tool) = ev.get("tool").and_then(Value::as_str) {
131            by_tool.entry(tool).or_default().push(ev);
132        }
133    }
134    let tools = by_tool
135        .iter()
136        .map(|(tool, rows)| ToolUsage {
137            tool: (*tool).to_string(),
138            calls: rows.len() as i64,
139            errors: rows
140                .iter()
141                .filter(|ev| {
142                    matches!(
143                        ev.get("outcome").and_then(Value::as_str),
144                        Some("error") | Some("exception")
145                    )
146                })
147                .count() as i64,
148            truncated: rows
149                .iter()
150                .filter(|ev| ev.get("truncated") == Some(&Value::Bool(true)))
151                .count() as i64,
152            avg_duration_ms: average_duration(rows),
153        })
154        .collect();
155    Ok(TelemetrySummary {
156        path: log,
157        event_count: events.len() as i64,
158        session_count: sessions.len() as i64,
159        first_ts: stamps.first().map(|s| s.to_string()),
160        last_ts: stamps.last().map(|s| s.to_string()),
161        skipped_lines: skipped,
162        tools,
163    })
164}
165
166/// `TelemetrySummary.to_dict()` — pinned key order.
167pub fn summary_value(summary: &TelemetrySummary) -> Value {
168    let mut m = Map::new();
169    m.insert("schema_version".into(), Value::String(SCHEMA_VERSION.into()));
170    m.insert("path".into(), Value::String(summary.path.clone()));
171    m.insert("event_count".into(), Value::from(summary.event_count));
172    m.insert("session_count".into(), Value::from(summary.session_count));
173    m.insert(
174        "first_ts".into(),
175        summary
176            .first_ts
177            .clone()
178            .map(Value::String)
179            .unwrap_or(Value::Null),
180    );
181    m.insert(
182        "last_ts".into(),
183        summary
184            .last_ts
185            .clone()
186            .map(Value::String)
187            .unwrap_or(Value::Null),
188    );
189    m.insert("skipped_lines".into(), Value::from(summary.skipped_lines));
190    m.insert(
191        "tools".into(),
192        Value::Array(summary.tools.iter().map(tool_value).collect()),
193    );
194    Value::Object(m)
195}
196
197fn tool_value(tool: &ToolUsage) -> Value {
198    let mut m = Map::new();
199    m.insert("tool".into(), Value::String(tool.tool.clone()));
200    m.insert("calls".into(), Value::from(tool.calls));
201    m.insert("errors".into(), Value::from(tool.errors));
202    m.insert("truncated".into(), Value::from(tool.truncated));
203    m.insert("avg_duration_ms".into(), Value::from(tool.avg_duration_ms));
204    Value::Object(m)
205}
206
207/// The prefilled usage-report issue URL. The local log path is DELETED
208/// from the shared report (counts and timestamps only); the JSON is
209/// `json.dumps(..., ensure_ascii=False, indent=2)` and the query is
210/// `urllib.parse.urlencode` (quote_plus per value).
211pub fn share_url(summary: &TelemetrySummary) -> String {
212    let mut report_data = summary_value(summary);
213    if let Value::Object(map) = &mut report_data {
214        map.shift_remove("path");
215    }
216    let report = pyjson::dumps_indent2_no_ascii(&report_data);
217    let query = quote_plus_urlencode(&[("template", SHARE_TEMPLATE), (SHARE_FIELD, &report)]);
218    format!("{SHARE_ISSUE_URL}?{query}")
219}