2026-03-14 05:45:52 +00:00
|
|
|
//! 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<serde_json::Value> {
|
|
|
|
|
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<serde_json::Value> {
|
|
|
|
|
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<serde_json::Value> {
|
|
|
|
|
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<serde_json::Value> {
|
|
|
|
|
// 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::<u64>().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(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
feat(TASK-12): beta telemetry — report endpoint + settings toggle
Backend: telemetry.report RPC builds anonymous health report with node ID
(SHA-256 hash of pubkey, truncated), version, uptime, container states,
CPU/RAM, federation peers, and recent alerts. Saves latest report to disk.
Requires analytics opt-in (existing analytics.enable/disable flow).
Frontend: "Beta Telemetry" section in Settings with enable/disable toggle.
Shows what data is and isn't collected. Mock backend handles all analytics
and telemetry RPCs.
Privacy: No wallet data, no private keys, no DIDs, no IP addresses.
Node identified by truncated hash only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:14:47 +00:00
|
|
|
|
|
|
|
|
/// 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<serde_json::Value> {
|
|
|
|
|
// 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<serde_json::Value> = 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::<u64>().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::<f64>().ok())
|
|
|
|
|
.map(|f| f as u64)
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
// Recent alerts from metrics store
|
|
|
|
|
let recent_alerts: Vec<serde_json::Value> = 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)
|
|
|
|
|
}
|
2026-03-14 05:45:52 +00:00
|
|
|
}
|