use anyhow::{Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::thread::JoinHandle;
use std::time::Duration;
use uuid::Uuid;
static PENDING_EVENTS: OnceLock<Mutex<Vec<JoinHandle<()>>>> = OnceLock::new();
fn get_pending_events() -> &'static Mutex<Vec<JoinHandle<()>>> {
PENDING_EVENTS.get_or_init(|| Mutex::new(Vec::new()))
}
fn spawn_send(event: TelemetryEvent) {
let handle = std::thread::spawn(move || {
let _ = send_event(&event);
});
if let Ok(mut pending) = get_pending_events().lock() {
pending.push(handle);
}
}
const POSTHOG_API_KEY: &str = "phc_PUDsD0lYcsjoXfMRYhH9k91xDbuxzAyw6ZD0tg3OUHz";
const POSTHOG_ENDPOINT: &str = "https://eu.i.posthog.com/capture/";
pub const TELEMETRY_DOCS_URL: &str = "https://usehyperstack.com/telemetry";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelemetryConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_id: Option<String>,
#[serde(default)]
pub consent_shown: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub consent_shown_at: Option<String>,
}
fn default_enabled() -> bool {
true
}
impl Default for TelemetryConfig {
fn default() -> Self {
Self {
enabled: true,
anonymous_id: None,
consent_shown: false,
consent_shown_at: None,
}
}
}
impl TelemetryConfig {
pub fn load() -> Self {
Self::load_from_path(&config_path()).unwrap_or_default()
}
fn load_from_path(path: &PathBuf) -> Result<Self> {
let contents = fs::read_to_string(path)?;
let config: TelemetryConfig = serde_json::from_str(&contents)?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
}
let contents = serde_json::to_string_pretty(self)?;
fs::write(&path, contents)
.with_context(|| format!("Failed to write telemetry config: {:?}", path))?;
Ok(())
}
pub fn get_or_create_anonymous_id(&mut self) -> String {
if let Some(ref id) = self.anonymous_id {
return id.clone();
}
let id = Uuid::new_v4().to_string();
self.anonymous_id = Some(id.clone());
let _ = self.save(); id
}
}
fn config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".hyperstack")
.join("telemetry.json")
}
pub fn should_collect() -> bool {
if std::env::var("DO_NOT_TRACK")
.map(|v| v == "1" || v.to_lowercase() == "true")
.unwrap_or(false)
{
return false;
}
if std::env::var("HYPERSTACK_TELEMETRY_DISABLED")
.map(|v| v == "1" || v.to_lowercase() == "true")
.unwrap_or(false)
{
return false;
}
if std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok() {
return false;
}
let config = TelemetryConfig::load();
config.enabled
}
pub fn enable() -> Result<()> {
let mut config = TelemetryConfig::load();
config.enabled = true;
config.save()
}
pub fn disable() -> Result<()> {
let mut config = TelemetryConfig::load();
config.enabled = false;
config.save()
}
pub fn status() -> (bool, Option<String>) {
let config = TelemetryConfig::load();
let effective_enabled = should_collect();
(effective_enabled, config.anonymous_id)
}
pub fn flush() {
if let Ok(mut pending) = get_pending_events().lock() {
for handle in pending.drain(..) {
let _ = handle.join();
}
}
}
pub fn show_consent_banner_if_needed() {
let mut config = TelemetryConfig::load();
if config.consent_shown {
return;
}
let banner = r#"
┌─────────────────────────────────────────────────────────────┐
│ │
│ Hyperstack collects anonymous usage data to improve the │
│ CLI. No personal information or project details are sent. │
│ │
│ Disable anytime: hs telemetry disable │
│ Learn more: https://usehyperstack.com/telemetry │
│ │
└─────────────────────────────────────────────────────────────┘
"#;
let _ = writeln!(std::io::stderr(), "{}", banner);
config.consent_shown = true;
config.consent_shown_at = Some(chrono::Utc::now().to_rfc3339());
let _ = config.save();
record_first_run();
}
#[derive(Debug, Clone, Serialize)]
pub struct TelemetryEvent {
pub event: String,
pub command: String,
pub cli_version: String,
pub os: String,
pub arch: String,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
pub duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
pub anonymous_id: String,
pub session_id: String,
pub timestamp: String,
}
fn get_session_id() -> &'static str {
static SESSION_ID: OnceLock<String> = OnceLock::new();
SESSION_ID.get_or_init(|| Uuid::new_v4().to_string())
}
pub fn record_command(
command: &str,
success: bool,
error_code: Option<&str>,
duration: Duration,
extra: Option<HashMap<String, String>>,
) {
if !should_collect() {
return;
}
let mut config = TelemetryConfig::load();
let anonymous_id = config.get_or_create_anonymous_id();
let event = TelemetryEvent {
event: "command_executed".to_string(),
command: command.to_string(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
success,
error_code: error_code.map(|s| s.to_string()),
duration_ms: duration.as_millis() as u64,
template: extra.as_ref().and_then(|e| e.get("template").cloned()),
anonymous_id,
session_id: get_session_id().to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
};
spawn_send(event);
}
pub fn record_first_run() {
if !should_collect() {
return;
}
let mut config = TelemetryConfig::load();
let anonymous_id = config.get_or_create_anonymous_id();
let event = TelemetryEvent {
event: "first_run".to_string(),
command: String::new(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
success: true,
error_code: None,
duration_ms: 0,
template: None,
anonymous_id,
session_id: get_session_id().to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
};
spawn_send(event);
}
pub fn record_template_selected(template: &str) {
if !should_collect() {
return;
}
let mut config = TelemetryConfig::load();
let anonymous_id = config.get_or_create_anonymous_id();
let event = TelemetryEvent {
event: "create_template_selected".to_string(),
command: "create".to_string(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
success: true,
error_code: None,
duration_ms: 0,
template: Some(template.to_string()),
anonymous_id,
session_id: get_session_id().to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
};
spawn_send(event);
}
pub fn record_create_completed(template: &str, duration: Duration) {
if !should_collect() {
return;
}
let mut config = TelemetryConfig::load();
let anonymous_id = config.get_or_create_anonymous_id();
let event = TelemetryEvent {
event: "create_completed".to_string(),
command: "create".to_string(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
success: true,
error_code: None,
duration_ms: duration.as_millis() as u64,
template: Some(template.to_string()),
anonymous_id,
session_id: get_session_id().to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
};
spawn_send(event);
}
pub fn record_stack_deployed(stack_name: &str, duration: Duration) {
if !should_collect() {
return;
}
let mut config = TelemetryConfig::load();
let anonymous_id = config.get_or_create_anonymous_id();
let mut event = TelemetryEvent {
event: "stack_deployed".to_string(),
command: "up".to_string(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
success: true,
error_code: None,
duration_ms: duration.as_millis() as u64,
template: None,
anonymous_id,
session_id: get_session_id().to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
};
event.template = Some(format!(
"stack_count:{}",
if stack_name.is_empty() { "all" } else { "1" }
));
spawn_send(event);
}
pub fn record_sdk_generated(language: &str) {
if !should_collect() {
return;
}
let mut config = TelemetryConfig::load();
let anonymous_id = config.get_or_create_anonymous_id();
let event = TelemetryEvent {
event: "sdk_generated".to_string(),
command: "sdk".to_string(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
success: true,
error_code: None,
duration_ms: 0,
template: Some(language.to_string()),
anonymous_id,
session_id: get_session_id().to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
};
spawn_send(event);
}
pub fn record_stack_rollback(success: bool) {
if !should_collect() {
return;
}
let mut config = TelemetryConfig::load();
let anonymous_id = config.get_or_create_anonymous_id();
let event = TelemetryEvent {
event: "stack_rollback".to_string(),
command: "stack rollback".to_string(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
success,
error_code: None,
duration_ms: 0,
template: None,
anonymous_id,
session_id: get_session_id().to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
};
spawn_send(event);
}
#[derive(Serialize)]
struct PostHogCapture<'a> {
api_key: &'static str,
event: &'a str,
distinct_id: &'a str,
properties: &'a TelemetryEvent,
}
fn send_event(event: &TelemetryEvent) -> Result<()> {
let event = scrub_event(event);
let payload = PostHogCapture {
api_key: POSTHOG_API_KEY,
event: &event.event,
distinct_id: &event.anonymous_id,
properties: &event,
};
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5))
.build()?;
client
.post(POSTHOG_ENDPOINT)
.json(&payload)
.send()
.context("Failed to send telemetry")?;
Ok(())
}
fn scrub_event(event: &TelemetryEvent) -> TelemetryEvent {
let mut event = event.clone();
let patterns = [
r"/Users/[^/\s]+", r"/home/[^/\s]+", r"C:\\Users\\[^\\]+", r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", ];
for pattern in &patterns {
if let Ok(re) = Regex::new(pattern) {
event.command = re.replace_all(&event.command, "[REDACTED]").to_string();
if let Some(ref code) = event.error_code {
event.error_code = Some(re.replace_all(code, "[REDACTED]").to_string());
}
}
}
event
}
pub fn extract_error_code(error: &anyhow::Error) -> Option<String> {
let msg = error.to_string().to_lowercase();
if msg.contains("not authenticated") || msg.contains("auth") {
return Some("auth_required".to_string());
}
if msg.contains("network") || msg.contains("connection") || msg.contains("timeout") {
return Some("network_error".to_string());
}
if msg.contains("not found") {
return Some("not_found".to_string());
}
if msg.contains("permission") || msg.contains("denied") {
return Some("permission_denied".to_string());
}
if msg.contains("config") || msg.contains("parse") || msg.contains("invalid") {
return Some("config_error".to_string());
}
if msg.contains("api error") {
return Some("api_error".to_string());
}
Some("unknown_error".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = TelemetryConfig::default();
assert!(config.enabled);
assert!(config.anonymous_id.is_none());
assert!(!config.consent_shown);
}
#[test]
fn test_scrub_event_removes_paths() {
let event = TelemetryEvent {
event: "test".to_string(),
command: "Error at /Users/john/project".to_string(),
cli_version: "0.1.0".to_string(),
os: "darwin".to_string(),
arch: "arm64".to_string(),
success: false,
error_code: Some("Error in /home/jane/code".to_string()),
duration_ms: 100,
template: None,
anonymous_id: "test-id".to_string(),
session_id: "session-id".to_string(),
timestamp: "2024-01-01T00:00:00Z".to_string(),
};
let scrubbed = scrub_event(&event);
assert!(!scrubbed.command.contains("john"));
assert!(!scrubbed.error_code.as_ref().unwrap().contains("jane"));
assert!(scrubbed.command.contains("[REDACTED]"));
}
#[test]
fn test_error_code_extraction() {
let auth_error = anyhow::anyhow!("Not authenticated. Run 'hs auth login' first.");
assert_eq!(
extract_error_code(&auth_error),
Some("auth_required".to_string())
);
let network_error = anyhow::anyhow!("Connection timeout");
assert_eq!(
extract_error_code(&network_error),
Some("network_error".to_string())
);
}
}