kanade_shared/wire/heartbeat.rs
1use serde::{Deserialize, Serialize};
2
3/// Liveness ping every agent sends on a 30 s cadence (see
4/// `inventory_interval` / `heartbeat_interval` in agent_config).
5///
6/// `hostname` and `os_family` are enriched baseline facts so the
7/// SPA agents page has *something* to show as soon as the agent
8/// boots — even when the full WMI-driven `HwInventory` hasn't been
9/// (or can't be) collected. Both stay `Option<String>` so older
10/// agents that don't send them still deserialize cleanly.
11#[derive(Serialize, Deserialize, Debug, Clone)]
12pub struct Heartbeat {
13 pub pc_id: String,
14 pub at: chrono::DateTime<chrono::Utc>,
15 pub agent_version: String,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub hostname: Option<String>,
18 /// Coarse OS bucket from `std::env::consts::OS` — `"windows"`,
19 /// `"linux"`, `"macos"`. Rich OS metadata still flows through
20 /// the inventory path; this is just the "agent is alive on a
21 /// <family>" signal.
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub os_family: Option<String>,
24 // v0.37 / Part 2: agent process self-perf. All Option so older
25 // agents (or any future build that hits a sysinfo error) keep
26 // sending valid heartbeats — backend just shows blanks. Cost on
27 // the agent is one `sysinfo::System::refresh_processes_specifics`
28 // call per 30 s tick. On Windows the underlying APIs are
29 // `CreateToolhelp32Snapshot` + per-process `GetProcessMemoryInfo`
30 // / `GetProcessIoCounters` (NOT WMI; NOT
31 // `NtQuerySystemInformation`). Single-digit ms on a typical
32 // endpoint; scales with the host's process count for the
33 // Toolhelp snapshot — fine on a normal PC, larger on RDS hosts.
34 /// Agent process CPU usage, in percent-of-one-core (a process
35 /// fully pinning one core reports 100; one pinning two cores
36 /// reports 200). This is sysinfo's convention — closer to
37 /// `top` than to Windows Task Manager (which normalises by
38 /// total cores, so a 1-core peg on an 8-core box shows up as
39 /// ~12.5 % in TM). Divide by host core count if you want a
40 /// host-normalised view. `None` is published on the very first
41 /// heartbeat after process start, because sysinfo's CPU% needs
42 /// two consecutive samples to diff — populating it would
43 /// always report 0.0 there and risk an operator misreading
44 /// "agent isn't doing anything".
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub agent_cpu_pct: Option<f64>,
47 /// Agent process resident set size in bytes — sysinfo's
48 /// `Process::memory()`, which on Windows is
49 /// `PROCESS_MEMORY_COUNTERS_EX::WorkingSetSize` (full working
50 /// set, shared + private). Closest Task Manager column is
51 /// "Working set (memory)", NOT "Memory (private working set)"
52 /// which would be `PrivateUsage` and sysinfo exposes
53 /// separately as `virtual_memory()`.
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub agent_rss_bytes: Option<i64>,
56 /// Absolute bytes the agent process has read from disk since
57 /// it started. Wire format is cumulative (not delta) so
58 /// dropped / out-of-order heartbeats don't poison rate math
59 /// for any client that wants to derive a rate by diffing
60 /// successive snapshots. Today neither the backend projector
61 /// nor the SPA does that diff — they just store and render
62 /// the cumulative value. Future SPA work or an exporter can
63 /// compute rate without a schema change.
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub agent_disk_read_bytes: Option<i64>,
66 /// Absolute bytes the agent process has written to disk since
67 /// it started. Same shape as `agent_disk_read_bytes`.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub agent_disk_written_bytes: Option<i64>,
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use chrono::TimeZone;
76
77 #[test]
78 fn heartbeat_round_trips_through_json() {
79 let hb = Heartbeat {
80 pc_id: "minipc".into(),
81 at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
82 agent_version: "0.12.0".into(),
83 hostname: Some("MINIPC".into()),
84 os_family: Some("windows".into()),
85 agent_cpu_pct: Some(0.3),
86 agent_rss_bytes: Some(45_000_000),
87 agent_disk_read_bytes: Some(1024 * 1024),
88 agent_disk_written_bytes: Some(512 * 1024),
89 };
90 let json = serde_json::to_string(&hb).unwrap();
91 let back: Heartbeat = serde_json::from_str(&json).unwrap();
92 assert_eq!(back.pc_id, hb.pc_id);
93 assert_eq!(back.at, hb.at);
94 assert_eq!(back.agent_version, hb.agent_version);
95 assert_eq!(back.hostname, hb.hostname);
96 assert_eq!(back.os_family, hb.os_family);
97 assert_eq!(back.agent_cpu_pct, hb.agent_cpu_pct);
98 assert_eq!(back.agent_rss_bytes, hb.agent_rss_bytes);
99 assert_eq!(back.agent_disk_read_bytes, hb.agent_disk_read_bytes);
100 assert_eq!(back.agent_disk_written_bytes, hb.agent_disk_written_bytes);
101 }
102
103 #[test]
104 fn heartbeat_without_enrichment_still_decodes() {
105 // Older agents sending only the v0.11 shape must still parse.
106 let json = r#"{"pc_id":"x","at":"2026-05-16T00:00:00Z","agent_version":"0.11.5"}"#;
107 let hb: Heartbeat = serde_json::from_str(json).unwrap();
108 assert_eq!(hb.pc_id, "x");
109 assert_eq!(hb.hostname, None);
110 assert_eq!(hb.os_family, None);
111 // v0.37 Part 2: perf fields are also optional and default
112 // to None, so a pre-0.37 agent's heartbeat keeps decoding.
113 assert_eq!(hb.agent_cpu_pct, None);
114 assert_eq!(hb.agent_rss_bytes, None);
115 assert_eq!(hb.agent_disk_read_bytes, None);
116 assert_eq!(hb.agent_disk_written_bytes, None);
117 }
118}