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