Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ tempfile = "3"
sha2 = "0.10"
ureq = "2"
hostname = "0.4"
getrandom = "0.4"
flate2 = "1.0"
quick-xml = "0.37"
which = "8"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,10 +472,10 @@ brew uninstall rtk # If installed via Homebrew

## Privacy & Telemetry

RTK collects **anonymous, aggregate usage metrics** once per day to help prioritize development. This is standard practice for open-source CLI tools.
RTK collects **anonymous, aggregate usage metrics** once per day, **enabled by default**. This helps prioritize development. See opt-out options below.

**What is collected:**
- Device hash (SHA-256 of hostname+username, not reversible)
- Device hash (salted SHA-256 — per-user random salt stored locally, not reversible)
- RTK version, OS, architecture
- Command count (last 24h) and top command names (e.g. "git", "cargo" — no arguments, no file paths)
- Token savings percentage
Expand Down
4 changes: 2 additions & 2 deletions docs/tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,8 +538,8 @@ let _ = conn.execute(

## Security & Privacy

- **Local storage only**: Database never leaves the machine
- **No telemetry**: RTK does not phone home or send analytics
- **Local storage only**: Tracking database never leaves the machine
- **Telemetry enabled by default**: RTK sends a daily anonymous usage ping (version, OS, command counts, token savings). Device identity is a salted SHA-256 hash. Opt out with `RTK_TELEMETRY_DISABLED=1` or `[telemetry] enabled = false` in `~/.config/rtk/config.toml`
- **User control**: Users can delete `~/.local/share/rtk/tracking.db` anytime
- **90-day retention**: Old data automatically purged

Expand Down
10 changes: 8 additions & 2 deletions src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;

use crate::config;
use crate::integrity;

// Embedded hook script (guards before set -euo pipefail)
Expand Down Expand Up @@ -279,9 +280,14 @@ pub fn run(
install_cursor_hooks(verbose)?;
}

// Telemetry notice (shown once during init)
println!();
println!(" [info] Anonymous telemetry is enabled (opt-out: RTK_TELEMETRY_DISABLED=1)");
let env_disabled = std::env::var("RTK_TELEMETRY_DISABLED").unwrap_or_default() == "1";
let config_disabled = matches!(config::telemetry_enabled(), Some(false));
if env_disabled || config_disabled {
println!(" [info] Anonymous telemetry is disabled");
} else {
println!(" [info] Anonymous telemetry is enabled by default (opt-out: RTK_TELEMETRY_DISABLED=1)");
}
println!(" [info] See: https://github.com/rtk-ai/rtk#privacy--telemetry");

Ok(())
Expand Down
90 changes: 89 additions & 1 deletion src/telemetry.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
use crate::config;
use crate::tracking;
use sha2::{Digest, Sha256};
use std::io::Write;
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");
Expand Down Expand Up @@ -85,6 +89,7 @@ fn send_ping() -> Result<(), Box<dyn std::error::Error>> {
}

fn generate_device_hash() -> String {
let salt = get_or_create_salt();
let hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_default();
Expand All @@ -93,12 +98,64 @@ fn generate_device_hash() -> String {
.unwrap_or_default();

let mut hasher = Sha256::new();
hasher.update(salt.as_bytes());
hasher.update(b":");
hasher.update(hostname.as_bytes());
hasher.update(b":");
hasher.update(username.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().map(|b| format!("{:02x}", b)).collect()
}

fn salt_file_path() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("rtk")
.join(".device_salt")
}

fn get_stats() -> (i64, Vec<String>, Option<f64>, i64, i64) {
let tracker = match tracking::Tracker::new() {
Ok(t) => t,
Expand Down Expand Up @@ -174,7 +231,38 @@ mod tests {
let h1 = generate_device_hash();
let h2 = generate_device_hash();
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64); // SHA-256 hex
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]
Expand Down
Loading