use std::collections::HashMap;
use anyhow::Result;
use crate::telemetry;
pub fn run(
enable: bool,
disable: bool,
status: bool,
show: bool,
all: bool,
clear: bool,
) -> Result<i32> {
if enable {
telemetry::write_consent(true)
.map_err(|e| anyhow::anyhow!("failed to write consent: {}", e))?;
println!("telemetry enabled — events will be recorded locally");
return Ok(0);
}
if disable {
telemetry::write_consent(false)
.map_err(|e| anyhow::anyhow!("failed to write consent: {}", e))?;
println!("telemetry disabled");
return Ok(0);
}
if clear {
if let Some(dir) = telemetry::telemetry_dir() {
if dir.is_dir() {
std::fs::remove_dir_all(&dir)
.map_err(|e| anyhow::anyhow!("failed to clear telemetry: {}", e))?;
println!("telemetry data cleared");
} else {
println!("no telemetry data to clear");
}
} else {
println!("no telemetry data directory");
}
return Ok(0);
}
if show || all {
return telemetry_show(all);
}
let _ = status;
telemetry_status()
}
fn telemetry_status() -> Result<i32> {
telemetry::cleanup_telemetry_dir();
let (consent, reason) = telemetry::check_consent();
let state_str = match consent {
telemetry::Consent::Enabled => "enabled",
telemetry::Consent::Disabled => "disabled",
};
let reason_str = match reason {
telemetry::ConsentReason::DoNotTrack => "DO_NOT_TRACK=1",
telemetry::ConsentReason::EnvVar => "TKT_TELEMETRY env var",
telemetry::ConsentReason::CiDetected => "CI environment detected",
telemetry::ConsentReason::ConfigFile => "consent.toml",
telemetry::ConsentReason::Default => "default — never opted in",
};
println!("telemetry: {} ({})", state_str, reason_str);
if let Some(dir) = telemetry::telemetry_dir() {
if dir.is_dir() {
let mut total_bytes: u64 = 0;
let mut total_events: usize = 0;
let mut projects: Vec<(String, usize, u64)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "jsonl") {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
let lines = std::fs::read_to_string(&path)
.map(|c| c.lines().filter(|l| !l.is_empty()).count())
.unwrap_or(0);
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
total_bytes += size;
total_events += lines;
projects.push((name, lines, size));
}
}
}
projects.sort_by_key(|p| std::cmp::Reverse(p.1));
println!(
"storage: {} ({} events across {} projects)",
format_bytes(total_bytes),
total_events,
projects.len()
);
for (name, events, bytes) in &projects {
println!(" {}: {} events ({})", name, events, format_bytes(*bytes));
}
} else {
println!("storage: 0 bytes (no data)");
}
} else {
println!("storage: unavailable (no data directory)");
}
if let Some(path) = telemetry::consent_file_path() {
let exists = if path.is_file() { "found" } else { "not found" };
println!("consent file: {} ({})", path.display(), exists);
}
let dnt = std::env::var("DO_NOT_TRACK").unwrap_or_else(|_| "unset".to_string());
let tkt_tel = std::env::var("TKT_TELEMETRY").unwrap_or_else(|_| "unset".to_string());
let ci = std::env::var("CI").unwrap_or_else(|_| "unset".to_string());
println!(
"env overrides: DO_NOT_TRACK={}, TKT_TELEMETRY={}, CI={}",
dnt, tkt_tel, ci
);
Ok(0)
}
fn telemetry_show(show_all: bool) -> Result<i32> {
let dir = match telemetry::telemetry_dir() {
Some(d) if d.is_dir() => d,
_ => {
println!("no telemetry data");
return Ok(0);
}
};
let mut all_lines: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "jsonl") {
if let Ok(content) = std::fs::read_to_string(&path) {
for line in content.lines() {
if !line.is_empty() {
all_lines.push(line.to_string());
}
}
}
}
}
}
if all_lines.is_empty() {
println!("no telemetry events recorded");
return Ok(0);
}
all_lines.sort_by(|a, b| extract_ts(a.as_str()).cmp(extract_ts(b.as_str())));
print_summary(&all_lines);
print_workflows(&all_lines);
print_friction(&all_lines);
println!();
let start = if show_all {
0
} else {
all_lines.len().saturating_sub(20)
};
let showing = all_lines.len() - start;
if show_all {
println!("all events ({}):", all_lines.len());
} else {
println!(
"recent events ({} total, showing last {}):",
all_lines.len(),
showing
);
}
println!();
for line in &all_lines[start..] {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
let error_part = val["error_kind"]
.as_str()
.map(|k| format!(" err={}", k))
.unwrap_or_default();
println!(
" {} {} cmd={} exit={}{} {}ms",
val["ts"].as_str().unwrap_or("?"),
val["project"].as_str().unwrap_or("?"),
val["cmd"].as_str().unwrap_or("?"),
val["exit_code"].as_i64().unwrap_or(-1),
error_part,
val["duration_ms"].as_u64().unwrap_or(0),
);
} else {
println!(" {}", line);
}
}
Ok(0)
}
fn print_summary(lines: &[String]) {
let mut cmd_counts: HashMap<String, usize> = HashMap::new();
let mut error_count: usize = 0;
let mut slow_cmds: Vec<(String, String, u64)> = Vec::new();
for line in lines {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
let cmd = val["cmd"].as_str().unwrap_or("?").to_string();
*cmd_counts.entry(cmd.clone()).or_default() += 1;
if val["exit_code"].as_i64().unwrap_or(0) != 0 {
error_count += 1;
}
let duration = val["duration_ms"].as_u64().unwrap_or(0);
if duration > 2000 {
let ts = val["ts"].as_str().unwrap_or("?").to_string();
slow_cmds.push((ts, cmd, duration));
}
}
}
let mut cmd_list: Vec<_> = cmd_counts.iter().collect();
cmd_list.sort_by_key(|(_, count)| std::cmp::Reverse(**count));
print!("commands:");
for (cmd, count) in &cmd_list {
print!(" {}:{}", cmd, count);
}
println!();
let total = lines.len();
if error_count > 0 {
println!(
"errors: {}/{} ({:.0}%)",
error_count,
total,
(error_count as f64 / total as f64) * 100.0
);
} else {
println!("errors: 0");
}
if !slow_cmds.is_empty() {
println!("slow (>2s): {}", slow_cmds.len());
for (ts, cmd, ms) in slow_cmds.iter().rev().take(5) {
println!(" {} {} {:.1}s", ts, cmd, *ms as f64 / 1000.0);
}
}
}
fn print_workflows(lines: &[String]) {
if lines.len() < 3 {
return;
}
struct Ev {
ts: String,
project: String,
cmd: String,
exit_code: i64,
}
let events: Vec<Ev> = lines
.iter()
.filter_map(|line| {
serde_json::from_str::<serde_json::Value>(line)
.ok()
.map(|v| Ev {
ts: v["ts"].as_str().unwrap_or("").to_string(),
project: v["project"].as_str().unwrap_or("").to_string(),
cmd: v["cmd"].as_str().unwrap_or("").to_string(),
exit_code: v["exit_code"].as_i64().unwrap_or(0),
})
})
.collect();
let mut retries: usize = 0;
let mut complete_workflows: usize = 0;
let mut batch_worthy: usize = 0;
for i in 1..events.len() {
let prev = &events[i - 1];
let cur = &events[i];
if prev.cmd == cur.cmd
&& prev.project == cur.project
&& prev.exit_code != 0
&& within_seconds(&prev.ts, &cur.ts, 300)
{
retries += 1;
}
}
for (i, ev) in events.iter().enumerate() {
if ev.cmd != "ready" {
continue;
}
for later in &events[(i + 1)..] {
if later.project != ev.project {
continue;
}
if !within_seconds(&ev.ts, &later.ts, 3600) {
break;
}
if later.cmd == "close" && later.exit_code == 0 {
complete_workflows += 1;
break;
}
}
}
let mut i = 0;
while i < events.len() {
if events[i].cmd == "new" && events[i].exit_code == 0 {
let mut streak = 1;
let start_ts = &events[i].ts;
let start_project = &events[i].project;
let mut next = i + 1;
while next < events.len()
&& events[next].cmd == "new"
&& events[next].project == *start_project
&& events[next].exit_code == 0
&& within_seconds(start_ts, &events[next].ts, 60)
{
streak += 1;
next += 1;
}
if streak >= 3 {
batch_worthy += 1;
i = next;
continue;
}
}
i += 1;
}
if retries == 0 && complete_workflows == 0 && batch_worthy == 0 {
return;
}
println!("workflows:");
if complete_workflows > 0 {
println!(" complete (ready→close): {}", complete_workflows);
}
if retries > 0 {
println!(" retries (fail→retry <5min): {}", retries);
}
if batch_worthy > 0 {
println!(
" batch-worthy (3+ new in <1min): {} (consider tkt batch)",
batch_worthy
);
}
}
fn print_friction(lines: &[String]) {
if lines.len() < 10 {
return;
}
struct Ev {
ts: String,
project: String,
cmd: String,
exit_code: i64,
duration_ms: u64,
}
let events: Vec<Ev> = lines
.iter()
.filter_map(|line| {
serde_json::from_str::<serde_json::Value>(line)
.ok()
.map(|v| Ev {
ts: v["ts"].as_str().unwrap_or("").to_string(),
project: v["project"].as_str().unwrap_or("").to_string(),
cmd: v["cmd"].as_str().unwrap_or("").to_string(),
exit_code: v["exit_code"].as_i64().unwrap_or(0),
duration_ms: v["duration_ms"].as_u64().unwrap_or(0),
})
})
.collect();
let mut durations_by_cmd: HashMap<String, Vec<u64>> = HashMap::new();
for ev in &events {
durations_by_cmd
.entry(ev.cmd.clone())
.or_default()
.push(ev.duration_ms);
}
let median_by_cmd: HashMap<&str, u64> = durations_by_cmd
.iter_mut()
.map(|(cmd, durs)| {
durs.sort_unstable();
let med = durs[durs.len() / 2];
(cmd.as_str(), med)
})
.collect();
struct FrictionStats {
total: usize,
errors: usize,
retries: usize,
slow: usize,
}
let mut stats: HashMap<String, FrictionStats> = HashMap::new();
for (i, ev) in events.iter().enumerate() {
let entry = stats.entry(ev.cmd.clone()).or_insert(FrictionStats {
total: 0,
errors: 0,
retries: 0,
slow: 0,
});
entry.total += 1;
if ev.exit_code != 0 {
entry.errors += 1;
}
if i > 0 {
let prev = &events[i - 1];
if prev.cmd == ev.cmd
&& prev.project == ev.project
&& prev.exit_code != 0
&& within_seconds(&prev.ts, &ev.ts, 30)
{
entry.retries += 1;
}
}
let med = median_by_cmd.get(ev.cmd.as_str()).copied().unwrap_or(0);
if med > 100 && ev.duration_ms > med * 2 {
entry.slow += 1;
}
}
let mut friction_list: Vec<(&str, f64, &FrictionStats)> = stats
.iter()
.filter_map(|(cmd, s)| {
if s.total == 0 {
return None;
}
let friction_events = s.errors + s.retries + s.slow;
if friction_events == 0 {
return None;
}
let score = friction_events as f64 / s.total as f64;
Some((cmd.as_str(), score, s))
})
.collect();
friction_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
if friction_list.is_empty() {
return;
}
print!("friction:");
for (cmd, _score, s) in &friction_list {
let friction_events = s.errors + s.retries + s.slow;
let parts: Vec<String> = [
(s.errors > 0).then(|| format!("{} fail", s.errors)),
(s.retries > 0).then(|| format!("{} retry", s.retries)),
(s.slow > 0).then(|| format!("{} slow", s.slow)),
]
.into_iter()
.flatten()
.collect();
print!(
" {} {}/{} ({})",
cmd,
friction_events,
s.total,
parts.join(", ")
);
print!(" |");
}
println!();
}
fn within_seconds(ts1: &str, ts2: &str, max_secs: u64) -> bool {
let s1 = ts_to_secs(ts1);
let s2 = ts_to_secs(ts2);
match (s1, s2) {
(Some(a), Some(b)) => a.abs_diff(b) <= max_secs,
_ => false,
}
}
fn ts_to_secs(ts: &str) -> Option<u64> {
if ts.len() < 19 {
return None;
}
let year: u64 = ts[0..4].parse().ok()?;
let month: u64 = ts[5..7].parse().ok()?;
let day: u64 = ts[8..10].parse().ok()?;
let hour: u64 = ts[11..13].parse().ok()?;
let min: u64 = ts[14..16].parse().ok()?;
let sec: u64 = ts[17..19].parse().ok()?;
Some(((year * 365 + month * 30 + day) * 86400) + hour * 3600 + min * 60 + sec)
}
fn extract_ts(line: &str) -> &str {
if let Some(start) = line.find("\"ts\":\"") {
let rest = &line[start + 6..];
if let Some(end) = rest.find('"') {
return &rest[..end];
}
}
""
}
fn format_bytes(bytes: u64) -> String {
if bytes < 1024 {
format!("{} bytes", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}