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>
This commit is contained in:
Dorian
2026-03-18 23:14:47 +00:00
parent d6695e7741
commit 002605f193
4 changed files with 230 additions and 38 deletions

View File

@@ -117,4 +117,90 @@ impl RpcHandler {
"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<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)
}
}

View File

@@ -703,6 +703,7 @@ impl RpcHandler {
"analytics.enable" => self.handle_analytics_enable().await,
"analytics.disable" => self.handle_analytics_disable().await,
"analytics.get-snapshot" => self.handle_analytics_get_snapshot().await,
"telemetry.report" => self.handle_telemetry_report().await,
// Real-time metrics monitoring
"monitoring.current" => self.handle_monitoring_current().await,