kanade_shared/wire/process_perf.rs
1//! Per-process snapshot for operator-driven host investigation
2//! (v0.41 / Phase 2 of the perf telemetry pipeline).
3//!
4//! Distinct from [`HostPerf`]: that's a whole-machine roll-up that
5//! every agent publishes on a 60 s cadence by default. `ProcessPerf`
6//! is the **expensive** path — it requires walking the full OS
7//! process table (`CreateToolhelp32Snapshot` on Windows, `/proc`
8//! enumeration on Linux), so it's off by default and only turns on
9//! per-PC when the operator flips `process_perf_enabled` in
10//! `agent_config`. The agent auto-disables publishing the moment
11//! `process_perf_expires_at` slides into the past — see
12//! [`EffectiveConfig::process_perf_active_at`].
13//!
14//! [`HostPerf`]: super::HostPerf
15//! [`EffectiveConfig::process_perf_active_at`]: super::EffectiveConfig::process_perf_active_at
16
17use serde::{Deserialize, Serialize};
18
19/// One agent tick worth of "top processes by CPU" data. `processes`
20/// is already sorted (descending CPU%) and clipped to the
21/// `process_perf_top_n` configured for that scope, so the backend
22/// projector can persist it verbatim without re-sorting.
23#[derive(Serialize, Deserialize, Debug, Clone)]
24pub struct ProcessPerf {
25 pub pc_id: String,
26 pub at: chrono::DateTime<chrono::Utc>,
27 pub processes: Vec<ProcessSnapshot>,
28}
29
30/// Single process row inside [`ProcessPerf`]. Mirrors the shape of
31/// the agent's self-perf fields on [`Heartbeat`] so existing
32/// formatters (em-dash for nulls etc.) keep working on the SPA.
33///
34/// [`Heartbeat`]: super::Heartbeat
35#[derive(Serialize, Deserialize, Debug, Clone)]
36pub struct ProcessSnapshot {
37 /// PID — `u32` matches Windows `DWORD` and is wide enough for
38 /// Linux's 22-bit space too.
39 pub pid: u32,
40 /// Image name as sysinfo reports it (basename on Linux,
41 /// "program.exe" on Windows). NOT the full path — that lives in
42 /// inventory if an operator needs to disambiguate two identically
43 /// named processes.
44 pub name: String,
45 /// Percent-of-one-core, same convention as
46 /// `Heartbeat::agent_cpu_pct`. A worker pinning two cores reports
47 /// `200.0` here; divide by the host's core count for a host-
48 /// normalised view.
49 pub cpu_pct: f64,
50 /// Resident set size in bytes — sysinfo's `Process::memory()`.
51 /// `i64` (not `u64`) so the projector binds cleanly via
52 /// `sqlx::query!` against an `INTEGER` column.
53 pub rss_bytes: i64,
54 /// Disk read rate, B/s, computed agent-side by diffing successive
55 /// cumulative `Process::disk_usage().total_read_bytes` and
56 /// dividing by elapsed wall time. `None` on the very first tick
57 /// for a freshly-tracked PID (no prior sample to diff) and after
58 /// any cadence change that resets the baseline.
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub disk_read_bytes_per_sec: Option<f64>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub disk_written_bytes_per_sec: Option<f64>,
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68 use chrono::TimeZone;
69
70 #[test]
71 fn process_perf_round_trips_through_json() {
72 let s = ProcessPerf {
73 pc_id: "minipc".into(),
74 at: chrono::Utc.with_ymd_and_hms(2026, 5, 24, 1, 0, 0).unwrap(),
75 processes: vec![
76 ProcessSnapshot {
77 pid: 4321,
78 name: "chrome.exe".into(),
79 cpu_pct: 87.5,
80 rss_bytes: 2_000_000_000,
81 disk_read_bytes_per_sec: Some(1024.0 * 1024.0),
82 disk_written_bytes_per_sec: Some(0.0),
83 },
84 ProcessSnapshot {
85 pid: 100,
86 name: "systemd".into(),
87 cpu_pct: 0.1,
88 rss_bytes: 50_000_000,
89 disk_read_bytes_per_sec: None,
90 disk_written_bytes_per_sec: None,
91 },
92 ],
93 };
94 let json = serde_json::to_string(&s).unwrap();
95 let back: ProcessPerf = serde_json::from_str(&json).unwrap();
96 assert_eq!(back.pc_id, s.pc_id);
97 assert_eq!(back.at, s.at);
98 assert_eq!(back.processes.len(), 2);
99 assert_eq!(back.processes[0].pid, 4321);
100 assert_eq!(back.processes[0].name, "chrome.exe");
101 assert_eq!(back.processes[0].cpu_pct, 87.5);
102 assert_eq!(back.processes[1].disk_read_bytes_per_sec, None);
103 }
104
105 #[test]
106 fn process_snapshot_with_optional_io_omitted_still_decodes() {
107 let json = r#"{"pid":1,"name":"init","cpu_pct":0.0,"rss_bytes":1000000}"#;
108 let s: ProcessSnapshot = serde_json::from_str(json).unwrap();
109 assert_eq!(s.pid, 1);
110 assert_eq!(s.name, "init");
111 assert_eq!(s.disk_read_bytes_per_sec, None);
112 assert_eq!(s.disk_written_bytes_per_sec, None);
113 }
114}