use std::fs;
use std::io::Write;
use std::path::Path;
use std::time::Duration;
const POSTHOG_TOKEN: &str = "phc_3DIgL4ES4ukoFmH4hgg3jR0e6O52PiQIfzfsVEjJu9u";
const POSTHOG_BATCH_URL: &str = "https://app.posthog.com/batch/";
pub fn is_enabled() -> bool {
if std::env::var("DO_NOT_TRACK").ok().as_deref() == Some("1") {
return false;
}
crate::config::is_analytics_enabled()
}
pub struct Event {
pub command: String,
pub flags: Vec<String>,
pub success: bool,
pub duration_ms: u64,
}
pub fn track(event: &Event) {
if !is_enabled() {
return;
}
let Some(dir) = crate::config::config_dir() else {
return;
};
if track_to_dir(&dir, event) {
eprintln!("lin: anonymous usage stats enabled. Disable: lin config analytics off");
}
}
fn track_to_dir(dir: &Path, event: &Event) -> bool {
let Some((install_id, first_run)) = get_or_create_install_id_in(dir) else {
return false;
};
let payload = serde_json::json!({
"event": "command_executed",
"distinct_id": install_id,
"properties": {
"command": event.command,
"flags": event.flags,
"success": event.success,
"duration_ms": event.duration_ms,
"version": env!("CARGO_PKG_VERSION"),
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
}
});
let queue_path = dir.join("analytics_queue.jsonl");
let Ok(line) = serde_json::to_string(&payload) else {
return false;
};
let Ok(mut file) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&queue_path)
else {
return false;
};
let _ = writeln!(file, "{line}");
first_run
}
pub async fn flush() {
let Some(dir) = crate::config::config_dir() else {
return;
};
flush_dir(&dir, POSTHOG_BATCH_URL).await;
}
async fn flush_dir(dir: &Path, url: &str) {
let queue_path = dir.join("analytics_queue.jsonl");
let contents = match fs::read_to_string(&queue_path) {
Ok(c) if !c.trim().is_empty() => c,
_ => return,
};
let events: Vec<serde_json::Value> = contents
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
if events.is_empty() {
return;
}
let batch_payload = serde_json::json!({
"api_key": POSTHOG_TOKEN,
"batch": events,
});
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
{
Ok(c) => c,
Err(_) => return,
};
let response = client.post(url).json(&batch_payload).send().await;
if let Ok(resp) = response
&& resp.status().is_success()
{
let _ = fs::write(&queue_path, "");
}
}
fn get_or_create_install_id_in(dir: &Path) -> Option<(String, bool)> {
let path = dir.join("analytics_id");
if let Ok(id) = fs::read_to_string(&path) {
let id = id.trim().to_string();
if !id.is_empty() {
return Some((id, false));
}
}
let id = uuid::Uuid::new_v4().to_string();
fs::create_dir_all(dir).ok()?;
fs::write(&path, &id).ok()?;
Some((id, true))
}
pub fn command_name(cmd: &crate::cli::Commands) -> &'static str {
use crate::cli::Commands;
match cmd {
Commands::Api(_) => "api",
Commands::Auth(_) => "auth",
Commands::Issues(_) => "issues",
Commands::Projects(_) => "projects",
Commands::Cycles(_) => "cycles",
Commands::Initiatives(_) => "initiatives",
Commands::Roadmap(_) => "roadmap",
Commands::Labels(_) => "labels",
Commands::Teams(_) => "teams",
Commands::Relations(_) => "relations",
Commands::Customers(_) => "customers",
Commands::Views(_) => "views",
Commands::Docs(_) => "docs",
Commands::Notifications(_) => "notifications",
Commands::Me(_) => "me",
Commands::Attachments(_) => "attachments",
Commands::Search(_) => "search",
Commands::Config(_) => "config",
Commands::Completions { .. } => "completions",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_enabled_default() {
temp_env::with_var_unset("DO_NOT_TRACK", || {
assert!(is_enabled());
});
}
#[test]
fn test_is_enabled_do_not_track() {
temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
assert!(!is_enabled());
});
}
#[test]
fn test_is_enabled_do_not_track_other_values() {
temp_env::with_var("DO_NOT_TRACK", Some("0"), || {
assert!(is_enabled());
});
temp_env::with_var("DO_NOT_TRACK", Some("true"), || {
assert!(is_enabled());
});
}
#[test]
fn test_install_id_created_on_first_run() {
let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
let _ = fs::create_dir_all(&dir);
let (id, first_run) = get_or_create_install_id_in(&dir).unwrap();
assert!(first_run);
assert_eq!(id.len(), 36);
let stored = fs::read_to_string(dir.join("analytics_id")).unwrap();
assert_eq!(stored, id);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_install_id_loaded_on_second_run() {
let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
let _ = fs::create_dir_all(&dir);
let (id1, first) = get_or_create_install_id_in(&dir).unwrap();
assert!(first);
let (id2, second) = get_or_create_install_id_in(&dir).unwrap();
assert!(!second);
assert_eq!(id1, id2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_track_writes_to_queue() {
let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
let _ = fs::create_dir_all(&dir);
let event = Event {
command: "issues list".to_string(),
flags: vec!["--json".to_string()],
success: true,
duration_ms: 150,
};
let first_run = track_to_dir(&dir, &event);
assert!(first_run);
let queue = fs::read_to_string(dir.join("analytics_queue.jsonl")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(queue.trim()).unwrap();
assert_eq!(parsed["event"], "command_executed");
assert_eq!(parsed["properties"]["command"], "issues list");
assert_eq!(parsed["properties"]["flags"][0], "--json");
assert_eq!(parsed["properties"]["success"], true);
assert_eq!(parsed["properties"]["duration_ms"], 150);
assert!(parsed["properties"]["version"].is_string());
assert!(parsed["properties"]["os"].is_string());
assert!(parsed["properties"]["arch"].is_string());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_track_appends_multiple_events() {
let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
let _ = fs::create_dir_all(&dir);
for i in 0..3 {
let event = Event {
command: format!("command {i}"),
flags: vec![],
success: true,
duration_ms: i * 100,
};
track_to_dir(&dir, &event);
}
let queue = fs::read_to_string(dir.join("analytics_queue.jsonl")).unwrap();
let lines: Vec<&str> = queue.trim().lines().collect();
assert_eq!(lines.len(), 3);
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
assert_eq!(first["distinct_id"], third["distinct_id"]);
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_flush_sends_and_clears_queue() {
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
let server = MockServer::start().await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/batch/"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
let _ = fs::create_dir_all(&dir);
let event = Event {
command: "issues list".to_string(),
flags: vec![],
success: true,
duration_ms: 100,
};
track_to_dir(&dir, &event);
track_to_dir(&dir, &event);
let queue_path = dir.join("analytics_queue.jsonl");
assert!(fs::read_to_string(&queue_path).unwrap().lines().count() == 2);
let url = format!("{}/batch/", server.uri());
flush_dir(&dir, &url).await;
let after = fs::read_to_string(&queue_path).unwrap();
assert!(after.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_flush_keeps_queue_on_failure() {
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
let server = MockServer::start().await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/batch/"))
.respond_with(ResponseTemplate::new(500))
.expect(1)
.mount(&server)
.await;
let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
let _ = fs::create_dir_all(&dir);
let event = Event {
command: "teams list".to_string(),
flags: vec![],
success: true,
duration_ms: 50,
};
track_to_dir(&dir, &event);
let queue_path = dir.join("analytics_queue.jsonl");
let before = fs::read_to_string(&queue_path).unwrap();
let url = format!("{}/batch/", server.uri());
flush_dir(&dir, &url).await;
let after = fs::read_to_string(&queue_path).unwrap();
assert_eq!(before, after);
let _ = fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_flush_noop_on_empty_queue() {
let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
let _ = fs::create_dir_all(&dir);
flush_dir(&dir, "http://localhost:1/batch/").await;
assert!(!dir.join("analytics_queue.jsonl").exists());
let _ = fs::remove_dir_all(&dir);
}
}