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}