Skip to main content

resource_tracker/output/
csv.rs

1use crate::metrics::Sample;
2
3/// CSV header using the same `system_`/`process_` prefix convention as
4/// Python resource-tracker.  System columns (21) cover host-wide metrics;
5/// process columns (11) cover the tracked PID tree.
6///
7/// Unit notes:
8///   system_cpu_usage    - fractional cores (0..N), same as Python
9///   system_memory_*_mib - mebibytes (MiB = 1,048,576 bytes)
10///   system_disk_*       - bytes per interval, same as Python
11///   system_net_*        - bytes per interval, same as Python
12///   system_disk_space_* - GB summed across all block-device mounts
13///   system_gpu_vram_mib - MiB, same as Python
14///   process_cpu_usage   - fractional cores consumed by tracked PID tree
15///
16/// Process fields not yet collected are emitted as empty strings.
17pub fn csv_header() -> &'static str {
18    "timestamp,\
19     system_processes,system_utime,system_stime,system_cpu_usage,\
20     system_memory_free_mib,system_memory_used_mib,system_memory_buffers_mib,\
21     system_memory_cached_mib,system_memory_active_mib,system_memory_inactive_mib,\
22     system_disk_read_bytes,system_disk_write_bytes,\
23     system_disk_space_total_gb,system_disk_space_used_gb,system_disk_space_free_gb,\
24     system_net_recv_bytes,system_net_sent_bytes,\
25     system_gpu_usage,system_gpu_vram_mib,system_gpu_utilized,\
26     process_pid,process_children,process_utime,process_stime,process_cpu_usage,\
27     process_memory_mib,process_disk_read_bytes,process_disk_write_bytes,\
28     process_gpu_usage,process_gpu_vram_mib,process_gpu_utilized,\
29     system_steal_time"
30}
31
32/// Serialize a `Sample` as a single CSV row (no newline).
33///
34/// `interval_secs` is required to convert bytes/sec rates into per-interval
35/// byte counts, matching Python resource-tracker's convention.
36///
37/// Process fields not yet collected are emitted as empty strings.
38/// All process fields are empty when no PID is being tracked.
39pub fn sample_to_csv_row(s: &Sample, interval_secs: u64) -> String {
40    // system_cpu_usage: host-level utilization in fractional cores (0..N_cores)
41    let cpu_usage = s.cpu.utilization_pct;
42
43    // Disk I/O: per-interval byte counts (rate × actual_interval ≈ bytes in this window).
44    // Prefer actual_interval_ms from the sample when available; fall back to the
45    // configured nominal interval so the first sample (which has no prior baseline)
46    // still produces a reasonable estimate.
47    let secs = s
48        .actual_interval_ms
49        .map(|ms| ms as f64 / 1000.0)
50        .unwrap_or_else(|| f64::from(u32::try_from(interval_secs).unwrap_or(u32::MAX)));
51    let disk_read: u64 = s
52        .disk
53        .iter()
54        .map(|d| (d.read_bytes_per_sec * secs) as u64)
55        .sum();
56    let disk_write: u64 = s
57        .disk
58        .iter()
59        .map(|d| (d.write_bytes_per_sec * secs) as u64)
60        .sum();
61
62    // Disk space: sum all mounts; used = total - free (includes root-reserved blocks)
63    let disk_space_total: f64 = s
64        .disk
65        .iter()
66        .flat_map(|d| d.mounts.iter())
67        .map(|m| m.total_bytes as f64 / 1_000_000_000.0)
68        .sum();
69    let disk_space_free: f64 = s
70        .disk
71        .iter()
72        .flat_map(|d| d.mounts.iter())
73        .map(|m| m.available_bytes as f64 / 1_000_000_000.0)
74        .sum();
75    let disk_space_used = disk_space_total - disk_space_free;
76
77    // Network I/O: per-interval byte counts
78    let net_recv: u64 = s
79        .network
80        .iter()
81        .map(|n| (n.rx_bytes_per_sec * secs) as u64)
82        .sum();
83    let net_sent: u64 = s
84        .network
85        .iter()
86        .map(|n| (n.tx_bytes_per_sec * secs) as u64)
87        .sum();
88
89    // GPU system aggregates
90    let gpu_usage: f64 = s.gpu.iter().map(|g| g.utilization_pct / 100.0).sum();
91    let gpu_vram: f64 = s
92        .gpu
93        .iter()
94        .map(|g| g.vram_used_bytes as f64 / 1_048_576.0)
95        .sum();
96    let gpu_utilized: u32 =
97        u32::try_from(s.gpu.iter().filter(|g| g.utilization_pct > 0.0).count()).unwrap_or(0);
98
99    // System columns (21): same layout and values as before, new names in header.
100    let system_row = format!(
101        "{},{},{:.3},{:.3},{:.4},{},{},{},{},{},{},{},{},{:.6},{:.6},{:.6},{},{},{:.4},{:.4},{}",
102        s.timestamp_secs,
103        s.cpu.process_count,
104        s.cpu.utime_secs,
105        s.cpu.stime_secs,
106        cpu_usage,
107        s.memory.free_mib,
108        s.memory.used_mib,
109        s.memory.buffers_mib,
110        s.memory.cached_mib,
111        s.memory.active_mib,
112        s.memory.inactive_mib,
113        disk_read,
114        disk_write,
115        disk_space_total,
116        disk_space_used,
117        disk_space_free,
118        net_recv,
119        net_sent,
120        gpu_usage,
121        gpu_vram,
122        gpu_utilized,
123    );
124
125    // Process columns (11): empty when not tracked or not yet collected.
126    let opt_u32 = |v: Option<u32>| v.map_or(String::new(), |x| x.to_string());
127    let opt_i32 = |v: Option<i32>| v.map_or(String::new(), |x| x.to_string());
128    let opt_f4 = |v: Option<f64>| v.map_or(String::new(), |x| format!("{x:.4}"));
129
130    let opt_u64 = |v: Option<u64>| v.map_or(String::new(), |x| x.to_string());
131
132    let process_row = [
133        opt_i32(s.tracked_pid),
134        opt_u32(s.cpu.process_child_count),
135        opt_f4(s.cpu.process_utime_secs),
136        opt_f4(s.cpu.process_stime_secs),
137        opt_f4(s.cpu.process_cores_used),
138        opt_u64(s.cpu.process_pss_mib),
139        opt_u64(s.cpu.process_disk_read_bytes),
140        opt_u64(s.cpu.process_disk_write_bytes),
141        opt_f4(s.cpu.process_gpu_usage),
142        opt_f4(s.cpu.process_gpu_vram_mib),
143        opt_u32(s.cpu.process_gpu_utilized),
144    ]
145    .join(",");
146
147    format!("{system_row},{process_row},{:.3}", s.cpu.steal_time_secs)
148}
149
150// ---------------------------------------------------------------------------
151// Unit tests
152// ---------------------------------------------------------------------------
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::metrics::{CpuMetrics, DiskMetrics, DiskMountMetrics, MemoryMetrics, Sample};
158
159    fn minimal_sample() -> Sample {
160        Sample {
161            timestamp_secs: 1_000_000,
162            actual_interval_ms: None,
163            job_name: None,
164            tracked_pid: None,
165            cpu: CpuMetrics {
166                utilization_pct: 2.5,
167                cgroup_utilization_pct: None,
168                cgroup_usage_secs: None,
169                utime_secs: 1.234,
170                stime_secs: 0.567,
171                steal_time_secs: 0.0,
172                steal_time_pct: 0.0,
173                per_core_steal_time_pct: vec![],
174                process_count: 42,
175                per_core_pct: vec![],
176                process_cores_used: None,
177                process_child_count: None,
178                process_utime_secs: None,
179                process_stime_secs: None,
180                process_pss_mib: None,
181                process_rss_mib: None,
182                process_disk_read_bytes: None,
183                process_disk_write_bytes: None,
184                process_gpu_usage: None,
185                process_gpu_vram_mib: None,
186                process_gpu_utilized: None,
187                process_tree_pids: vec![],
188            },
189            memory: MemoryMetrics {
190                total_mib: 8192,
191                free_mib: 1000,
192                available_mib: 2000,
193                used_mib: 2000,
194                used_pct: 25.0,
195                buffers_mib: 100,
196                cached_mib: 500,
197                swap_total_mib: 0,
198                swap_used_mib: 0,
199                swap_used_pct: 0.0,
200                active_mib: 1500,
201                inactive_mib: 300,
202            },
203            network: vec![],
204            disk: vec![],
205            gpu: vec![],
206        }
207    }
208
209    // T-CSV-01: header is the first line and contains no embedded newlines.
210    #[test]
211    fn test_csv_header_is_first_line_no_embedded_newline() {
212        let h = csv_header();
213        assert!(
214            h.starts_with("timestamp,"),
215            "header must start with 'timestamp,'"
216        );
217        assert!(
218            !h.contains('\n'),
219            "header must not contain an embedded newline"
220        );
221    }
222
223    // T-CSV-02: column count in each data row equals column count in header.
224    #[test]
225    fn test_csv_row_column_count_matches_header() {
226        let header_cols = csv_header().split(',').count();
227        let row = sample_to_csv_row(&minimal_sample(), 1);
228        let row_cols = row.split(',').count();
229        assert_eq!(
230            row_cols, header_cols,
231            "header has {header_cols} columns but row has {row_cols}: {row}"
232        );
233    }
234
235    // T-CSV-03: system_cpu_usage column equals host utilization_pct to 4 dp.
236    //
237    // NOTE: The Specification.md table formula reads "utilization_pct / 100 × total_cores"
238    // which is stale.  PR #1 Changelog explicitly corrected this:
239    //   "Was: utilization_pct / 100.0 * total_cores; Now: utilization_pct directly
240    //    (field is already in fractional cores)."
241    // The CpuMetrics field definition in the spec and in metrics/cpu.rs both confirm
242    // utilization_pct is in range 0.0..N_cores, not 0.0..100.0.
243    // This test verifies the actual (correct) behavior.
244    #[test]
245    fn test_csv_cpu_usage_is_utilization_pct_direct() {
246        let mut sample = minimal_sample();
247        sample.cpu.utilization_pct = 3.1415;
248        let row = sample_to_csv_row(&sample, 1);
249        // Column order: timestamp(0),system_processes(1),system_utime(2),
250        //   system_stime(3),system_cpu_usage(4),...
251        let cols: Vec<&str> = row.split(',').collect();
252        let cpu_usage: f64 = cols[4]
253            .parse()
254            .unwrap_or_else(|_| panic!("system_cpu_usage column is not numeric: {:?}", cols[4]));
255        assert!(
256            (cpu_usage - 3.1415_f64).abs() < 0.00005,
257            "system_cpu_usage {cpu_usage:.4} does not match utilization_pct 3.1415"
258        );
259    }
260
261    // T-CSV-04: disk_space_used_gb == disk_space_total_gb - disk_space_free_gb.
262    #[test]
263    fn test_csv_disk_space_used_equals_total_minus_free() {
264        let mut sample = minimal_sample();
265        sample.disk = vec![DiskMetrics {
266            device: "sda".to_string(),
267            model: None,
268            vendor: None,
269            serial: None,
270            device_type: None,
271            capacity_bytes: None,
272            mounts: vec![DiskMountMetrics {
273                mount_point: "/".to_string(),
274                filesystem: "ext4".to_string(),
275                total_bytes: 100_000_000_000,
276                used_bytes: 60_000_000_000,
277                available_bytes: 40_000_000_000,
278                used_pct: 60.0,
279            }],
280            read_bytes_per_sec: 0.0,
281            write_bytes_per_sec: 0.0,
282            read_bytes_total: 0,
283            write_bytes_total: 0,
284        }];
285        let row = sample_to_csv_row(&sample, 1);
286        // Column order: ...system_disk_space_total_gb(13),system_disk_space_used_gb(14),
287        //   system_disk_space_free_gb(15),...  (indices unchanged from original layout)
288        let cols: Vec<&str> = row.split(',').collect();
289        let total: f64 = cols[13].parse().unwrap();
290        let used: f64 = cols[14].parse().unwrap();
291        let free: f64 = cols[15].parse().unwrap();
292        assert!(
293            (used - (total - free)).abs() < 1e-9,
294            "disk_space_used_gb {used:.6} != total {total:.6} - free {free:.6}"
295        );
296    }
297
298    // T-CSV-05: output is byte-for-byte reproducible for the same sample.
299    #[test]
300    fn test_csv_output_is_deterministic() {
301        let sample = minimal_sample();
302        let r1 = sample_to_csv_row(&sample, 1);
303        let r2 = sample_to_csv_row(&sample, 1);
304        assert_eq!(r1, r2, "csv row output is not deterministic");
305    }
306
307    // T-CSV-07: process_gpu_usage, process_gpu_vram_mib, and process_gpu_utilized
308    // are emitted at columns 29, 30, and 31 when set.
309    #[test]
310    fn test_csv_process_gpu_fields_emitted_when_set() {
311        let mut sample = minimal_sample();
312        sample.tracked_pid = Some(42);
313        sample.cpu.process_gpu_usage = Some(0.55);
314        sample.cpu.process_gpu_vram_mib = Some(83.1875);
315        sample.cpu.process_gpu_utilized = Some(1);
316
317        let row = sample_to_csv_row(&sample, 1);
318        let cols: Vec<&str> = row.split(',').collect();
319
320        assert_eq!(cols[29], "0.5500", "process_gpu_usage mismatch");
321        assert_eq!(cols[30], "83.1875", "process_gpu_vram_mib mismatch");
322        assert_eq!(cols[31], "1", "process_gpu_utilized mismatch");
323    }
324
325    // T-CSV-08: process GPU columns are empty strings when no PID is tracked.
326    #[test]
327    fn test_csv_process_gpu_fields_empty_when_untracked() {
328        let sample = minimal_sample(); // tracked_pid = None, all process fields None
329
330        let row = sample_to_csv_row(&sample, 1);
331        let cols: Vec<&str> = row.split(',').collect();
332
333        assert_eq!(cols[29], "", "process_gpu_usage must be empty when None");
334        assert_eq!(cols[30], "", "process_gpu_vram_mib must be empty when None");
335        assert_eq!(cols[31], "", "process_gpu_utilized must be empty when None");
336    }
337
338    // T-CSV-06: no quoted fields; header has no trailing comma.
339    // Note: data rows may end with ',' when trailing process fields are empty
340    // (no PID tracked).  This is valid CSV -- empty fields after the last comma
341    // represent null values, not a formatting error.
342    #[test]
343    fn test_csv_no_trailing_commas_no_quoted_fields() {
344        let row = sample_to_csv_row(&minimal_sample(), 1);
345        assert!(!row.contains('"'), "double-quoted field in row: {row}");
346        assert!(!row.contains('\''), "single-quoted field in row: {row}");
347        let h = csv_header();
348        assert!(!h.ends_with(','), "trailing comma in header");
349        assert!(!h.contains('"'), "double-quoted field in header");
350    }
351
352    // T-CSV-09: sample_to_csv_row uses actual_interval_ms for disk/network byte
353    // conversion when Some, ignoring the nominal interval_secs argument.
354    //
355    // Setup: disk reports 1000 B/s; nominal interval = 1 s; actual interval = 2 s.
356    // Expected: system_disk_read_bytes = 2000 (rate × actual), not 1000 (rate × nominal).
357    #[test]
358    fn test_csv_rate_conversion_uses_actual_interval_when_present() {
359        use crate::metrics::DiskMetrics;
360        let mut sample = minimal_sample();
361        sample.actual_interval_ms = Some(2000); // 2 s actual
362        sample.disk = vec![DiskMetrics {
363            device: "sda".to_string(),
364            model: None,
365            vendor: None,
366            serial: None,
367            device_type: None,
368            capacity_bytes: None,
369            mounts: vec![],
370            read_bytes_per_sec: 1000.0,
371            write_bytes_per_sec: 500.0,
372            read_bytes_total: 0,
373            write_bytes_total: 0,
374        }];
375
376        // Column 11 = system_disk_read_bytes, column 12 = system_disk_write_bytes.
377        let row = sample_to_csv_row(&sample, 1); // nominal = 1 s
378        let cols: Vec<&str> = row.split(',').collect();
379        let read: u64 = cols[11]
380            .parse()
381            .unwrap_or_else(|_| panic!("system_disk_read_bytes not u64: {:?}", cols[11]));
382        let write: u64 = cols[12]
383            .parse()
384            .unwrap_or_else(|_| panic!("system_disk_write_bytes not u64: {:?}", cols[12]));
385        assert_eq!(
386            read, 2000,
387            "system_disk_read_bytes must use actual interval (2 s → 2000 B), not nominal (1 s → 1000 B)"
388        );
389        assert_eq!(
390            write, 1000,
391            "system_disk_write_bytes must use actual interval (2 s → 1000 B), not nominal (1 s → 500 B)"
392        );
393    }
394
395    // T-CSV-10: sample_to_csv_row falls back to the nominal interval_secs when
396    // actual_interval_ms is None (first sample -- no prior baseline exists).
397    //
398    // Setup: disk reports 1000 B/s; actual_interval_ms = None; nominal = 3 s.
399    // Expected: system_disk_read_bytes = 3000 (rate × nominal).
400    #[test]
401    fn test_csv_rate_conversion_falls_back_to_nominal_when_actual_absent() {
402        use crate::metrics::DiskMetrics;
403        let mut sample = minimal_sample();
404        sample.actual_interval_ms = None;
405        sample.disk = vec![DiskMetrics {
406            device: "sda".to_string(),
407            model: None,
408            vendor: None,
409            serial: None,
410            device_type: None,
411            capacity_bytes: None,
412            mounts: vec![],
413            read_bytes_per_sec: 1000.0,
414            write_bytes_per_sec: 0.0,
415            read_bytes_total: 0,
416            write_bytes_total: 0,
417        }];
418
419        let row = sample_to_csv_row(&sample, 3); // nominal = 3 s, no actual
420        let cols: Vec<&str> = row.split(',').collect();
421        let read: u64 = cols[11]
422            .parse()
423            .unwrap_or_else(|_| panic!("system_disk_read_bytes not u64: {:?}", cols[11]));
424        assert_eq!(
425            read, 3000,
426            "system_disk_read_bytes must use nominal interval (3 s → 3000 B) when actual_interval_ms is None"
427        );
428    }
429
430    // T-CSV-11: actual_interval_ms does NOT add a new column to the CSV row.
431    // The field is JSON-only; the CSV column count must remain unchanged.
432    #[test]
433    fn test_csv_actual_interval_ms_does_not_add_column() {
434        let mut with_interval = minimal_sample();
435        with_interval.actual_interval_ms = Some(1234);
436        let without_interval = minimal_sample(); // actual_interval_ms = None
437
438        let row_with = sample_to_csv_row(&with_interval, 1);
439        let row_without = sample_to_csv_row(&without_interval, 1);
440
441        assert_eq!(
442            row_with.split(',').count(),
443            row_without.split(',').count(),
444            "actual_interval_ms must not add a column to the CSV row"
445        );
446        assert_eq!(
447            row_with.split(',').count(),
448            csv_header().split(',').count(),
449            "CSV row column count must equal header column count"
450        );
451    }
452}