1use crate::metrics::Sample;
2
3pub 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
31pub fn sample_to_csv_row(s: &Sample, interval_secs: u64) -> String {
39 let cpu_usage = s.cpu.utilization_pct;
41
42 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 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 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 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 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 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#[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[test]
323 fn test_csv_process_gpu_fields_empty_when_untracked() {
324 let sample = minimal_sample(); 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 #[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 #[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); 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 let row = sample_to_csv_row(&sample, 1); 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 #[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); 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 #[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(); 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}