Skip to main content

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}