Skip to main content

kanade_shared/wire/
host_perf.rs

1//! Host-wide performance snapshot. Phase 1 of the perf telemetry
2//! pipeline (the design discussion landed in v0.40).
3//!
4//! Distinct from the *self*-perf fields already on [`Heartbeat`]:
5//! those describe the agent process. `HostPerf` describes the whole
6//! machine the agent is running on — CPU / Memory / Disk I/O / Network
7//! — and is what powers the per-PC time-series charts in the SPA.
8//!
9//! Cadence is taken from [`EffectiveConfig::host_perf_interval`]
10//! (default 60 s). It's deliberately slower than the 30 s heartbeat
11//! because gappy time-series data is acceptable and we'd rather keep
12//! the per-host CPU cost ignorable on Citrix / RDS boxes with thousands
13//! of processes.
14//!
15//! All numeric fields are `Option`. The agent populates them when
16//! sysinfo succeeds; missing values render as gaps in the chart. The
17//! optional shape also keeps forward-compat with future builds that
18//! might be unable to read e.g. swap on a sandbox.
19//!
20//! [`Heartbeat`]: super::Heartbeat
21//! [`EffectiveConfig::host_perf_interval`]: super::EffectiveConfig::host_perf_interval
22
23use serde::{Deserialize, Serialize};
24
25#[derive(Serialize, Deserialize, Debug, Clone)]
26pub struct HostPerf {
27    pub pc_id: String,
28    pub at: chrono::DateTime<chrono::Utc>,
29
30    /// Whole-host CPU utilisation in percent (0..100), normalised
31    /// across all logical cores — the Task Manager "CPU" column
32    /// shape, NOT sysinfo's per-core sum (which would report 800 on
33    /// an 8-core box pegged at 100 %). On Windows the underlying
34    /// signal is `NtQuerySystemInformation(SystemProcessorPerformance
35    /// Information)`; cost is sub-millisecond and scales with logical
36    /// core count, not process count.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub cpu_pct: Option<f64>,
39    /// Number of logical cores reported by sysinfo. Sent every tick
40    /// so the SPA can render "8 cores" alongside the chart without
41    /// needing a second API call into the inventory table.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub cpu_count: Option<u32>,
44
45    /// Physical memory in use, bytes. sysinfo's `System::used_memory()`
46    /// — on Windows this is `MEMORYSTATUSEX.ullTotalPhys -
47    /// ullAvailPhys`, matching Task Manager's "In use" indicator.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub mem_used_bytes: Option<i64>,
50    /// Physical memory total, bytes. Constant for the lifetime of a
51    /// host but sent every tick so the SPA can render a ratio without
52    /// a second query.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub mem_total_bytes: Option<i64>,
55    /// Swap / pagefile used, bytes. `None` on systems with no
56    /// configured swap (some sandboxes / containers).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub swap_used_bytes: Option<i64>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub swap_total_bytes: Option<i64>,
61
62    /// Disk read throughput across **all** volumes, bytes/sec. The
63    /// agent diffs sysinfo's cumulative `Disk::usage()` counters
64    /// between successive ticks and divides by the elapsed wall time
65    /// — backend stores the rate verbatim. `None` on the first tick
66    /// after agent start (no prior sample to diff) and after any
67    /// configuration-driven cadence change that resets the baseline.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub disk_read_bytes_per_sec: Option<f64>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub disk_written_bytes_per_sec: Option<f64>,
72
73    /// Network receive throughput across **all** interfaces (including
74    /// loopback), bytes/sec. Same diff-vs-prev-sample shape as
75    /// `disk_*_bytes_per_sec`. We include loopback in the total
76    /// because filtering it out reliably across Win / Linux is
77    /// surprisingly fiddly and most fleet hosts don't drive much
78    /// loopback traffic.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub net_rx_bytes_per_sec: Option<f64>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub net_tx_bytes_per_sec: Option<f64>,
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use chrono::TimeZone;
89
90    #[test]
91    fn host_perf_round_trips_through_json() {
92        let s = HostPerf {
93            pc_id: "minipc".into(),
94            at: chrono::Utc.with_ymd_and_hms(2026, 5, 24, 0, 0, 0).unwrap(),
95            cpu_pct: Some(12.5),
96            cpu_count: Some(8),
97            mem_used_bytes: Some(8_000_000_000),
98            mem_total_bytes: Some(16_000_000_000),
99            swap_used_bytes: Some(0),
100            swap_total_bytes: Some(4_000_000_000),
101            disk_read_bytes_per_sec: Some(1024.0 * 1024.0),
102            disk_written_bytes_per_sec: Some(512.0 * 1024.0),
103            net_rx_bytes_per_sec: Some(2048.0),
104            net_tx_bytes_per_sec: Some(1024.0),
105        };
106        let json = serde_json::to_string(&s).unwrap();
107        let back: HostPerf = serde_json::from_str(&json).unwrap();
108        assert_eq!(back.pc_id, s.pc_id);
109        assert_eq!(back.at, s.at);
110        assert_eq!(back.cpu_pct, s.cpu_pct);
111        assert_eq!(back.cpu_count, s.cpu_count);
112        assert_eq!(back.mem_used_bytes, s.mem_used_bytes);
113        assert_eq!(back.mem_total_bytes, s.mem_total_bytes);
114        assert_eq!(back.swap_used_bytes, s.swap_used_bytes);
115        assert_eq!(back.swap_total_bytes, s.swap_total_bytes);
116        assert_eq!(back.disk_read_bytes_per_sec, s.disk_read_bytes_per_sec);
117        assert_eq!(
118            back.disk_written_bytes_per_sec,
119            s.disk_written_bytes_per_sec
120        );
121        assert_eq!(back.net_rx_bytes_per_sec, s.net_rx_bytes_per_sec);
122        assert_eq!(back.net_tx_bytes_per_sec, s.net_tx_bytes_per_sec);
123    }
124
125    #[test]
126    fn host_perf_with_all_optional_fields_omitted_still_decodes() {
127        // Forward-compat: an agent that fails to collect anything
128        // beyond pc_id + at still emits a valid HostPerf. The backend
129        // projector should keep accepting these and write all-NULL
130        // sample rows so the gap is visible in the chart.
131        let json = r#"{"pc_id":"x","at":"2026-05-24T00:00:00Z"}"#;
132        let s: HostPerf = serde_json::from_str(json).unwrap();
133        assert_eq!(s.pc_id, "x");
134        assert!(s.cpu_pct.is_none());
135        assert!(s.mem_used_bytes.is_none());
136        assert!(s.disk_read_bytes_per_sec.is_none());
137        assert!(s.net_rx_bytes_per_sec.is_none());
138    }
139}