use std::collections::BTreeMap; use std::fs; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use crate::record::Record; pub const HOST_CPU_CHANNEL: &str = "host.cpu"; pub const CPU_SAMPLE_INTERVAL: Duration = Duration::from_secs(1); const SCHEMA: &str = "host.cpu.v1"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct HostCpuSample { pub schema: String, pub seq: u64, pub sample_unix_ms: u64, pub query_elapsed_ms: Option, pub host: Option, pub cores: Vec, pub processes: Vec, pub error: Option, } impl HostCpuSample { pub fn error(seq: u64, error: impl Into) -> Self { Self { schema: SCHEMA.to_string(), seq, sample_unix_ms: unix_ms_now(), query_elapsed_ms: None, host: None, cores: Vec::new(), processes: Vec::new(), error: Some(error.into()), } } } impl Record for HostCpuSample { const CHANNEL: &'static str = HOST_CPU_CHANNEL; } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CpuHostSample { pub logical_cpus: u32, pub total_percent: Option, pub idle_percent: Option, pub iowait_percent: Option, pub steal_percent: Option, pub load1: Option, pub load5: Option, pub load15: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CpuCoreSample { pub index: u32, pub total_percent: Option, pub idle_percent: Option, pub iowait_percent: Option, pub steal_percent: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CpuProcessSample { pub pid: u32, pub process_name: String, pub cpu_percent: Option, pub rss_bytes: Option, pub vms_bytes: Option, pub thread_count: Option, pub error: Option, } #[derive(Debug, Clone)] pub struct CpuSampler { watched_pids: Vec, previous_host: Option, previous_cores: BTreeMap, previous_process_ticks: BTreeMap, } impl CpuSampler { pub fn new(watched_pids: impl IntoIterator) -> Self { Self { watched_pids: watched_pids.into_iter().collect(), previous_host: None, previous_cores: BTreeMap::new(), previous_process_ticks: BTreeMap::new(), } } pub fn sample(&mut self, seq: u64) -> HostCpuSample { let started = Instant::now(); let sample_unix_ms = unix_ms_now(); let cpu_snapshot = match read_cpu_snapshot() { Ok(snapshot) => snapshot, Err(error) => return HostCpuSample::error(seq, error), }; let load = match read_load_average() { Ok(load) => Some(load), Err(error) => { let host = self.build_host_sample(&cpu_snapshot, None); let cores = self.build_core_samples(&cpu_snapshot); self.store_previous_cpu(&cpu_snapshot); return HostCpuSample { schema: SCHEMA.to_string(), seq, sample_unix_ms, query_elapsed_ms: Some(elapsed_ms(started)), host: Some(host), cores, processes: self.sample_processes(&cpu_snapshot), error: Some(error), }; } }; let host = self.build_host_sample(&cpu_snapshot, load); let cores = self.build_core_samples(&cpu_snapshot); let processes = self.sample_processes(&cpu_snapshot); self.store_previous_cpu(&cpu_snapshot); HostCpuSample { schema: SCHEMA.to_string(), seq, sample_unix_ms, query_elapsed_ms: Some(elapsed_ms(started)), host: Some(host), cores, processes, error: None, } } fn build_host_sample( &self, snapshot: &CpuSnapshot, load: Option, ) -> CpuHostSample { let percentages = self .previous_host .as_ref() .and_then(|previous| percentages(previous, &snapshot.host)); CpuHostSample { logical_cpus: saturating_usize_to_u32(snapshot.cores.len()), total_percent: percentages.as_ref().map(|p| p.busy_percent), idle_percent: percentages.as_ref().map(|p| p.idle_percent), iowait_percent: percentages.as_ref().map(|p| p.iowait_percent), steal_percent: percentages.as_ref().map(|p| p.steal_percent), load1: load.as_ref().map(|load| load.one), load5: load.as_ref().map(|load| load.five), load15: load.as_ref().map(|load| load.fifteen), } } fn build_core_samples(&self, snapshot: &CpuSnapshot) -> Vec { snapshot .cores .iter() .map(|core| { let percentages = self .previous_cores .get(&core.index) .and_then(|previous| percentages(previous, &core.times)); CpuCoreSample { index: core.index, total_percent: percentages.as_ref().map(|p| p.busy_percent), idle_percent: percentages.as_ref().map(|p| p.idle_percent), iowait_percent: percentages.as_ref().map(|p| p.iowait_percent), steal_percent: percentages.as_ref().map(|p| p.steal_percent), } }) .collect() } fn sample_processes(&mut self, snapshot: &CpuSnapshot) -> Vec { let total_delta = self .previous_host .as_ref() .and_then(|previous| snapshot.host.total().checked_sub(previous.total())); let logical_cpus = snapshot.cores.len().max(1) as f64; let mut samples = Vec::with_capacity(self.watched_pids.len()); for pid in &self.watched_pids { match read_process_counters(*pid) { Ok(process) => { let cpu_percent = match (total_delta, self.previous_process_ticks.get(pid).copied()) { (Some(total_delta), Some(previous_ticks)) if total_delta > 0 => process .cpu_ticks .checked_sub(previous_ticks) .map(|process_delta| { process_delta as f64 * logical_cpus * 100.0 / total_delta as f64 }), _ => None, }; self.previous_process_ticks.insert(*pid, process.cpu_ticks); samples.push(CpuProcessSample { pid: *pid, process_name: process.name, cpu_percent, rss_bytes: process.rss_bytes, vms_bytes: process.vms_bytes, thread_count: process.thread_count, error: None, }); } Err(error) => { self.previous_process_ticks.remove(pid); samples.push(CpuProcessSample { pid: *pid, process_name: String::new(), cpu_percent: None, rss_bytes: None, vms_bytes: None, thread_count: None, error: Some(error), }); } } } samples } fn store_previous_cpu(&mut self, snapshot: &CpuSnapshot) { self.previous_host = Some(snapshot.host.clone()); self.previous_cores = snapshot .cores .iter() .map(|core| (core.index, core.times.clone())) .collect(); } } #[derive(Debug, Clone, PartialEq, Eq)] struct CpuSnapshot { host: CpuTimes, cores: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] struct CpuCoreTimes { index: u32, times: CpuTimes, } #[derive(Debug, Clone, PartialEq, Eq)] struct CpuTimes { user: u64, nice: u64, system: u64, idle: u64, iowait: u64, irq: u64, softirq: u64, steal: u64, } impl CpuTimes { fn total(&self) -> u64 { self.user .saturating_add(self.nice) .saturating_add(self.system) .saturating_add(self.idle) .saturating_add(self.iowait) .saturating_add(self.irq) .saturating_add(self.softirq) .saturating_add(self.steal) } } #[derive(Debug, Clone, Copy, PartialEq)] struct CpuPercentages { busy_percent: f64, idle_percent: f64, iowait_percent: f64, steal_percent: f64, } #[derive(Debug, Clone, Copy, PartialEq)] struct LoadAverage { one: f64, five: f64, fifteen: f64, } #[derive(Debug, Clone, PartialEq, Eq)] struct ProcessCounters { name: String, cpu_ticks: u64, rss_bytes: Option, vms_bytes: Option, thread_count: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] struct ProcessStatus { name: Option, rss_bytes: Option, vms_bytes: Option, thread_count: Option, } fn read_cpu_snapshot() -> Result { let raw = fs::read_to_string("/proc/stat").map_err(|err| format!("read /proc/stat: {err}"))?; parse_cpu_snapshot(&raw).ok_or_else(|| "parse /proc/stat cpu rows".to_string()) } fn parse_cpu_snapshot(raw: &str) -> Option { let mut host = None; let mut cores = Vec::new(); for line in raw.lines() { let Some(label) = line.split_whitespace().next() else { continue; }; if label == "cpu" { host = parse_cpu_times(line); } else if let Some(index) = label .strip_prefix("cpu") .and_then(|suffix| suffix.parse::().ok()) { if let Some(times) = parse_cpu_times(line) { cores.push(CpuCoreTimes { index, times }); } } } host.map(|host| CpuSnapshot { host, cores }) } fn parse_cpu_times(line: &str) -> Option { let mut fields = line.split_whitespace().skip(1); Some(CpuTimes { user: fields.next()?.parse().ok()?, nice: fields.next()?.parse().ok()?, system: fields.next()?.parse().ok()?, idle: fields.next()?.parse().ok()?, iowait: fields.next().and_then(|v| v.parse().ok()).unwrap_or(0), irq: fields.next().and_then(|v| v.parse().ok()).unwrap_or(0), softirq: fields.next().and_then(|v| v.parse().ok()).unwrap_or(0), steal: fields.next().and_then(|v| v.parse().ok()).unwrap_or(0), }) } fn percentages(previous: &CpuTimes, current: &CpuTimes) -> Option { let total_delta = current.total().checked_sub(previous.total())?; if total_delta == 0 { return None; } let idle_delta = current.idle.checked_sub(previous.idle)?; let iowait_delta = current.iowait.checked_sub(previous.iowait)?; let steal_delta = current.steal.checked_sub(previous.steal)?; let idle_all_delta = idle_delta.saturating_add(iowait_delta); let busy_delta = total_delta.saturating_sub(idle_all_delta); let total = total_delta as f64; Some(CpuPercentages { busy_percent: busy_delta as f64 * 100.0 / total, idle_percent: idle_delta as f64 * 100.0 / total, iowait_percent: iowait_delta as f64 * 100.0 / total, steal_percent: steal_delta as f64 * 100.0 / total, }) } fn read_load_average() -> Result { let raw = fs::read_to_string("/proc/loadavg").map_err(|err| format!("read /proc/loadavg: {err}"))?; parse_load_average(&raw).ok_or_else(|| "parse /proc/loadavg".to_string()) } fn parse_load_average(raw: &str) -> Option { let mut fields = raw.split_whitespace(); Some(LoadAverage { one: fields.next()?.parse().ok()?, five: fields.next()?.parse().ok()?, fifteen: fields.next()?.parse().ok()?, }) } fn read_process_counters(pid: u32) -> Result { let stat_path = format!("/proc/{pid}/stat"); let stat_raw = fs::read_to_string(&stat_path).map_err(|err| format!("read {stat_path}: {err}"))?; let (stat_name, cpu_ticks) = parse_process_stat(&stat_raw).ok_or_else(|| format!("parse {stat_path}"))?; let status_path = format!("/proc/{pid}/status"); let status = fs::read_to_string(&status_path) .ok() .map(|raw| parse_process_status(&raw)) .unwrap_or_default(); Ok(ProcessCounters { name: status.name.unwrap_or(stat_name), cpu_ticks, rss_bytes: status.rss_bytes, vms_bytes: status.vms_bytes, thread_count: status.thread_count, }) } fn parse_process_stat(raw: &str) -> Option<(String, u64)> { let open = raw.find('(')?; let close = raw.rfind(')')?; if close <= open { return None; } let name = raw[open + 1..close].to_string(); let fields: Vec<&str> = raw[close + 1..].split_whitespace().collect(); let utime = fields.get(11)?.parse::().ok()?; let stime = fields.get(12)?.parse::().ok()?; Some((name, utime.saturating_add(stime))) } fn parse_process_status(raw: &str) -> ProcessStatus { let mut status = ProcessStatus::default(); for line in raw.lines() { if let Some(value) = line.strip_prefix("Name:") { status.name = Some(value.trim().to_string()); } else if let Some(value) = line.strip_prefix("VmRSS:") { status.rss_bytes = parse_kib_value(value); } else if let Some(value) = line.strip_prefix("VmSize:") { status.vms_bytes = parse_kib_value(value); } else if let Some(value) = line.strip_prefix("Threads:") { status.thread_count = value.trim().parse().ok(); } } status } fn parse_kib_value(value: &str) -> Option { let kib = value.split_whitespace().next()?.parse::().ok()?; kib.checked_mul(1024) } fn saturating_usize_to_u32(value: usize) -> u32 { value.min(u32::MAX as usize) as u32 } fn unix_ms_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) .unwrap_or(0) } fn elapsed_ms(started: Instant) -> u64 { started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64 } #[cfg(test)] mod tests { use super::*; #[test] fn parses_cpu_snapshot_rows() { let snapshot = parse_cpu_snapshot( "cpu 100 2 50 800 10 1 3 4 0 0\ncpu0 60 1 30 400 5 0 2 3 0 0\ncpu1 40 1 20 400 5 1 1 1 0 0\nintr 1\n", ) .expect("snapshot"); assert_eq!(snapshot.host.user, 100); assert_eq!(snapshot.host.total(), 970); assert_eq!(snapshot.cores.len(), 2); assert_eq!(snapshot.cores[0].index, 0); assert_eq!(snapshot.cores[1].times.system, 20); } #[test] fn calculates_cpu_percentages_from_deltas() { let previous = CpuTimes { user: 10, nice: 0, system: 10, idle: 80, iowait: 0, irq: 0, softirq: 0, steal: 0, }; let current = CpuTimes { user: 30, nice: 0, system: 20, idle: 130, iowait: 10, irq: 0, softirq: 0, steal: 10, }; let pct = percentages(&previous, ¤t).expect("percentages"); assert_eq!(pct.busy_percent, 40.0); assert_eq!(pct.idle_percent, 50.0); assert_eq!(pct.iowait_percent, 10.0); assert_eq!(pct.steal_percent, 10.0); } #[test] fn parses_load_average() { let load = parse_load_average("1.25 0.75 0.50 1/234 5678\n").expect("load"); assert_eq!(load.one, 1.25); assert_eq!(load.five, 0.75); assert_eq!(load.fifteen, 0.50); } #[test] fn parses_process_stat_with_spaces_in_name() { let stat = "123 (python worker) S 1 2 3 4 5 6 7 8 9 10 120 30 0 0 20 0 7 0 12345 4096 10"; let (name, ticks) = parse_process_stat(stat).expect("process stat"); assert_eq!(name, "python worker"); assert_eq!(ticks, 150); } #[test] fn parses_process_status_memory_and_threads() { let status = parse_process_status( "Name:\tpython3\nVmSize:\t 1000 kB\nVmRSS:\t 128 kB\nThreads:\t4\n", ); assert_eq!(status.name, Some("python3".to_string())); assert_eq!(status.vms_bytes, Some(1_024_000)); assert_eq!(status.rss_bytes, Some(131_072)); assert_eq!(status.thread_count, Some(4)); } #[test] fn sample_error_uses_host_cpu_schema() { let sample = HostCpuSample::error(7, "no proc"); assert_eq!(HostCpuSample::CHANNEL, "host.cpu"); assert_eq!(sample.schema, "host.cpu.v1"); assert_eq!(sample.seq, 7); assert_eq!(sample.error, Some("no proc".to_string())); } }