1use 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
31pub struct LogNotUtf8;
34
35pub 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
44pub 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
55pub fn telemetry_path() -> String {
57 xdg_rac_file("XDG_STATE_HOME", &[".local", "state"], TELEMETRY_FILENAME)
58}
59
60pub(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
84fn 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
100fn 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
114pub 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
166pub 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
207pub 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}