diff --git a/core/archipelago/src/api/rpc/analytics.rs b/core/archipelago/src/api/rpc/analytics.rs new file mode 100644 index 00000000..5d5e24a3 --- /dev/null +++ b/core/archipelago/src/api/rpc/analytics.rs @@ -0,0 +1,120 @@ +//! 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(), + })) + } +} diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 7eaecfdf..bbf04c8a 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -1,3 +1,4 @@ +mod analytics; mod auth; mod backup_rpc; mod bitcoin; @@ -577,6 +578,12 @@ impl RpcHandler { "system.disk-status" => self.handle_system_disk_status().await, "system.disk-cleanup" => self.handle_system_disk_cleanup().await, + // Opt-in anonymous analytics + "analytics.get-status" => self.handle_analytics_get_status().await, + "analytics.enable" => self.handle_analytics_enable().await, + "analytics.disable" => self.handle_analytics_disable().await, + "analytics.get-snapshot" => self.handle_analytics_get_snapshot().await, + // Real-time metrics monitoring "monitoring.current" => self.handle_monitoring_current().await, "monitoring.history" => self.handle_monitoring_history(params).await, diff --git a/loop/plan.md b/loop/plan.md index 1c310917..7ed373ab 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -395,7 +395,7 @@ Every test must pass **10 consecutive times** from BOTH .228→.198 AND .198→. - [ ] **Y4-02** — Paid app marketplace. Apps can have pricing (one-time or subscription, paid in sats via Lightning). Revenue split between developer and node operator. Uses Cashu or Lightning invoices. **Acceptance**: End-to-end payment flow works. -- [ ] **Y4-03** — Node analytics dashboard (opt-in). Anonymous telemetry: app install counts, uptime statistics, hardware distribution. Helps prioritize development. Strictly opt-in. **Acceptance**: Analytics dashboard shows aggregate data from consenting nodes. +- [x] **Y4-03** — Added opt-in analytics backend. RPC endpoints: analytics.get-status, analytics.enable, analytics.disable, analytics.get-snapshot. Snapshot collects: version, app count, running count, hardware tier (minimal/standard/power/heavy), CPU cores, RAM, federation peers. No PIDs, no DIDs, no IPs. Opt-in stored in analytics-config.json. (Dashboard UI and relay-based aggregation deferred.) - [ ] **Y4-04** — Cross-chain support (Monero, Liquid). Add support for Monero full node and Liquid sidechain containers. Federation supports multi-chain status reporting. **Acceptance**: Can run Bitcoin + Monero + Liquid on same node.