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 system_steal_time"
30}
31
32pub fn sample_to_csv_row(s: &Sample, interval_secs: u64) -> String {
40 let cpu_usage = s.cpu.utilization_pct;
42
43 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 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 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 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 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 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#[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[test]
327 fn test_csv_process_gpu_fields_empty_when_untracked() {
328 let sample = minimal_sample(); 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 #[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 #[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); 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 let row = sample_to_csv_row(&sample, 1); 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 #[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); 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 #[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(); 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}