mirror of
https://github.com/rtk-ai/rtk.git
synced 2026-09-19 07:33:17 +08:00
607 lines
19 KiB
Rust
607 lines
19 KiB
Rust
//! Optional usage ping so we know which commands people run most.
|
|
|
|
use super::constants::RTK_DATA_DIR;
|
|
use crate::core::config;
|
|
use crate::core::tracking;
|
|
use crate::hooks::constants::CLAUDE_DIR;
|
|
use crate::hooks::init::resolve_claude_dir;
|
|
use sha2::{Digest, Sha256};
|
|
use std::fmt::Write as FmtWrite;
|
|
use std::io::Write as IoWrite;
|
|
use std::path::PathBuf;
|
|
use std::sync::OnceLock;
|
|
|
|
static CACHED_SALT: OnceLock<String> = OnceLock::new();
|
|
|
|
const TELEMETRY_URL: Option<&str> = option_env!("RTK_TELEMETRY_URL");
|
|
const TELEMETRY_TOKEN: Option<&str> = option_env!("RTK_TELEMETRY_TOKEN");
|
|
const PING_INTERVAL_SECS: u64 = 23 * 3600; // 23 hours
|
|
|
|
/// Send a telemetry ping if enabled and not already sent today.
|
|
/// Fire-and-forget: errors are silently ignored.
|
|
pub fn maybe_ping() {
|
|
// No URL compiled in → telemetry disabled
|
|
if TELEMETRY_URL.is_none() {
|
|
return;
|
|
}
|
|
|
|
// Check opt-out: env var
|
|
if std::env::var("RTK_TELEMETRY_DISABLED").unwrap_or_default() == "1" {
|
|
return;
|
|
}
|
|
|
|
// Load config once (avoid double disk read)
|
|
let cfg = match config::Config::load() {
|
|
Ok(c) => c,
|
|
Err(_) => return,
|
|
};
|
|
|
|
// RGPD: require explicit consent before any telemetry
|
|
match cfg.telemetry.consent_given {
|
|
Some(true) => {}
|
|
Some(false) | None => return,
|
|
}
|
|
|
|
// Check opt-out: config.toml
|
|
if !cfg.telemetry.enabled {
|
|
return;
|
|
}
|
|
|
|
// Check last ping time
|
|
let marker = telemetry_marker_path();
|
|
if let Ok(metadata) = std::fs::metadata(&marker) {
|
|
if let Ok(modified) = metadata.modified() {
|
|
if let Ok(elapsed) = modified.elapsed() {
|
|
if elapsed.as_secs() < PING_INTERVAL_SECS {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Touch marker file immediately (before sending) to avoid double-ping
|
|
touch_marker(&marker);
|
|
|
|
// Spawn thread so we never block the CLI
|
|
std::thread::spawn(|| {
|
|
let _ = send_ping();
|
|
});
|
|
}
|
|
|
|
fn send_ping() -> Result<(), Box<dyn std::error::Error>> {
|
|
let url = TELEMETRY_URL.ok_or("no telemetry URL")?;
|
|
let device_hash = generate_device_hash();
|
|
let version = env!("CARGO_PKG_VERSION").to_string();
|
|
let os = std::env::consts::OS.to_string();
|
|
let arch = std::env::consts::ARCH.to_string();
|
|
let install_method = detect_install_method();
|
|
|
|
// Get stats from tracking DB (single connection for both basic + enriched)
|
|
let tracker = tracking::Tracker::new().ok();
|
|
let (commands_24h, top_commands, savings_pct, tokens_saved_24h, tokens_saved_total) =
|
|
match &tracker {
|
|
Some(t) => get_stats(t),
|
|
None => (0, vec![], None, 0, 0),
|
|
};
|
|
let enriched = match &tracker {
|
|
Some(t) => get_enriched_stats(t),
|
|
None => EnrichedStats {
|
|
passthrough_top: vec![],
|
|
parse_failures_24h: 0,
|
|
low_savings_commands: vec![],
|
|
avg_savings_per_command: 0.0,
|
|
hook_type: detect_hook_type(),
|
|
custom_toml_filters: count_custom_toml_filters(),
|
|
first_seen_days: 0,
|
|
active_days_30d: 0,
|
|
commands_total: 0,
|
|
ecosystem_mix: serde_json::json!({}),
|
|
tokens_saved_30d: 0,
|
|
estimated_savings_usd_30d: 0.0,
|
|
has_config_toml: detect_has_config(),
|
|
exclude_commands_count: count_exclude_commands(),
|
|
projects_count: 0,
|
|
meta_usage: serde_json::json!({}),
|
|
},
|
|
};
|
|
|
|
let payload = serde_json::json!({
|
|
"device_hash": device_hash,
|
|
"version": version,
|
|
"os": os,
|
|
"arch": arch,
|
|
"install_method": install_method,
|
|
"commands_24h": commands_24h,
|
|
"top_commands": top_commands,
|
|
"savings_pct": savings_pct,
|
|
"tokens_saved_24h": tokens_saved_24h,
|
|
"tokens_saved_total": tokens_saved_total,
|
|
// Quality: identify gaps and weak filters
|
|
"passthrough_top": enriched.passthrough_top,
|
|
"parse_failures_24h": enriched.parse_failures_24h,
|
|
"low_savings_commands": enriched.low_savings_commands,
|
|
"avg_savings_per_command": enriched.avg_savings_per_command,
|
|
// Adoption: which tools and configs
|
|
"hook_type": enriched.hook_type,
|
|
"custom_toml_filters": enriched.custom_toml_filters,
|
|
// Retention: engagement signals
|
|
"first_seen_days": enriched.first_seen_days,
|
|
"active_days_30d": enriched.active_days_30d,
|
|
"commands_total": enriched.commands_total,
|
|
// Ecosystem: where to invest filters
|
|
"ecosystem_mix": enriched.ecosystem_mix,
|
|
// Economics: value delivered
|
|
"tokens_saved_30d": enriched.tokens_saved_30d,
|
|
"estimated_savings_usd_30d": enriched.estimated_savings_usd_30d,
|
|
// Configuration: user maturity
|
|
"has_config_toml": enriched.has_config_toml,
|
|
"exclude_commands_count": enriched.exclude_commands_count,
|
|
"projects_count": enriched.projects_count,
|
|
// Meta-commands: feature adoption
|
|
"meta_usage": enriched.meta_usage,
|
|
});
|
|
|
|
let mut req = ureq::post(url).set("Content-Type", "application/json");
|
|
|
|
if let Some(token) = TELEMETRY_TOKEN {
|
|
req = req.set("X-RTK-Token", token);
|
|
}
|
|
|
|
// 2 second timeout — if server is down, we move on
|
|
req.timeout(std::time::Duration::from_secs(2))
|
|
.send_string(&payload.to_string())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn generate_device_hash() -> String {
|
|
let salt = get_or_create_salt();
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(salt.as_bytes());
|
|
format!("{:x}", hasher.finalize())
|
|
}
|
|
|
|
fn get_or_create_salt() -> String {
|
|
CACHED_SALT
|
|
.get_or_init(|| {
|
|
let salt_path = salt_file_path();
|
|
|
|
if let Ok(contents) = std::fs::read_to_string(&salt_path) {
|
|
let trimmed = contents.trim().to_string();
|
|
if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
let salt = random_salt();
|
|
if let Some(parent) = salt_path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
if let Ok(mut f) = std::fs::File::create(&salt_path) {
|
|
let _ = f.write_all(salt.as_bytes());
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = std::fs::set_permissions(
|
|
&salt_path,
|
|
std::fs::Permissions::from_mode(0o600),
|
|
);
|
|
}
|
|
}
|
|
salt
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
fn random_salt() -> String {
|
|
let mut buf = [0u8; 32];
|
|
if getrandom::fill(&mut buf).is_err() {
|
|
let fallback = format!("{:?}:{}", std::time::SystemTime::now(), std::process::id());
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(fallback.as_bytes());
|
|
return format!("{:x}", hasher.finalize());
|
|
}
|
|
buf.iter().fold(String::new(), |mut output, b| {
|
|
let _ = write!(output, "{b:02x}");
|
|
output
|
|
})
|
|
}
|
|
|
|
pub fn salt_file_path() -> PathBuf {
|
|
dirs::data_local_dir()
|
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
|
.join("rtk")
|
|
.join(".device_salt")
|
|
}
|
|
|
|
fn get_stats(tracker: &tracking::Tracker) -> (i64, Vec<String>, Option<f64>, i64, i64) {
|
|
let since_24h = chrono::Utc::now() - chrono::Duration::hours(24);
|
|
|
|
let commands_24h = tracker.count_commands_since(since_24h).unwrap_or(0);
|
|
let top_commands = tracker.top_commands(5).unwrap_or_default();
|
|
let savings_pct = tracker.overall_savings_pct().ok();
|
|
let tokens_saved_24h = tracker.tokens_saved_24h(since_24h).unwrap_or(0);
|
|
let tokens_saved_total = tracker.total_tokens_saved().unwrap_or(0);
|
|
|
|
(
|
|
commands_24h,
|
|
top_commands,
|
|
savings_pct,
|
|
tokens_saved_24h,
|
|
tokens_saved_total,
|
|
)
|
|
}
|
|
|
|
struct EnrichedStats {
|
|
// Quality: identify gaps and weak filters
|
|
passthrough_top: Vec<String>,
|
|
parse_failures_24h: i64,
|
|
low_savings_commands: Vec<String>,
|
|
avg_savings_per_command: f64,
|
|
// Adoption: which tools and configs
|
|
hook_type: String,
|
|
custom_toml_filters: usize,
|
|
// Retention: engagement signals
|
|
first_seen_days: i64,
|
|
active_days_30d: i64,
|
|
commands_total: i64,
|
|
// Ecosystem: where to invest filters
|
|
ecosystem_mix: serde_json::Value,
|
|
// Economics: value delivered
|
|
tokens_saved_30d: i64,
|
|
estimated_savings_usd_30d: f64,
|
|
// Configuration: user maturity
|
|
has_config_toml: bool,
|
|
exclude_commands_count: usize,
|
|
projects_count: i64,
|
|
// Meta-commands: feature adoption
|
|
meta_usage: serde_json::Value,
|
|
}
|
|
|
|
fn get_enriched_stats(tracker: &tracking::Tracker) -> EnrichedStats {
|
|
let since_24h = chrono::Utc::now() - chrono::Duration::hours(24);
|
|
|
|
let passthrough_top = tracker
|
|
.top_passthrough(5)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|(cmd, count)| format!("{}:{}", cmd, count))
|
|
.collect();
|
|
|
|
let parse_failures_24h = tracker.parse_failures_since(since_24h).unwrap_or(0);
|
|
|
|
let low_savings_commands = tracker
|
|
.low_savings_commands(5)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|(cmd, pct)| format!("{}:{:.0}%", cmd, pct))
|
|
.collect();
|
|
|
|
let avg_savings_per_command = tracker.avg_savings_per_command().unwrap_or(0.0);
|
|
|
|
let first_seen_days = tracker.first_seen_days().unwrap_or(0);
|
|
let active_days_30d = tracker.active_days_30d().unwrap_or(0);
|
|
let commands_total = tracker.commands_total().unwrap_or(0);
|
|
|
|
let ecosystem_mix = serde_json::Value::Object(
|
|
tracker
|
|
.ecosystem_mix()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|(k, v)| (k, serde_json::json!(v)))
|
|
.collect(),
|
|
);
|
|
|
|
let tokens_saved_30d = tracker.tokens_saved_30d().unwrap_or(0);
|
|
// Estimate USD savings: tokens_saved are input tokens (CLI output compressed before
|
|
// reaching the LLM). Use input pricing: Claude Sonnet $3/Mtok.
|
|
let estimated_savings_usd_30d = tokens_saved_30d as f64 / 1_000_000.0 * 3.0;
|
|
|
|
let projects_count = tracker.projects_count().unwrap_or(0);
|
|
|
|
let meta_usage = build_meta_usage(tracker);
|
|
|
|
EnrichedStats {
|
|
passthrough_top,
|
|
parse_failures_24h,
|
|
low_savings_commands,
|
|
avg_savings_per_command,
|
|
hook_type: detect_hook_type(),
|
|
custom_toml_filters: count_custom_toml_filters(),
|
|
first_seen_days,
|
|
active_days_30d,
|
|
commands_total,
|
|
ecosystem_mix,
|
|
tokens_saved_30d,
|
|
estimated_savings_usd_30d,
|
|
projects_count,
|
|
has_config_toml: detect_has_config(),
|
|
exclude_commands_count: count_exclude_commands(),
|
|
meta_usage,
|
|
}
|
|
}
|
|
|
|
/// Build meta-command usage counts (gain, discover, proxy, verify, learn, init).
|
|
fn build_meta_usage(tracker: &tracking::Tracker) -> serde_json::Value {
|
|
let meta_cmds = ["gain", "discover", "proxy", "verify", "learn", "init"];
|
|
let mut usage = serde_json::Map::new();
|
|
for meta in &meta_cmds {
|
|
let count = tracker.count_meta_command(meta).unwrap_or(0);
|
|
if count > 0 {
|
|
usage.insert(meta.to_string(), serde_json::json!(count));
|
|
}
|
|
}
|
|
serde_json::Value::Object(usage)
|
|
}
|
|
|
|
/// Check if user has a config.toml file.
|
|
fn detect_has_config() -> bool {
|
|
dirs::config_dir()
|
|
.map(|d| d.join("rtk/config.toml").exists())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Count commands in exclude_commands config.
|
|
fn count_exclude_commands() -> usize {
|
|
crate::core::config::Config::load()
|
|
.map(|c| c.hooks.exclude_commands.len())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Detect which AI agent hook is installed.
|
|
fn detect_hook_type() -> String {
|
|
let home = match dirs::home_dir() {
|
|
Some(h) => h,
|
|
None => return "unknown".to_string(),
|
|
};
|
|
|
|
let claude_dir = resolve_claude_dir().unwrap_or_else(|_| home.join(CLAUDE_DIR));
|
|
|
|
// Check in order of popularity
|
|
let checks = [
|
|
(claude_dir.join("hooks/rtk-rewrite.sh"), "claude"),
|
|
(claude_dir.join("hooks/rtk-rewrite.json"), "claude"),
|
|
(home.join(".gemini/hooks/rtk-hook.sh"), "gemini"),
|
|
(home.join(".codex/AGENTS.md"), "codex"),
|
|
(home.join(".cursor/hooks/rtk-rewrite.json"), "cursor"),
|
|
];
|
|
|
|
for (path, name) in &checks {
|
|
if path.exists() {
|
|
return name.to_string();
|
|
}
|
|
}
|
|
|
|
// Check project-level hooks (Claude script + project-scoped Copilot config)
|
|
if let Ok(cwd) = std::env::current_dir() {
|
|
if cwd.join(".claude/hooks/rtk-rewrite.sh").exists() {
|
|
return "claude".to_string();
|
|
}
|
|
if cwd.join(".github/hooks/rtk-rewrite.json").exists() {
|
|
return "copilot".to_string();
|
|
}
|
|
}
|
|
|
|
"none".to_string()
|
|
}
|
|
|
|
/// Count user-defined TOML filter files (project-local + global).
|
|
fn count_custom_toml_filters() -> usize {
|
|
let mut count = 0;
|
|
|
|
// Project-local: .rtk/filters/*.toml
|
|
if let Ok(cwd) = std::env::current_dir() {
|
|
if let Ok(entries) = std::fs::read_dir(cwd.join(".rtk/filters")) {
|
|
count += entries
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
|
|
.count();
|
|
}
|
|
}
|
|
|
|
// Global: ~/.config/rtk/filters/*.toml
|
|
if let Some(config_dir) = dirs::config_dir() {
|
|
if let Ok(entries) = std::fs::read_dir(config_dir.join("rtk/filters")) {
|
|
count += entries
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
|
|
.count();
|
|
}
|
|
}
|
|
|
|
count
|
|
}
|
|
|
|
fn detect_install_method() -> &'static str {
|
|
let exe = match std::env::current_exe() {
|
|
Ok(p) => p,
|
|
Err(_) => return "unknown",
|
|
};
|
|
let real_path = std::fs::canonicalize(&exe)
|
|
.unwrap_or(exe)
|
|
.to_string_lossy()
|
|
.to_string();
|
|
install_method_from_path(&real_path)
|
|
}
|
|
|
|
fn install_method_from_path(path: &str) -> &'static str {
|
|
if path.contains("/Cellar/rtk/") || path.contains("/homebrew/") {
|
|
"homebrew"
|
|
} else if path.contains("/.cargo/bin/") || path.contains("\\.cargo\\bin\\") {
|
|
"cargo"
|
|
} else if path.contains("/.local/bin/") || path.contains("\\.local\\bin\\") {
|
|
"script"
|
|
} else if path.contains("/nix/store/") {
|
|
"nix"
|
|
} else {
|
|
"other"
|
|
}
|
|
}
|
|
|
|
pub fn telemetry_marker_path() -> PathBuf {
|
|
let data_dir = dirs::data_local_dir()
|
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
|
.join(RTK_DATA_DIR);
|
|
let _ = std::fs::create_dir_all(&data_dir);
|
|
data_dir.join(".telemetry_last_ping")
|
|
}
|
|
|
|
fn touch_marker(path: &PathBuf) {
|
|
let _ = std::fs::write(path, b"");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_device_hash_is_stable() {
|
|
let h1 = generate_device_hash();
|
|
let h2 = generate_device_hash();
|
|
assert_eq!(h1, h2);
|
|
assert_eq!(h1.len(), 64);
|
|
}
|
|
|
|
#[test]
|
|
fn test_device_hash_is_valid_hex() {
|
|
let hash = generate_device_hash();
|
|
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_salt_is_persisted() {
|
|
let s1 = get_or_create_salt();
|
|
let s2 = get_or_create_salt();
|
|
assert_eq!(s1, s2);
|
|
assert_eq!(s1.len(), 64);
|
|
assert!(s1.chars().all(|c| c.is_ascii_hexdigit()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_random_salt_uniqueness() {
|
|
let s1 = random_salt();
|
|
let s2 = random_salt();
|
|
assert_ne!(s1, s2);
|
|
assert_eq!(s1.len(), 64);
|
|
assert_eq!(s2.len(), 64);
|
|
}
|
|
|
|
#[test]
|
|
fn test_salt_file_path_is_in_rtk_dir() {
|
|
let path = salt_file_path();
|
|
assert!(path.to_string_lossy().contains("rtk"));
|
|
assert!(path.to_string_lossy().contains(".device_salt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_marker_path_exists() {
|
|
let path = telemetry_marker_path();
|
|
assert!(path.to_string_lossy().contains("rtk"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_install_method_unix_paths() {
|
|
assert_eq!(
|
|
install_method_from_path("/opt/homebrew/Cellar/rtk/0.28.0/bin/rtk"),
|
|
"homebrew"
|
|
);
|
|
assert_eq!(
|
|
install_method_from_path("/usr/local/homebrew/bin/rtk"),
|
|
"homebrew"
|
|
);
|
|
assert_eq!(
|
|
install_method_from_path("/home/user/.cargo/bin/rtk"),
|
|
"cargo"
|
|
);
|
|
assert_eq!(
|
|
install_method_from_path("/home/user/.local/bin/rtk"),
|
|
"script"
|
|
);
|
|
assert_eq!(
|
|
install_method_from_path("/nix/store/abc123-rtk/bin/rtk"),
|
|
"nix"
|
|
);
|
|
assert_eq!(install_method_from_path("/usr/bin/rtk"), "other");
|
|
}
|
|
|
|
#[test]
|
|
fn test_install_method_windows_paths() {
|
|
assert_eq!(
|
|
install_method_from_path("C:\\Users\\user\\.cargo\\bin\\rtk.exe"),
|
|
"cargo"
|
|
);
|
|
assert_eq!(
|
|
install_method_from_path("C:\\Users\\user\\.local\\bin\\rtk.exe"),
|
|
"script"
|
|
);
|
|
assert_eq!(
|
|
install_method_from_path("C:\\Program Files\\rtk\\rtk.exe"),
|
|
"other"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_install_method_returns_known_value() {
|
|
let method = detect_install_method();
|
|
assert!(
|
|
["homebrew", "cargo", "script", "nix", "other", "unknown"].contains(&method),
|
|
"Unexpected install method: {}",
|
|
method
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_stats_returns_tuple() {
|
|
let tracker = match tracking::Tracker::new() {
|
|
Ok(t) => t,
|
|
Err(_) => return, // No DB — skip
|
|
};
|
|
let (cmds, top, pct, saved_24h, saved_total) = get_stats(&tracker);
|
|
assert!(cmds >= 0);
|
|
assert!(top.len() <= 5);
|
|
assert!(saved_24h >= 0);
|
|
assert!(saved_total >= 0);
|
|
if let Some(p) = pct {
|
|
assert!((0.0..=100.0).contains(&p));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_enriched_stats_returns_valid_data() {
|
|
let tracker = match tracking::Tracker::new() {
|
|
Ok(t) => t,
|
|
Err(_) => return,
|
|
};
|
|
let stats = get_enriched_stats(&tracker);
|
|
assert!(stats.passthrough_top.len() <= 5);
|
|
assert!(stats.parse_failures_24h >= 0);
|
|
assert!(stats.low_savings_commands.len() <= 5);
|
|
assert!((0.0..=100.0).contains(&stats.avg_savings_per_command));
|
|
assert!(
|
|
["claude", "gemini", "codex", "cursor", "copilot", "none", "unknown"]
|
|
.iter()
|
|
.any(|&h| stats.hook_type.starts_with(h)),
|
|
"Unexpected hook type: {}",
|
|
stats.hook_type
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_hook_type_returns_known() {
|
|
let ht = detect_hook_type();
|
|
assert!(
|
|
["claude", "gemini", "codex", "cursor", "copilot", "none", "unknown"]
|
|
.contains(&ht.as_str()),
|
|
"Unexpected hook type: {}",
|
|
ht
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_count_custom_toml_filters() {
|
|
// Should not panic even if directories don't exist
|
|
let count = count_custom_toml_filters();
|
|
assert!(count < 10000); // sanity check
|
|
}
|
|
}
|