use crate::cmd::run_cmd;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
static TELEMETRY_CACHE: Mutex<Option<(Telemetry, std::time::Instant)>> = Mutex::new(None);
const CACHE_TTL_SECS: u64 = 30;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::exhaustive_structs)]
pub struct Telemetry {
pub timestamp: u64,
pub system: SystemInfo,
pub hardware: HardwareInfo,
pub network: NetworkInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::exhaustive_structs)]
pub struct SystemInfo {
pub cpu_model: String,
pub cpu_count: u32,
pub ram_total: String,
pub ram_free: String,
pub ram_available: String,
pub disk_total: String,
pub disk_free: String,
pub disk_used_percent: String,
pub uptime: String,
pub uptime_seconds: u64,
pub load_average: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::exhaustive_structs)]
pub struct HardwareInfo {
#[serde(default)]
pub accelerators: Vec<AcceleratorInfo>,
#[serde(default)]
pub jax_available: bool,
#[serde(default)]
pub jax_version: Option<String>,
#[serde(default)]
pub jax_device_count: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::exhaustive_structs)]
pub struct AcceleratorInfo {
pub kind: String,
pub count: usize,
#[serde(default)]
pub vendor: Option<String>,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::exhaustive_structs)]
pub struct NetworkInfo {
pub public_ip: String,
pub tunnel_running: bool,
pub tunnel_pid: Option<u32>,
#[serde(default)]
pub listening_ports: Vec<u16>,
}
fn read_proc_file(path: &str) -> std::io::Result<String> {
std::fs::read_to_string(path)
}
fn parse_meminfo_kb(data: &str, key: &str) -> u64 {
data.lines()
.find(|l| l.starts_with(key))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0)
}
fn format_mem_kb(kb: u64) -> String {
if kb >= 1_048_576 {
format!("{}Gi", kb / 1_048_576)
} else if kb >= 1_024 {
format!("{}Mi", kb / 1_024)
} else {
format!("{}Ki", kb)
}
}
fn format_uptime(total_seconds: u64) -> String {
let days = total_seconds / 86_400;
let hours = (total_seconds % 86_400) / 3_600;
let minutes = (total_seconds % 3_600) / 60;
let mut parts: Vec<String> = Vec::with_capacity(3);
if days > 0 {
parts.push(format!("{} day{}", days, if days == 1 { "" } else { "s" }));
}
if hours > 0 {
parts.push(format!(
"{} hour{}",
hours,
if hours == 1 { "" } else { "s" }
));
}
if minutes > 0 || parts.is_empty() {
parts.push(format!(
"{} minute{}",
minutes,
if minutes == 1 { "" } else { "s" }
));
}
format!("up {}", parts.join(", "))
}
impl Telemetry {
pub fn capture() -> Self {
let now = std::time::Instant::now();
{
let cache = TELEMETRY_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((cached, instant)) = cache.as_ref() {
if now.duration_since(*instant).as_secs() < CACHE_TTL_SECS {
return cached.clone();
}
}
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let telemetry = Self {
timestamp,
system: SystemInfo::capture(),
hardware: HardwareInfo::capture(),
network: NetworkInfo::capture(),
};
let mut cache = TELEMETRY_CACHE.lock().unwrap_or_else(|e| e.into_inner());
*cache = Some((telemetry.clone(), now));
telemetry
}
pub fn print_report(&self) {
println!("\n{}", "=".repeat(60));
println!(" RUNTIMO TELEMETRY [{}]", self.timestamp);
println!("{}", "=".repeat(60));
println!("\n--- SYSTEM ---");
println!(
" CPU : {} ({} cores)",
self.system.cpu_model, self.system.cpu_count
);
println!(
" RAM : {} total, {} free, {} available",
self.system.ram_total, self.system.ram_free, self.system.ram_available
);
println!(
" Disk : {} total, {} free ({}% used)",
self.system.disk_total, self.system.disk_free, self.system.disk_used_percent
);
println!(
" Uptime: {} ({}s)",
self.system.uptime, self.system.uptime_seconds
);
println!(
" Load : {} ({} cores)",
self.system.load_average, self.system.cpu_count
);
println!("\n--- HARDWARE ---");
if self.hardware.accelerators.is_empty() {
println!(" Accelerators: none detected");
} else {
for acc in &self.hardware.accelerators {
println!(
" {}: {}x {}{}",
acc.kind,
acc.count,
acc.model.as_deref().unwrap_or("unknown"),
acc.vendor
.as_ref()
.map(|v| format!(" ({})", v))
.unwrap_or_default()
);
}
}
if self.hardware.jax_available {
println!(
" JAX: v{} ({} devices)",
self.hardware
.jax_version
.clone()
.unwrap_or_else(|| "unknown".into()),
self.hardware.jax_device_count.unwrap_or(0)
);
}
println!("\n--- NETWORK ---");
println!(" Public IP: {}", self.network.public_ip);
if self.network.tunnel_running {
println!(
" Tunnel: cloudflared (PID {})",
self.network
.tunnel_pid
.map_or_else(|| "?".to_string(), |p| p.to_string())
);
} else {
println!(" Tunnel: none");
}
if self.network.listening_ports.is_empty() {
println!(" Listening ports: none");
} else {
let ports_str = self
.network
.listening_ports
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");
println!(" Listening ports: {}", ports_str);
}
println!("\n{}", "=".repeat(60));
}
}
impl SystemInfo {
fn capture() -> Self {
let cpuinfo = read_proc_file("/proc/cpuinfo").unwrap_or_default();
let cpu_model = cpuinfo
.lines()
.find(|l| l.starts_with("model name"))
.and_then(|l| l.split(':').nth(1))
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string());
let cpu_count: u32 = cpuinfo
.lines()
.filter(|l| l.starts_with("processor"))
.count()
.try_into()
.unwrap_or(0);
let meminfo = read_proc_file("/proc/meminfo").unwrap_or_default();
let ram_total = format_mem_kb(parse_meminfo_kb(&meminfo, "MemTotal:"));
let ram_free = format_mem_kb(parse_meminfo_kb(&meminfo, "MemFree:"));
let ram_available = format_mem_kb(parse_meminfo_kb(&meminfo, "MemAvailable:"));
let uptime = read_proc_file("/proc/uptime").unwrap_or_default();
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let uptime_seconds: u64 = uptime
.split_whitespace()
.next()
.and_then(|s| s.parse::<f64>().ok())
.map_or(0, |f: f64| f as u64);
let uptime_str = format_uptime(uptime_seconds);
let loadavg = read_proc_file("/proc/loadavg").unwrap_or_default();
let load_average = {
let mut fields = loadavg.split_whitespace();
match (fields.next(), fields.next(), fields.next()) {
(Some(one), Some(five), Some(fifteen)) => {
format!("{one}, {five}, {fifteen}")
}
_ => String::from("unknown"),
}
};
let disk_total = run_cmd("df -h / | tail -1 | awk '{print $2}'");
let disk_free = run_cmd("df -h / | tail -1 | awk '{print $4}'");
let disk_pct_str = run_cmd("df / | tail -1 | awk '{print $5}'");
let disk_used_percent = disk_pct_str.replace('%', "");
Self {
cpu_model,
cpu_count,
ram_total,
ram_free,
ram_available,
disk_total,
disk_free,
disk_used_percent,
uptime: uptime_str,
uptime_seconds,
load_average,
}
}
}
impl HardwareInfo {
fn capture() -> Self {
let mut accelerators = Vec::new();
let tpu_count: usize = run_cmd("ls /dev/accel* 2>/dev/null | wc -l")
.parse()
.unwrap_or(0);
if tpu_count > 0 {
accelerators.push(AcceleratorInfo {
kind: "tpu".into(),
count: tpu_count,
vendor: Some("google".into()),
model: None,
});
}
let nvidia_gpu_count: usize = run_cmd("nvidia-smi --list-gpus 2>/dev/null | wc -l")
.parse()
.unwrap_or(0);
if nvidia_gpu_count > 0 {
let model =
run_cmd("nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1");
accelerators.push(AcceleratorInfo {
kind: "gpu".into(),
count: nvidia_gpu_count,
vendor: Some("nvidia".into()),
model: if model.is_empty() { None } else { Some(model) },
});
}
let amd_gpu_count: usize =
run_cmd("rocm-smi --showproductname 2>/dev/null | grep -c 'GPU\\['")
.parse()
.unwrap_or(0);
if amd_gpu_count > 0 {
accelerators.push(AcceleratorInfo {
kind: "gpu".into(),
count: amd_gpu_count,
vendor: Some("amd".into()),
model: None,
});
}
if nvidia_gpu_count == 0 && amd_gpu_count == 0 {
let dri_count: usize = run_cmd("ls /dev/dri/render* 2>/dev/null | wc -l")
.parse()
.unwrap_or(0);
if dri_count > 0 {
accelerators.push(AcceleratorInfo {
kind: "gpu".into(),
count: dri_count,
vendor: None,
model: Some("drm-render".into()),
});
}
}
let jax_available =
run_cmd("timeout 10 python3 -c 'import jax' 2>/dev/null && echo yes || echo no")
== "yes";
let jax_version = if jax_available {
Some(run_cmd(
"timeout 10 python3 -c 'import jax; print(jax.__version__)'",
))
} else {
None
};
let jax_device_count = if jax_available {
run_cmd("timeout 10 python3 -c 'import jax; print(len(jax.devices()))'")
.parse()
.ok()
} else {
None
};
Self {
accelerators,
jax_available,
jax_version,
jax_device_count,
}
}
}
impl NetworkInfo {
fn capture() -> Self {
let public_ip = if std::env::var("RUNTIMO_ENABLE_PUBLIC_IP").as_deref() == Ok("1") {
run_cmd(
"curl -s --connect-timeout 5 --max-time 5 ifconfig.me 2>/dev/null || echo 'unknown'",
)
} else {
"unknown".to_string()
};
let (tunnel_running, tunnel_pid) = detect_cloudflared();
let listening_ports = read_listening_ports();
Self {
public_ip,
tunnel_running,
tunnel_pid,
listening_ports,
}
}
}
fn detect_cloudflared() -> (bool, Option<u32>) {
let Ok(dir) = std::fs::read_dir("/proc") else {
return (false, None);
};
for entry in dir.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.chars().all(|c| c.is_ascii_digit()) {
continue;
}
let comm_path = path.join("comm");
let Ok(content) = std::fs::read_to_string(&comm_path) else {
continue;
};
if content.trim() == "cloudflared" {
if let Ok(pid) = name.parse::<u32>() {
return (true, Some(pid));
}
}
}
(false, None)
}
fn read_listening_ports() -> Vec<u16> {
let mut ports = Vec::new();
for path in &["/proc/net/tcp", "/proc/net/tcp6"] {
let data = read_proc_file(path).unwrap_or_default();
for line in data.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 4 {
continue;
}
if parts.get(3) != Some(&"0A") {
continue;
}
if let Some(port_hex) = parts.get(1).and_then(|addr| addr.split(':').nth(1)) {
if let Ok(port) = u16::from_str_radix(port_hex, 16) {
ports.push(port);
}
}
}
}
ports.sort_unstable();
ports.dedup();
ports
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_telemetry_capture() {
let telemetry = Telemetry::capture();
assert!(telemetry.timestamp > 0, "timestamp must be positive");
let s = &telemetry.system;
assert!(!s.cpu_model.is_empty(), "cpu_model must not be empty");
assert!(s.cpu_count > 0, "cpu_count must be > 0");
assert!(!s.ram_total.is_empty(), "ram_total must not be empty");
assert!(!s.ram_free.is_empty(), "ram_free must not be empty");
assert!(
!s.ram_available.is_empty(),
"ram_available must not be empty"
);
assert!(!s.disk_total.is_empty(), "disk_total must not be empty");
assert!(s.uptime_seconds > 0, "uptime_seconds must be > 0");
assert!(!s.load_average.is_empty(), "load_average must not be empty");
let h = &telemetry.hardware;
assert!(
h.accelerators.iter().all(|a| !a.kind.is_empty()),
"accelerator kind must not be empty"
);
assert!(
h.accelerators.iter().all(|a| a.count > 0),
"accelerator count must be > 0"
);
let net = &telemetry.network;
assert!(!net.public_ip.is_empty(), "public_ip must not be empty");
assert_eq!(
net.public_ip, "unknown",
"public_ip should be 'unknown' by default (opt-in via RUNTIMO_ENABLE_PUBLIC_IP=1)"
);
assert!(
net.listening_ports.iter().all(|p| *p > 0),
"all listening ports must be > 0"
);
}
#[test]
fn test_telemetry_cache_works() {
let t1 = Telemetry::capture();
let t2 = Telemetry::capture();
assert_eq!(
t1.timestamp, t2.timestamp,
"cached telemetry should be identical"
);
}
#[test]
fn test_system_info_from_proc() {
let sys = SystemInfo::capture();
assert!(sys.cpu_count > 0, "cpu_count from /proc/cpuinfo");
assert!(
!sys.ram_available.is_empty(),
"ram_available from /proc/meminfo MemAvailable"
);
assert!(sys.uptime_seconds > 0, "uptime_seconds from /proc/uptime");
assert!(
sys.uptime.starts_with("up "),
"uptime string should start with 'up ': got '{}'",
sys.uptime
);
assert!(
!sys.cpu_model.is_empty(),
"cpu_model from /proc/cpuinfo model name"
);
}
#[test]
fn test_cloudflared_detection() {
let (running, pid) = detect_cloudflared();
if running {
assert!(pid.is_some(), "tunnel_running implies tunnel_pid");
let found_pid = pid.unwrap();
let comm_path = format!("/proc/{}/comm", found_pid);
if let Ok(content) = std::fs::read_to_string(&comm_path) {
assert_eq!(
content.trim(),
"cloudflared",
"PID {} comm should be 'cloudflared', got '{}'",
found_pid,
content.trim()
);
}
}
assert!(!running || pid.is_some());
}
#[test]
fn test_listening_ports() {
let ports = read_listening_ports();
let mut uniq = ports.clone();
uniq.dedup();
assert_eq!(
ports.len(),
uniq.len(),
"listening ports must have no duplicates"
);
for w in ports.windows(2) {
assert!(w[0] <= w[1], "listening ports must be sorted: {:?}", ports);
}
for &p in &ports {
assert!(p > 0, "port 0 is not a valid listening port");
}
}
#[test]
fn test_format_mem_kb() {
assert_eq!(format_mem_kb(512), "512Ki");
assert_eq!(format_mem_kb(1024), "1Mi");
assert_eq!(format_mem_kb(1536), "1Mi"); assert_eq!(format_mem_kb(1048576), "1Gi");
assert_eq!(format_mem_kb(2097152), "2Gi");
assert_eq!(format_mem_kb(768000), "750Mi"); assert_eq!(format_mem_kb(0), "0Ki");
}
#[test]
fn test_format_uptime() {
assert!(
format_uptime(0).contains("minute"),
"zero uptime: {}",
format_uptime(0)
);
assert!(
format_uptime(60).contains("1 minute"),
"60s: {}",
format_uptime(60)
);
assert!(
format_uptime(3600).contains("1 hour"),
"3600s: {}",
format_uptime(3600)
);
assert!(
format_uptime(86400).contains("1 day"),
"86400s: {}",
format_uptime(86400)
);
assert!(
format_uptime(12345).starts_with("up "),
"uptime should start with 'up '"
);
}
#[test]
fn test_parse_meminfo_kb() {
let sample = "MemTotal: 32768000 kB\nMemFree: 8000000 kB\nMemAvailable: 22000000 kB\n";
assert_eq!(parse_meminfo_kb(sample, "MemTotal:"), 32_768_000);
assert_eq!(parse_meminfo_kb(sample, "MemFree:"), 8_000_000);
assert_eq!(parse_meminfo_kb(sample, "MemAvailable:"), 22_000_000);
assert_eq!(parse_meminfo_kb(sample, "SwapTotal:"), 0);
assert_eq!(parse_meminfo_kb("", "MemTotal:"), 0);
}
#[test]
fn test_accelerators_back_compat() {
let hw = HardwareInfo {
accelerators: vec![
AcceleratorInfo {
kind: "gpu".into(),
count: 4,
vendor: Some("nvidia".into()),
model: Some("A100".into()),
},
AcceleratorInfo {
kind: "tpu".into(),
count: 8,
vendor: Some("google".into()),
model: None,
},
],
jax_available: false,
jax_version: None,
jax_device_count: None,
};
let total_tpu: usize = hw
.accelerators
.iter()
.filter(|a| a.kind == "tpu")
.map(|a| a.count)
.sum();
let total_gpu: usize = hw
.accelerators
.iter()
.filter(|a| a.kind == "gpu")
.map(|a| a.count)
.sum();
assert_eq!(total_tpu, 8, "total tpu should be 8");
assert_eq!(total_gpu, 4, "total gpu should be 4");
}
#[test]
fn test_accelerators_empty_is_valid() {
let hw = HardwareInfo {
accelerators: vec![],
jax_available: false,
jax_version: None,
jax_device_count: None,
};
assert!(hw.accelerators.is_empty());
}
#[test]
fn test_telemetry_serialization_roundtrip() {
let hw = HardwareInfo {
accelerators: vec![AcceleratorInfo {
kind: "gpu".into(),
count: 2,
vendor: Some("nvidia".into()),
model: Some("H100".into()),
}],
jax_available: true,
jax_version: Some("0.4.30".into()),
jax_device_count: Some(2),
};
let net = NetworkInfo {
public_ip: "192.0.2.1".into(),
tunnel_running: false,
tunnel_pid: None,
listening_ports: vec![22, 80, 443],
};
let json = serde_json::to_string(&hw).unwrap();
let parsed: HardwareInfo = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.accelerators.len(), 1);
assert_eq!(parsed.accelerators[0].kind, "gpu");
assert_eq!(parsed.accelerators[0].model.as_deref(), Some("H100"));
let json = serde_json::to_string(&net).unwrap();
let parsed: NetworkInfo = serde_json::from_str(&json).unwrap();
assert!(parsed.listening_ports.contains(&22));
assert!(parsed.listening_ports.contains(&443));
assert!(!parsed.tunnel_running);
assert!(parsed.tunnel_pid.is_none());
}
#[test]
fn test_telemetry_deserialize_old_wal_event() {
let old_json = r#"{
"jax_available": true,
"jax_version": "0.4.25",
"jax_device_count": 8
}"#;
let parsed: HardwareInfo = serde_json::from_str(old_json).unwrap();
assert!(
parsed.accelerators.is_empty(),
"old WAL events deserialize with empty accelerators"
);
assert!(parsed.jax_available);
}
#[test]
fn test_network_info_listening_ports_roundtrip() {
let net = NetworkInfo {
public_ip: "unknown".into(),
tunnel_running: false,
tunnel_pid: None,
listening_ports: vec![22, 11434, 3389],
};
let json = serde_json::to_string(&net).unwrap();
let parsed: NetworkInfo = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.listening_ports, vec![22, 11434, 3389]);
assert!(!parsed.tunnel_running);
}
}