//! Opt-in anonymous node analytics. //! When enabled, collects aggregate stats (app install counts, uptime, hardware tier). //! No personally identifiable information. No IP addresses. No DIDs. //! Data stays local until explicitly shared via future relay mechanism. use super::RpcHandler; use anyhow::Result; use tracing::info; const ANALYTICS_FILE: &str = "analytics-config.json"; impl RpcHandler { /// Check if analytics are enabled. pub(super) async fn handle_analytics_get_status(&self) -> Result { let config_path = self.config.data_dir.join(ANALYTICS_FILE); let enabled = if config_path.exists() { let data = tokio::fs::read_to_string(&config_path).await?; let config: serde_json::Value = serde_json::from_str(&data).unwrap_or_default(); config["enabled"].as_bool().unwrap_or(false) } else { false }; Ok(serde_json::json!({ "enabled": enabled, "description": "Anonymous aggregate statistics. No personal data collected.", })) } /// Enable opt-in analytics. pub(super) async fn handle_analytics_enable(&self) -> Result { let config_path = self.config.data_dir.join(ANALYTICS_FILE); let config = serde_json::json!({ "enabled": true, "opted_in_at": chrono::Utc::now().to_rfc3339(), }); tokio::fs::write(&config_path, serde_json::to_string_pretty(&config)?).await?; info!("Analytics opted in"); Ok(serde_json::json!({ "enabled": true })) } /// Disable analytics. pub(super) async fn handle_analytics_disable(&self) -> Result { let config_path = self.config.data_dir.join(ANALYTICS_FILE); let config = serde_json::json!({ "enabled": false, "opted_out_at": chrono::Utc::now().to_rfc3339(), }); tokio::fs::write(&config_path, serde_json::to_string_pretty(&config)?).await?; info!("Analytics opted out"); Ok(serde_json::json!({ "enabled": false })) } /// Get an anonymous analytics snapshot of this node. /// Only returns aggregate data — no DIDs, no IPs, no secrets. pub(super) async fn handle_analytics_get_snapshot(&self) -> Result { // Check if opted in let config_path = self.config.data_dir.join(ANALYTICS_FILE); let enabled = if config_path.exists() { let data = tokio::fs::read_to_string(&config_path).await?; let config: serde_json::Value = serde_json::from_str(&data).unwrap_or_default(); config["enabled"].as_bool().unwrap_or(false) } else { false }; if !enabled { return Ok(serde_json::json!({ "error": "Analytics not enabled. Opt in via analytics.enable first.", "enabled": false, })); } // Collect anonymous aggregate data let (data, _) = self.state_manager.get_snapshot().await; let app_count = data.package_data.len(); let running_count = data.package_data.values() .filter(|p| matches!(p.state, crate::data_model::PackageState::Running)) .count(); // Hardware tier (anonymous) let cpu_cores = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(0); let mem_output = tokio::process::Command::new("grep") .args(["MemTotal", "/proc/meminfo"]) .output() .await; let total_ram_mb = mem_output.ok() .and_then(|o| { let s = String::from_utf8_lossy(&o.stdout); s.split_whitespace().nth(1)?.parse::().ok() }) .map(|kb| kb / 1024) .unwrap_or(0); let hardware_tier = match total_ram_mb { 0..=3999 => "minimal", 4000..=7999 => "standard", 8000..=15999 => "power", _ => "heavy", }; let version = &data.server_info.version; let federation_peers = data.peer_health.len(); Ok(serde_json::json!({ "version": version, "app_count": app_count, "running_count": running_count, "hardware_tier": hardware_tier, "cpu_cores": cpu_cores, "ram_mb": total_ram_mb, "federation_peers": federation_peers, "collected_at": chrono::Utc::now().to_rfc3339(), })) } /// Build a full telemetry report for the beta fleet monitoring. /// Includes health data, container states, errors, and uptime. /// No wallet data, no keys, no personal data — only system health. pub(super) async fn handle_telemetry_report(&self) -> Result { // Check opt-in let config_path = self.config.data_dir.join(ANALYTICS_FILE); let enabled = if config_path.exists() { let data = tokio::fs::read_to_string(&config_path).await?; let config: serde_json::Value = serde_json::from_str(&data).unwrap_or_default(); config["enabled"].as_bool().unwrap_or(false) } else { false }; if !enabled { anyhow::bail!("Telemetry not enabled. Opt in via analytics.enable first."); } let (data, _) = self.state_manager.get_snapshot().await; // Anonymous node ID — SHA-256 hash of the DID (not the DID itself) let node_id = { use sha2::{Sha256, Digest}; let mut hasher = Sha256::new(); hasher.update(data.server_info.pubkey.as_bytes()); hex::encode(hasher.finalize())[..16].to_string() }; // Container states let containers: Vec = data.package_data.iter().map(|(id, pkg)| { serde_json::json!({ "id": id, "state": format!("{:?}", pkg.state), "version": pkg.manifest.version, }) }).collect(); // System stats let cpu_cores = std::thread::available_parallelism() .map(|n| n.get()).unwrap_or(0); let mem_output = tokio::process::Command::new("grep") .args(["MemTotal", "/proc/meminfo"]) .output().await; let total_ram_mb = mem_output.ok() .and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().nth(1)?.parse::().ok()) .map(|kb| kb / 1024).unwrap_or(0); // Uptime let uptime_secs = tokio::fs::read_to_string("/proc/uptime").await .ok() .and_then(|s| s.split_whitespace().next()?.parse::().ok()) .map(|f| f as u64) .unwrap_or(0); // Recent alerts from metrics store let recent_alerts: Vec = self.metrics_store.get_alerts().await .into_iter() .take(10) .map(|a| serde_json::json!({ "rule": format!("{:?}", a.rule_type), "message": a.message, "fired_at": a.fired_at.to_rfc3339(), })) .collect(); let report = serde_json::json!({ "node_id": node_id, "version": data.server_info.version, "uptime_secs": uptime_secs, "cpu_cores": cpu_cores, "ram_mb": total_ram_mb, "containers": containers, "container_count": data.package_data.len(), "running_count": data.package_data.values() .filter(|p| matches!(p.state, crate::data_model::PackageState::Running)).count(), "federation_peers": data.peer_health.len(), "recent_alerts": recent_alerts, "reported_at": chrono::Utc::now().to_rfc3339(), }); // Save latest report to disk for debugging let report_path = self.config.data_dir.join("telemetry-latest.json"); let _ = tokio::fs::write(&report_path, serde_json::to_string_pretty(&report)?).await; Ok(report) } }