1use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10#[cfg(target_os = "macos")]
11use std::io::Read;
12#[cfg(target_os = "linux")]
13use std::path::Path;
14#[cfg(target_os = "macos")]
15use std::process::Stdio;
16#[cfg(target_os = "macos")]
17use std::time::{Duration, Instant};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20pub const MODEL_ID: &str = "telemetry_v1";
22pub const MODEL_VERSION: u32 = 1;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct TelemetryMetric {
28 pub domain: String,
29 pub name: String,
30 pub value: f64,
31 pub unit: String,
32 pub source: String,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct TelemetrySnapshot {
38 pub model_id: String,
39 pub model_version: u32,
40 pub collected_unix_ms: u64,
41 pub os: String,
42 pub arch: String,
43 pub cpu_count: usize,
44 pub loadavg_1m: Option<f64>,
45 pub loadavg_5m: Option<f64>,
46 pub loadavg_15m: Option<f64>,
47 pub metrics: Vec<TelemetryMetric>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct TelemetryMetricDelta {
53 pub domain: String,
54 pub name: String,
55 pub unit: String,
56 pub source: String,
57 pub start_value: f64,
58 pub end_value: f64,
59 pub delta_value: f64,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct TelemetryWindowReport {
65 pub model_id: String,
66 pub model_version: u32,
67 pub elapsed_ms: u64,
68 pub start: TelemetrySnapshot,
69 pub end: TelemetrySnapshot,
70 pub deltas: Vec<TelemetryMetricDelta>,
71}
72
73fn unix_ms_now() -> u64 {
74 SystemTime::now()
75 .duration_since(UNIX_EPOCH)
76 .unwrap_or_default()
77 .as_millis() as u64
78}
79
80#[cfg(target_os = "macos")]
81fn unix_secs_now() -> u64 {
82 SystemTime::now()
83 .duration_since(UNIX_EPOCH)
84 .unwrap_or_default()
85 .as_secs()
86}
87
88fn push_metric(
89 out: &mut Vec<TelemetryMetric>,
90 domain: &str,
91 name: impl Into<String>,
92 value: f64,
93 unit: &str,
94 source: &str,
95) {
96 if !value.is_finite() {
97 return;
98 }
99 out.push(TelemetryMetric {
100 domain: domain.to_string(),
101 name: name.into(),
102 value,
103 unit: unit.to_string(),
104 source: source.to_string(),
105 });
106}
107
108#[cfg(target_os = "linux")]
109fn read_trimmed(path: &Path) -> Option<String> {
110 let raw = std::fs::read_to_string(path).ok()?;
111 let v = raw.trim();
112 if v.is_empty() {
113 None
114 } else {
115 Some(v.to_string())
116 }
117}
118
119#[cfg(target_os = "linux")]
120fn normalize_key(raw: &str) -> String {
121 let mut out = String::with_capacity(raw.len());
122 let mut prev_us = false;
123 for ch in raw.to_ascii_lowercase().chars() {
124 let mapped = if ch.is_ascii_alphanumeric() { ch } else { '_' };
125 if mapped == '_' {
126 if !prev_us {
127 out.push(mapped);
128 }
129 prev_us = true;
130 } else {
131 out.push(mapped);
132 prev_us = false;
133 }
134 }
135 out.trim_matches('_').to_string()
136}
137
138#[cfg(target_os = "linux")]
139fn read_first_f64(path: &Path) -> Option<f64> {
140 std::fs::read_to_string(path)
141 .ok()
142 .and_then(|s| s.split_whitespace().next().and_then(|v| v.parse().ok()))
143}
144
145#[cfg(target_os = "linux")]
146fn linux_clk_tck() -> f64 {
147 let hz = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
149 if hz > 0 { hz as f64 } else { 100.0 }
150}
151
152#[cfg(target_os = "linux")]
153fn parse_psi_line(resource: &str, line: &str, out: &mut Vec<TelemetryMetric>) {
154 let mut parts = line.split_whitespace();
155 let Some(scope) = parts.next() else {
156 return;
157 };
158 if scope != "some" && scope != "full" {
159 return;
160 }
161 for item in parts {
162 let Some((k, v)) = item.split_once('=') else {
163 continue;
164 };
165 let Some(value) = v.parse::<f64>().ok() else {
166 continue;
167 };
168 let name = format!("{resource}_{scope}_{k}");
169 let unit = if k.starts_with("avg") { "pct" } else { "us" };
170 push_metric(out, "pressure", name, value, unit, "psi");
171 }
172}
173
174#[cfg(target_os = "linux")]
175fn is_likely_disk_device(name: &str) -> bool {
176 if name.starts_with("loop")
177 || name.starts_with("ram")
178 || name.starts_with("dm-")
179 || name.starts_with("md")
180 || name.starts_with("zram")
181 || name.starts_with("sr")
182 || name.starts_with("fd")
183 || name.starts_with("nbd")
184 {
185 return false;
186 }
187 if name.starts_with("nvme") {
188 return !name.contains('p');
189 }
190 if name.starts_with("mmcblk") {
191 return !name.contains('p');
192 }
193 if name.starts_with("sd")
194 || name.starts_with("hd")
195 || name.starts_with("vd")
196 || name.starts_with("xvd")
197 {
198 return !name.chars().last().is_some_and(|c| c.is_ascii_digit());
199 }
200 !name.chars().last().is_some_and(|c| c.is_ascii_digit())
201}
202
203#[cfg(target_os = "linux")]
204fn parse_microunit_supply(
205 out: &mut Vec<TelemetryMetric>,
206 dir: &Path,
207 metric_prefix: &str,
208 key: &str,
209 unit: &str,
210 scale: f64,
211) {
212 let path = dir.join(key);
213 if let Some(raw) = read_first_f64(&path) {
214 push_metric(
215 out,
216 "power",
217 format!("{metric_prefix}.{key}"),
218 raw / scale,
219 unit,
220 "linux_power_supply",
221 );
222 }
223}
224
225#[cfg(target_os = "linux")]
226fn parse_power_supply_state(out: &mut Vec<TelemetryMetric>, dir: &Path, metric_prefix: &str) {
227 let status_path = dir.join("status");
228 let Some(status) = read_trimmed(&status_path) else {
229 return;
230 };
231 let lower = status.to_ascii_lowercase();
232 let flags = [
233 ("is_charging", lower == "charging"),
234 ("is_discharging", lower == "discharging"),
235 ("is_full", lower == "full"),
236 ("is_not_charging", lower == "not charging"),
237 ];
238 for (label, enabled) in flags {
239 push_metric(
240 out,
241 "power",
242 format!("{metric_prefix}.{label}"),
243 if enabled { 1.0 } else { 0.0 },
244 "bool",
245 "linux_power_supply",
246 );
247 }
248}
249
250#[cfg(target_os = "macos")]
251fn run_command(cmd: &str, args: &[&str]) -> Option<String> {
252 const COMMAND_TIMEOUT: Duration = Duration::from_millis(400);
253
254 let mut child = std::process::Command::new(cmd)
255 .args(args)
256 .stdin(Stdio::null())
257 .stdout(Stdio::piped())
258 .stderr(Stdio::null())
259 .spawn()
260 .ok()?;
261
262 let start = Instant::now();
263 loop {
264 match child.try_wait() {
265 Ok(Some(status)) => {
266 if !status.success() {
267 return None;
268 }
269 let mut out = Vec::new();
270 if let Some(mut stdout) = child.stdout.take() {
271 let _ = stdout.read_to_end(&mut out);
272 }
273 let s = String::from_utf8_lossy(&out).trim().to_string();
274 return if s.is_empty() { None } else { Some(s) };
275 }
276 Ok(None) => {
277 if start.elapsed() >= COMMAND_TIMEOUT {
278 let _ = child.kill();
279 let _ = child.wait();
280 return None;
281 }
282 std::thread::sleep(Duration::from_millis(5));
283 }
284 Err(_) => return None,
285 }
286 }
287}
288
289#[cfg(target_os = "macos")]
290fn read_sysctl(key: &str) -> Option<String> {
291 run_command("/usr/sbin/sysctl", &["-n", key])
292}
293
294#[cfg(target_os = "macos")]
295fn parse_first_f64(s: &str) -> Option<f64> {
296 s.split_whitespace().next()?.parse::<f64>().ok()
297}
298
299#[cfg(target_os = "macos")]
300fn parse_size_with_suffix(token: &str) -> Option<f64> {
301 let suffix = token.chars().last()?;
302 let (number, multiplier) = match suffix {
303 'K' | 'k' => (token.get(..token.len().saturating_sub(1))?, 1024.0),
304 'M' | 'm' => (token.get(..token.len().saturating_sub(1))?, 1024.0 * 1024.0),
305 'G' | 'g' => (
306 token.get(..token.len().saturating_sub(1))?,
307 1024.0 * 1024.0 * 1024.0,
308 ),
309 'T' | 't' => (
310 token.get(..token.len().saturating_sub(1))?,
311 1024.0 * 1024.0 * 1024.0 * 1024.0,
312 ),
313 'P' | 'p' => (
314 token.get(..token.len().saturating_sub(1))?,
315 1024.0 * 1024.0 * 1024.0 * 1024.0 * 1024.0,
316 ),
317 _ => (token, 1.0),
318 };
319 number.parse::<f64>().ok().map(|v| v * multiplier)
320}
321
322#[cfg(target_os = "macos")]
323fn parse_vm_stat_value(raw: &str) -> Option<f64> {
324 let cleaned = raw.replace(['.', ','], "");
325 cleaned
326 .split_whitespace()
327 .next()
328 .and_then(|s| s.parse::<f64>().ok())
329}
330
331#[cfg(target_os = "macos")]
332fn parse_macos_uptime_piece(raw: &str) -> Option<f64> {
333 let piece = raw.trim().trim_start_matches("up ").trim();
334 if piece.is_empty() {
335 return None;
336 }
337 if let Some((h, m)) = piece.split_once(':')
338 && let (Ok(hours), Ok(minutes)) = (h.trim().parse::<f64>(), m.trim().parse::<f64>())
339 {
340 return Some(hours * 3600.0 + minutes * 60.0);
341 }
342
343 let mut parts = piece.split_whitespace();
344 let value = parts.next()?.parse::<f64>().ok()?;
345 let unit = parts.next().unwrap_or_default().to_ascii_lowercase();
346 if unit.starts_with("day") {
347 Some(value * 86_400.0)
348 } else if unit.starts_with("hr") || unit.starts_with("hour") {
349 Some(value * 3600.0)
350 } else if unit.starts_with("min") {
351 Some(value * 60.0)
352 } else if unit.starts_with("sec") {
353 Some(value)
354 } else {
355 None
356 }
357}
358
359#[cfg(target_os = "macos")]
360fn collect_macos_uptime_metrics(out: &mut Vec<TelemetryMetric>) {
361 let Some(raw) = run_command("/usr/bin/uptime", &[]) else {
362 return;
363 };
364 let left = raw
365 .split("load average")
366 .next()
367 .unwrap_or(raw.as_str())
368 .trim()
369 .trim_end_matches(',')
370 .trim();
371
372 let uptime_section = raw
373 .split_once(" up ")
374 .map(|(_, rest)| {
375 rest.split("load average")
376 .next()
377 .unwrap_or(rest)
378 .trim()
379 .trim_end_matches(',')
380 .trim()
381 })
382 .unwrap_or("");
383
384 let mut uptime_seconds = 0.0;
385 let mut users = None;
386 for piece in left.split(',').map(str::trim) {
387 if piece.contains("user") {
388 users = piece.split_whitespace().find_map(|s| s.parse::<f64>().ok());
389 continue;
390 }
391 if let Some(sec) = parse_macos_uptime_piece(piece) {
392 uptime_seconds += sec;
393 }
394 }
395 if uptime_seconds <= 0.0 {
396 for piece in uptime_section.split(',').map(str::trim) {
397 if let Some(sec) = parse_macos_uptime_piece(piece) {
398 uptime_seconds += sec;
399 }
400 }
401 }
402
403 if uptime_seconds > 0.0 {
404 push_metric(
405 out,
406 "system",
407 "uptime_seconds",
408 uptime_seconds,
409 "s",
410 "uptime",
411 );
412 }
413 if let Some(users) = users {
414 push_metric(out, "system", "logged_in_users", users, "count", "uptime");
415 }
416}
417
418#[cfg(target_os = "macos")]
419fn collect_macos_sysctl_metrics(out: &mut Vec<TelemetryMetric>) {
420 if let Some(tb_hz) = read_sysctl("hw.tbfrequency").and_then(|s| parse_first_f64(&s)) {
421 push_metric(out, "frequency", "timebase_hz", tb_hz, "Hz", "sysctl");
422 }
423 if let Some(cpu_hz) = read_sysctl("hw.cpufrequency").and_then(|s| parse_first_f64(&s)) {
424 push_metric(out, "frequency", "cpu_hz", cpu_hz, "Hz", "sysctl");
425 }
426 if let Some(total_bytes) = read_sysctl("hw.memsize").and_then(|s| parse_first_f64(&s)) {
427 push_metric(out, "memory", "total_bytes", total_bytes, "bytes", "sysctl");
428 }
429 if let Some(active_cpu) = read_sysctl("hw.activecpu").and_then(|s| parse_first_f64(&s)) {
430 push_metric(
431 out,
432 "scheduling",
433 "active_cpu_count",
434 active_cpu,
435 "count",
436 "sysctl",
437 );
438 }
439 if let Some(num_tasks) = read_sysctl("kern.num_tasks").and_then(|s| parse_first_f64(&s)) {
440 push_metric(
441 out,
442 "scheduling",
443 "task_count",
444 num_tasks,
445 "count",
446 "sysctl",
447 );
448 }
449 if let Some(num_threads) = read_sysctl("kern.num_threads").and_then(|s| parse_first_f64(&s)) {
450 push_metric(
451 out,
452 "scheduling",
453 "thread_count",
454 num_threads,
455 "count",
456 "sysctl",
457 );
458 }
459 if let Some(pressure_level) =
460 read_sysctl("kern.memorystatus_vm_pressure_level").and_then(|s| parse_first_f64(&s))
461 {
462 push_metric(
463 out,
464 "pressure",
465 "memory_pressure_level",
466 pressure_level,
467 "level",
468 "sysctl",
469 );
470 }
471 if let Some(boot_raw) = read_sysctl("kern.boottime")
472 && let Some(sec_part) = boot_raw
473 .split("sec =")
474 .nth(1)
475 .and_then(|s| s.split(',').next())
476 .and_then(|s| s.trim().parse::<u64>().ok())
477 {
478 let uptime = unix_secs_now().saturating_sub(sec_part) as f64;
479 push_metric(out, "system", "uptime_seconds", uptime, "s", "sysctl");
480 }
481 if let Some(swapusage) = read_sysctl("vm.swapusage") {
482 for (label, metric_name) in [
483 ("total", "swap_total_bytes"),
484 ("used", "swap_used_bytes"),
485 ("free", "swap_free_bytes"),
486 ] {
487 if let Some(value) = swapusage
488 .split(&format!("{label} ="))
489 .nth(1)
490 .and_then(|s| s.split_whitespace().next())
491 .and_then(parse_size_with_suffix)
492 {
493 push_metric(out, "memory", metric_name, value, "bytes", "sysctl");
494 }
495 }
496 }
497}
498
499#[cfg(target_os = "macos")]
500fn collect_macos_cp_time_metrics(out: &mut Vec<TelemetryMetric>) {
501 let Some(raw) = read_sysctl("kern.cp_time") else {
502 return;
503 };
504 let parts: Vec<f64> = raw
505 .split_whitespace()
506 .filter_map(|s| s.parse::<f64>().ok())
507 .collect();
508 if parts.len() < 5 {
509 return;
510 }
511
512 let user = parts[0];
513 let nice = parts[1];
514 let system = parts[2];
515 let idle = parts[3];
516 let intr = parts[4];
517
518 push_metric(
519 out,
520 "scheduling",
521 "cpu_time_user_ticks",
522 user,
523 "ticks",
524 "sysctl",
525 );
526 push_metric(
527 out,
528 "scheduling",
529 "cpu_time_nice_ticks",
530 nice,
531 "ticks",
532 "sysctl",
533 );
534 push_metric(
535 out,
536 "scheduling",
537 "cpu_time_system_ticks",
538 system,
539 "ticks",
540 "sysctl",
541 );
542 push_metric(
543 out,
544 "scheduling",
545 "cpu_time_idle_ticks",
546 idle,
547 "ticks",
548 "sysctl",
549 );
550 push_metric(
551 out,
552 "scheduling",
553 "cpu_time_interrupt_ticks",
554 intr,
555 "ticks",
556 "sysctl",
557 );
558 push_metric(
559 out,
560 "scheduling",
561 "cpu_time_busy_ticks",
562 user + nice + system + intr,
563 "ticks",
564 "sysctl",
565 );
566}
567
568#[cfg(target_os = "macos")]
569fn collect_macos_vm_stat_metrics(out: &mut Vec<TelemetryMetric>) {
570 let Some(vm) = run_command("/usr/bin/vm_stat", &[]) else {
571 return;
572 };
573 let mut page_size = 4096.0_f64;
574
575 for line in vm.lines() {
576 if line.contains("page size of")
577 && let Some(ps) = line
578 .split("page size of")
579 .nth(1)
580 .and_then(|s| s.split_whitespace().next())
581 .and_then(|s| s.parse::<f64>().ok())
582 {
583 page_size = ps;
584 continue;
585 }
586 let Some((raw_key, raw_value)) = line.split_once(':') else {
587 continue;
588 };
589 let key = raw_key.trim().trim_matches('"');
590 let Some(value) = parse_vm_stat_value(raw_value) else {
591 continue;
592 };
593
594 if let Some(metric_name) = match key {
595 "Pages free" => Some("free_bytes"),
596 "Pages active" => Some("active_bytes"),
597 "Pages inactive" => Some("inactive_bytes"),
598 "Pages speculative" => Some("speculative_bytes"),
599 "Pages throttled" => Some("throttled_bytes"),
600 "Pages wired down" => Some("wired_bytes"),
601 "Pages purgeable" => Some("purgeable_bytes"),
602 "File-backed pages" => Some("file_backed_bytes"),
603 "Anonymous pages" => Some("anonymous_bytes"),
604 "Pages occupied by compressor" => Some("compressed_bytes"),
605 "Pages stored in compressor" => Some("compressed_store_bytes"),
606 _ => None,
607 } {
608 push_metric(
609 out,
610 "memory",
611 metric_name,
612 value * page_size,
613 "bytes",
614 "vm_stat",
615 );
616 continue;
617 }
618
619 if let Some(metric_name) = match key {
620 "Translation faults" => Some("translation_faults_total"),
621 "Pageins" => Some("pageins_total"),
622 "Pageouts" => Some("pageouts_total"),
623 "Swapins" => Some("swapins_total"),
624 "Swapouts" => Some("swapouts_total"),
625 "Cow faults" => Some("cow_faults_total"),
626 "Reactivations" => Some("reactivations_total"),
627 "Compressions" => Some("compressions_total"),
628 "Decompressions" => Some("decompressions_total"),
629 "Zero fill pages" => Some("zero_fill_pages_total"),
630 "Purgeable count" => Some("purgeable_count_total"),
631 _ => None,
632 } {
633 push_metric(out, "vm", metric_name, value, "count", "vm_stat");
634 }
635 }
636}
637
638#[cfg(target_os = "macos")]
639fn collect_macos_network_metrics(out: &mut Vec<TelemetryMetric>) {
640 let Some(raw) = run_command("/usr/sbin/netstat", &["-ibn"]) else {
641 return;
642 };
643 let mut interface_count = 0.0;
644 let mut rx_bytes = 0.0;
645 let mut tx_bytes = 0.0;
646 let mut rx_packets = 0.0;
647 let mut tx_packets = 0.0;
648 let mut rx_errors = 0.0;
649 let mut tx_errors = 0.0;
650
651 for line in raw.lines() {
652 let cols: Vec<&str> = line.split_whitespace().collect();
653 if cols.is_empty() || cols[0] == "Name" || cols.len() < 8 {
654 continue;
655 }
656 let name = cols[0];
657 if name.starts_with("lo") {
658 continue;
659 }
660
661 let ibytes = cols.iter().rev().nth(1).and_then(|s| s.parse::<f64>().ok());
663 let obytes = cols.iter().next_back().and_then(|s| s.parse::<f64>().ok());
664 let ipkts = cols.get(4).and_then(|s| s.parse::<f64>().ok());
665 let opkts = cols.get(6).and_then(|s| s.parse::<f64>().ok());
666 let ierrs = cols.get(5).and_then(|s| s.parse::<f64>().ok());
667 let oerrs = cols.get(7).and_then(|s| s.parse::<f64>().ok());
668
669 if let (Some(ibytes), Some(obytes)) = (ibytes, obytes) {
670 interface_count += 1.0;
671 rx_bytes += ibytes;
672 tx_bytes += obytes;
673 if let Some(v) = ipkts {
674 rx_packets += v;
675 }
676 if let Some(v) = opkts {
677 tx_packets += v;
678 }
679 if let Some(v) = ierrs {
680 rx_errors += v;
681 }
682 if let Some(v) = oerrs {
683 tx_errors += v;
684 }
685 }
686 }
687
688 if interface_count <= 0.0 {
689 return;
690 }
691 push_metric(
692 out,
693 "network",
694 "interface_rows_non_loopback",
695 interface_count,
696 "count",
697 "netstat",
698 );
699 push_metric(
700 out,
701 "network",
702 "rx_bytes_total",
703 rx_bytes,
704 "bytes",
705 "netstat",
706 );
707 push_metric(
708 out,
709 "network",
710 "tx_bytes_total",
711 tx_bytes,
712 "bytes",
713 "netstat",
714 );
715 push_metric(
716 out,
717 "network",
718 "rx_packets_total",
719 rx_packets,
720 "count",
721 "netstat",
722 );
723 push_metric(
724 out,
725 "network",
726 "tx_packets_total",
727 tx_packets,
728 "count",
729 "netstat",
730 );
731 push_metric(
732 out,
733 "network",
734 "rx_errors_total",
735 rx_errors,
736 "count",
737 "netstat",
738 );
739 push_metric(
740 out,
741 "network",
742 "tx_errors_total",
743 tx_errors,
744 "count",
745 "netstat",
746 );
747}
748
749fn collect_loadavg() -> (Option<f64>, Option<f64>, Option<f64>) {
750 #[cfg(unix)]
751 {
752 let mut values = [0.0_f64; 3];
753 let n = unsafe { libc::getloadavg(values.as_mut_ptr(), 3) };
755 if n <= 0 {
756 (None, None, None)
757 } else {
758 (
759 Some(values[0]),
760 (n > 1).then_some(values[1]),
761 (n > 2).then_some(values[2]),
762 )
763 }
764 }
765 #[cfg(not(unix))]
766 {
767 (None, None, None)
768 }
769}
770
771#[cfg(target_os = "linux")]
772fn collect_linux_proc_metrics(out: &mut Vec<TelemetryMetric>) {
773 if let Some(uptime) = read_first_f64(Path::new("/proc/uptime")) {
774 push_metric(out, "system", "uptime_seconds", uptime, "s", "procfs");
775 }
776
777 if let Ok(loadavg) = std::fs::read_to_string("/proc/loadavg")
778 && let Some(tasks) = loadavg.split_whitespace().nth(3)
779 && let Some((running, total)) = tasks.split_once('/')
780 {
781 if let Ok(running) = running.parse::<f64>() {
782 push_metric(
783 out,
784 "scheduling",
785 "runnable_tasks",
786 running,
787 "count",
788 "procfs",
789 );
790 }
791 if let Ok(total) = total.parse::<f64>() {
792 push_metric(
793 out,
794 "scheduling",
795 "sched_entities",
796 total,
797 "count",
798 "procfs",
799 );
800 }
801 }
802
803 if let Ok(mem) = std::fs::read_to_string("/proc/meminfo") {
804 for line in mem.lines() {
805 let Some((key, rest)) = line.split_once(':') else {
806 continue;
807 };
808 let Some(raw_value) = rest
809 .split_whitespace()
810 .next()
811 .and_then(|v| v.parse::<f64>().ok())
812 else {
813 continue;
814 };
815 let parsed = match key {
816 "MemTotal" => Some(("total_bytes", raw_value * 1024.0, "bytes")),
817 "MemAvailable" => Some(("available_bytes", raw_value * 1024.0, "bytes")),
818 "MemFree" => Some(("free_bytes", raw_value * 1024.0, "bytes")),
819 "Buffers" => Some(("buffers_bytes", raw_value * 1024.0, "bytes")),
820 "Cached" => Some(("cached_bytes", raw_value * 1024.0, "bytes")),
821 "Active" => Some(("active_bytes", raw_value * 1024.0, "bytes")),
822 "Inactive" => Some(("inactive_bytes", raw_value * 1024.0, "bytes")),
823 "AnonPages" => Some(("anon_pages_bytes", raw_value * 1024.0, "bytes")),
824 "Mapped" => Some(("mapped_bytes", raw_value * 1024.0, "bytes")),
825 "Shmem" => Some(("shmem_bytes", raw_value * 1024.0, "bytes")),
826 "Slab" => Some(("slab_bytes", raw_value * 1024.0, "bytes")),
827 "SReclaimable" => Some(("slab_reclaimable_bytes", raw_value * 1024.0, "bytes")),
828 "SUnreclaim" => Some(("slab_unreclaimable_bytes", raw_value * 1024.0, "bytes")),
829 "KernelStack" => Some(("kernel_stack_bytes", raw_value * 1024.0, "bytes")),
830 "PageTables" => Some(("page_tables_bytes", raw_value * 1024.0, "bytes")),
831 "Dirty" => Some(("dirty_bytes", raw_value * 1024.0, "bytes")),
832 "Writeback" => Some(("writeback_bytes", raw_value * 1024.0, "bytes")),
833 "SwapTotal" => Some(("swap_total_bytes", raw_value * 1024.0, "bytes")),
834 "SwapFree" => Some(("swap_free_bytes", raw_value * 1024.0, "bytes")),
835 "SwapCached" => Some(("swap_cached_bytes", raw_value * 1024.0, "bytes")),
836 "Committed_AS" => Some(("committed_as_bytes", raw_value * 1024.0, "bytes")),
837 "CommitLimit" => Some(("commit_limit_bytes", raw_value * 1024.0, "bytes")),
838 "HugePages_Total" => Some(("hugepages_total", raw_value, "count")),
839 "HugePages_Free" => Some(("hugepages_free", raw_value, "count")),
840 "HugePages_Rsvd" => Some(("hugepages_reserved", raw_value, "count")),
841 "Hugepagesize" => Some(("hugepage_size_bytes", raw_value * 1024.0, "bytes")),
842 _ => None,
843 };
844 if let Some((name, value, unit)) = parsed {
845 push_metric(out, "memory", name, value, unit, "procfs");
846 }
847 }
848 }
849}
850
851#[cfg(target_os = "linux")]
852fn collect_linux_proc_stat_metrics(out: &mut Vec<TelemetryMetric>) {
853 let Ok(raw) = std::fs::read_to_string("/proc/stat") else {
854 return;
855 };
856 let clk_tck = linux_clk_tck();
857
858 for line in raw.lines() {
859 if let Some(rest) = line.strip_prefix("cpu ") {
860 let parts: Vec<f64> = rest
861 .split_whitespace()
862 .filter_map(|s| s.parse::<f64>().ok())
863 .collect();
864 if parts.len() < 4 {
865 continue;
866 }
867 let user = parts.first().copied().unwrap_or(0.0) / clk_tck;
868 let nice = parts.get(1).copied().unwrap_or(0.0) / clk_tck;
869 let system = parts.get(2).copied().unwrap_or(0.0) / clk_tck;
870 let idle = parts.get(3).copied().unwrap_or(0.0) / clk_tck;
871 let iowait = parts.get(4).copied().unwrap_or(0.0) / clk_tck;
872 let irq = parts.get(5).copied().unwrap_or(0.0) / clk_tck;
873 let softirq = parts.get(6).copied().unwrap_or(0.0) / clk_tck;
874 let steal = parts.get(7).copied().unwrap_or(0.0) / clk_tck;
875 let guest = parts.get(8).copied().unwrap_or(0.0) / clk_tck;
876 let guest_nice = parts.get(9).copied().unwrap_or(0.0) / clk_tck;
877
878 push_metric(
879 out,
880 "scheduling",
881 "cpu_time_user_seconds",
882 user,
883 "s",
884 "procfs",
885 );
886 push_metric(
887 out,
888 "scheduling",
889 "cpu_time_nice_seconds",
890 nice,
891 "s",
892 "procfs",
893 );
894 push_metric(
895 out,
896 "scheduling",
897 "cpu_time_system_seconds",
898 system,
899 "s",
900 "procfs",
901 );
902 push_metric(
903 out,
904 "scheduling",
905 "cpu_time_idle_seconds",
906 idle,
907 "s",
908 "procfs",
909 );
910 push_metric(
911 out,
912 "scheduling",
913 "cpu_time_iowait_seconds",
914 iowait,
915 "s",
916 "procfs",
917 );
918 push_metric(
919 out,
920 "scheduling",
921 "cpu_time_irq_seconds",
922 irq,
923 "s",
924 "procfs",
925 );
926 push_metric(
927 out,
928 "scheduling",
929 "cpu_time_softirq_seconds",
930 softirq,
931 "s",
932 "procfs",
933 );
934 push_metric(
935 out,
936 "scheduling",
937 "cpu_time_steal_seconds",
938 steal,
939 "s",
940 "procfs",
941 );
942 push_metric(
943 out,
944 "scheduling",
945 "cpu_time_guest_seconds",
946 guest,
947 "s",
948 "procfs",
949 );
950 push_metric(
951 out,
952 "scheduling",
953 "cpu_time_guest_nice_seconds",
954 guest_nice,
955 "s",
956 "procfs",
957 );
958 push_metric(
959 out,
960 "scheduling",
961 "cpu_time_busy_seconds",
962 user + nice + system + irq + softirq + steal,
963 "s",
964 "procfs",
965 );
966 push_metric(
967 out,
968 "scheduling",
969 "cpu_time_idle_iowait_seconds",
970 idle + iowait,
971 "s",
972 "procfs",
973 );
974 continue;
975 }
976
977 if let Some(rest) = line.strip_prefix("ctxt ")
978 && let Ok(value) = rest.trim().parse::<f64>()
979 {
980 push_metric(
981 out,
982 "scheduling",
983 "context_switches_total",
984 value,
985 "count",
986 "procfs",
987 );
988 continue;
989 }
990 if let Some(rest) = line.strip_prefix("intr ")
991 && let Some(value) = rest
992 .split_whitespace()
993 .next()
994 .and_then(|s| s.parse::<f64>().ok())
995 {
996 push_metric(
997 out,
998 "scheduling",
999 "interrupts_total",
1000 value,
1001 "count",
1002 "procfs",
1003 );
1004 continue;
1005 }
1006 if let Some(rest) = line.strip_prefix("processes ")
1007 && let Ok(value) = rest.trim().parse::<f64>()
1008 {
1009 push_metric(
1010 out,
1011 "scheduling",
1012 "processes_forked_total",
1013 value,
1014 "count",
1015 "procfs",
1016 );
1017 continue;
1018 }
1019 if let Some(rest) = line.strip_prefix("procs_running ")
1020 && let Ok(value) = rest.trim().parse::<f64>()
1021 {
1022 push_metric(
1023 out,
1024 "scheduling",
1025 "processes_running",
1026 value,
1027 "count",
1028 "procfs",
1029 );
1030 continue;
1031 }
1032 if let Some(rest) = line.strip_prefix("procs_blocked ")
1033 && let Ok(value) = rest.trim().parse::<f64>()
1034 {
1035 push_metric(
1036 out,
1037 "scheduling",
1038 "processes_blocked",
1039 value,
1040 "count",
1041 "procfs",
1042 );
1043 continue;
1044 }
1045 if let Some(rest) = line.strip_prefix("btime ")
1046 && let Ok(value) = rest.trim().parse::<f64>()
1047 {
1048 push_metric(out, "system", "boot_unix_seconds", value, "s", "procfs");
1049 }
1050 }
1051}
1052
1053#[cfg(target_os = "linux")]
1054fn collect_linux_pressure_metrics(out: &mut Vec<TelemetryMetric>) {
1055 for resource in ["cpu", "io", "memory"] {
1056 let path = Path::new("/proc/pressure").join(resource);
1057 let Ok(raw) = std::fs::read_to_string(path) else {
1058 continue;
1059 };
1060 for line in raw.lines() {
1061 parse_psi_line(resource, line, out);
1062 }
1063 }
1064}
1065
1066#[cfg(target_os = "linux")]
1067fn collect_linux_entropy_metrics(out: &mut Vec<TelemetryMetric>) {
1068 if let Some(value) = read_first_f64(Path::new("/proc/sys/kernel/random/entropy_avail")) {
1069 push_metric(
1070 out,
1071 "entropy",
1072 "kernel_pool_available_bits",
1073 value,
1074 "bits",
1075 "procfs",
1076 );
1077 }
1078}
1079
1080#[cfg(target_os = "linux")]
1081fn collect_linux_network_metrics(out: &mut Vec<TelemetryMetric>) {
1082 let Ok(raw) = std::fs::read_to_string("/proc/net/dev") else {
1083 return;
1084 };
1085 let mut iface_count = 0.0;
1086 let mut rx_bytes = 0.0;
1087 let mut rx_packets = 0.0;
1088 let mut rx_errors = 0.0;
1089 let mut rx_drops = 0.0;
1090 let mut tx_bytes = 0.0;
1091 let mut tx_packets = 0.0;
1092 let mut tx_errors = 0.0;
1093 let mut tx_drops = 0.0;
1094
1095 let mut rx_bytes_non_lo = 0.0;
1096 let mut tx_bytes_non_lo = 0.0;
1097
1098 for line in raw.lines().skip(2) {
1099 let Some((iface_raw, stats_raw)) = line.split_once(':') else {
1100 continue;
1101 };
1102 let iface = iface_raw.trim();
1103 let fields: Vec<f64> = stats_raw
1104 .split_whitespace()
1105 .filter_map(|s| s.parse::<f64>().ok())
1106 .collect();
1107 if fields.len() < 16 {
1108 continue;
1109 }
1110 iface_count += 1.0;
1111 rx_bytes += fields[0];
1112 rx_packets += fields[1];
1113 rx_errors += fields[2];
1114 rx_drops += fields[3];
1115 tx_bytes += fields[8];
1116 tx_packets += fields[9];
1117 tx_errors += fields[10];
1118 tx_drops += fields[11];
1119 if iface != "lo" {
1120 rx_bytes_non_lo += fields[0];
1121 tx_bytes_non_lo += fields[8];
1122 }
1123 }
1124
1125 if iface_count <= 0.0 {
1126 return;
1127 }
1128 push_metric(
1129 out,
1130 "network",
1131 "interface_count",
1132 iface_count,
1133 "count",
1134 "procfs_netdev",
1135 );
1136 push_metric(
1137 out,
1138 "network",
1139 "rx_bytes_total",
1140 rx_bytes,
1141 "bytes",
1142 "procfs_netdev",
1143 );
1144 push_metric(
1145 out,
1146 "network",
1147 "tx_bytes_total",
1148 tx_bytes,
1149 "bytes",
1150 "procfs_netdev",
1151 );
1152 push_metric(
1153 out,
1154 "network",
1155 "rx_packets_total",
1156 rx_packets,
1157 "count",
1158 "procfs_netdev",
1159 );
1160 push_metric(
1161 out,
1162 "network",
1163 "tx_packets_total",
1164 tx_packets,
1165 "count",
1166 "procfs_netdev",
1167 );
1168 push_metric(
1169 out,
1170 "network",
1171 "rx_errors_total",
1172 rx_errors,
1173 "count",
1174 "procfs_netdev",
1175 );
1176 push_metric(
1177 out,
1178 "network",
1179 "tx_errors_total",
1180 tx_errors,
1181 "count",
1182 "procfs_netdev",
1183 );
1184 push_metric(
1185 out,
1186 "network",
1187 "rx_drops_total",
1188 rx_drops,
1189 "count",
1190 "procfs_netdev",
1191 );
1192 push_metric(
1193 out,
1194 "network",
1195 "tx_drops_total",
1196 tx_drops,
1197 "count",
1198 "procfs_netdev",
1199 );
1200 push_metric(
1201 out,
1202 "network",
1203 "rx_bytes_non_loopback_total",
1204 rx_bytes_non_lo,
1205 "bytes",
1206 "procfs_netdev",
1207 );
1208 push_metric(
1209 out,
1210 "network",
1211 "tx_bytes_non_loopback_total",
1212 tx_bytes_non_lo,
1213 "bytes",
1214 "procfs_netdev",
1215 );
1216}
1217
1218#[cfg(target_os = "linux")]
1219fn collect_linux_disk_metrics(out: &mut Vec<TelemetryMetric>) {
1220 let Ok(raw) = std::fs::read_to_string("/proc/diskstats") else {
1221 return;
1222 };
1223 let mut disk_count = 0.0;
1224 let mut read_ios = 0.0;
1225 let mut read_merged = 0.0;
1226 let mut read_sectors = 0.0;
1227 let mut read_time_ms = 0.0;
1228 let mut write_ios = 0.0;
1229 let mut write_merged = 0.0;
1230 let mut write_sectors = 0.0;
1231 let mut write_time_ms = 0.0;
1232 let mut io_in_progress = 0.0;
1233 let mut io_time_ms = 0.0;
1234 let mut weighted_io_time_ms = 0.0;
1235
1236 for line in raw.lines() {
1237 let parts: Vec<&str> = line.split_whitespace().collect();
1238 if parts.len() < 14 {
1239 continue;
1240 }
1241 let name = parts[2];
1242 if !is_likely_disk_device(name) {
1243 continue;
1244 }
1245 let parsed: Vec<f64> = parts[3..14]
1246 .iter()
1247 .filter_map(|v| v.parse::<f64>().ok())
1248 .collect();
1249 if parsed.len() < 11 {
1250 continue;
1251 }
1252 disk_count += 1.0;
1253 read_ios += parsed[0];
1254 read_merged += parsed[1];
1255 read_sectors += parsed[2];
1256 read_time_ms += parsed[3];
1257 write_ios += parsed[4];
1258 write_merged += parsed[5];
1259 write_sectors += parsed[6];
1260 write_time_ms += parsed[7];
1261 io_in_progress += parsed[8];
1262 io_time_ms += parsed[9];
1263 weighted_io_time_ms += parsed[10];
1264 }
1265
1266 if disk_count <= 0.0 {
1267 return;
1268 }
1269 push_metric(
1270 out,
1271 "disk",
1272 "device_count",
1273 disk_count,
1274 "count",
1275 "procfs_diskstats",
1276 );
1277 push_metric(
1278 out,
1279 "disk",
1280 "read_ios_total",
1281 read_ios,
1282 "count",
1283 "procfs_diskstats",
1284 );
1285 push_metric(
1286 out,
1287 "disk",
1288 "write_ios_total",
1289 write_ios,
1290 "count",
1291 "procfs_diskstats",
1292 );
1293 push_metric(
1294 out,
1295 "disk",
1296 "read_merged_total",
1297 read_merged,
1298 "count",
1299 "procfs_diskstats",
1300 );
1301 push_metric(
1302 out,
1303 "disk",
1304 "write_merged_total",
1305 write_merged,
1306 "count",
1307 "procfs_diskstats",
1308 );
1309 push_metric(
1310 out,
1311 "disk",
1312 "read_sectors_total",
1313 read_sectors,
1314 "sectors",
1315 "procfs_diskstats",
1316 );
1317 push_metric(
1318 out,
1319 "disk",
1320 "write_sectors_total",
1321 write_sectors,
1322 "sectors",
1323 "procfs_diskstats",
1324 );
1325 push_metric(
1326 out,
1327 "disk",
1328 "read_time_ms_total",
1329 read_time_ms,
1330 "ms",
1331 "procfs_diskstats",
1332 );
1333 push_metric(
1334 out,
1335 "disk",
1336 "write_time_ms_total",
1337 write_time_ms,
1338 "ms",
1339 "procfs_diskstats",
1340 );
1341 push_metric(
1342 out,
1343 "disk",
1344 "io_in_progress_total",
1345 io_in_progress,
1346 "count",
1347 "procfs_diskstats",
1348 );
1349 push_metric(
1350 out,
1351 "disk",
1352 "io_time_ms_total",
1353 io_time_ms,
1354 "ms",
1355 "procfs_diskstats",
1356 );
1357 push_metric(
1358 out,
1359 "disk",
1360 "weighted_io_time_ms_total",
1361 weighted_io_time_ms,
1362 "ms",
1363 "procfs_diskstats",
1364 );
1365}
1366
1367#[cfg(target_os = "linux")]
1368fn collect_linux_power_supply_metrics(out: &mut Vec<TelemetryMetric>) {
1369 let root = Path::new("/sys/class/power_supply");
1370 let Ok(entries) = std::fs::read_dir(root) else {
1371 return;
1372 };
1373
1374 for entry in entries.flatten() {
1375 let dir = entry.path();
1376 if !dir.is_dir() {
1377 continue;
1378 }
1379 let Some(raw_name) = dir.file_name().and_then(|s| s.to_str()) else {
1380 continue;
1381 };
1382 let name = normalize_key(raw_name);
1383 if name.is_empty() {
1384 continue;
1385 }
1386
1387 if let Some(v) = read_first_f64(&dir.join("online")) {
1388 push_metric(
1389 out,
1390 "power",
1391 format!("{name}.is_online"),
1392 v,
1393 "bool",
1394 "linux_power_supply",
1395 );
1396 }
1397 if let Some(v) = read_first_f64(&dir.join("present")) {
1398 push_metric(
1399 out,
1400 "power",
1401 format!("{name}.is_present"),
1402 v,
1403 "bool",
1404 "linux_power_supply",
1405 );
1406 }
1407 if let Some(v) = read_first_f64(&dir.join("capacity")) {
1408 push_metric(
1409 out,
1410 "power",
1411 format!("{name}.capacity_percent"),
1412 v,
1413 "percent",
1414 "linux_power_supply",
1415 );
1416 }
1417 parse_microunit_supply(out, &dir, &name, "voltage_now", "V", 1_000_000.0);
1418 parse_microunit_supply(out, &dir, &name, "current_now", "A", 1_000_000.0);
1419 parse_microunit_supply(out, &dir, &name, "power_now", "W", 1_000_000.0);
1420 parse_microunit_supply(out, &dir, &name, "energy_now", "Wh", 1_000_000.0);
1421 parse_microunit_supply(out, &dir, &name, "energy_full", "Wh", 1_000_000.0);
1422 parse_microunit_supply(out, &dir, &name, "energy_full_design", "Wh", 1_000_000.0);
1423 parse_microunit_supply(out, &dir, &name, "charge_now", "Ah", 1_000_000.0);
1424 parse_microunit_supply(out, &dir, &name, "charge_full", "Ah", 1_000_000.0);
1425 parse_microunit_supply(out, &dir, &name, "charge_full_design", "Ah", 1_000_000.0);
1426
1427 if let Some(v) = read_first_f64(&dir.join("temp")) {
1428 push_metric(
1430 out,
1431 "thermal",
1432 format!("{name}.temperature_c"),
1433 v / 10.0,
1434 "C",
1435 "linux_power_supply",
1436 );
1437 }
1438 parse_power_supply_state(out, &dir, &name);
1439 }
1440}
1441
1442#[cfg(target_os = "linux")]
1443fn collect_linux_freq_metrics(out: &mut Vec<TelemetryMetric>) {
1444 let root = Path::new("/sys/devices/system/cpu");
1445 let mut values_hz: Vec<(usize, f64)> = Vec::new();
1446 if let Ok(entries) = std::fs::read_dir(root) {
1447 for entry in entries.flatten() {
1448 let path = entry.path();
1449 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
1450 continue;
1451 };
1452 if !name.starts_with("cpu") {
1453 continue;
1454 }
1455 let Some(cpu_id) = name
1456 .strip_prefix("cpu")
1457 .and_then(|s| s.parse::<usize>().ok())
1458 else {
1459 continue;
1460 };
1461 let cpufreq_dir = path.join("cpufreq");
1462 if !cpufreq_dir.is_dir() {
1463 continue;
1464 }
1465 for key in ["scaling_cur_freq", "cpuinfo_cur_freq"] {
1466 if let Some(khz) = read_first_f64(&cpufreq_dir.join(key)) {
1467 values_hz.push((cpu_id, khz * 1000.0));
1468 break;
1469 }
1470 }
1471 }
1472 }
1473
1474 if values_hz.is_empty() {
1475 return;
1476 }
1477 values_hz.sort_by_key(|(id, _)| *id);
1478
1479 if let Some((_, cpu0_hz)) = values_hz
1480 .iter()
1481 .find(|(id, _)| *id == 0)
1482 .or_else(|| values_hz.first())
1483 {
1484 push_metric(out, "frequency", "cpu0_hz", *cpu0_hz, "Hz", "cpufreq");
1485 }
1486
1487 let mut min_hz = f64::INFINITY;
1488 let mut max_hz: f64 = 0.0;
1489 let mut sum_hz = 0.0;
1490 for (_, hz) in &values_hz {
1491 min_hz = min_hz.min(*hz);
1492 max_hz = max_hz.max(*hz);
1493 sum_hz += *hz;
1494 }
1495 let avg_hz = sum_hz / values_hz.len() as f64;
1496 push_metric(out, "frequency", "cpu_hz_avg", avg_hz, "Hz", "cpufreq");
1497 push_metric(out, "frequency", "cpu_hz_min", min_hz, "Hz", "cpufreq");
1498 push_metric(out, "frequency", "cpu_hz_max", max_hz, "Hz", "cpufreq");
1499 push_metric(
1500 out,
1501 "frequency",
1502 "cpu_hz_sampled_cores",
1503 values_hz.len() as f64,
1504 "count",
1505 "cpufreq",
1506 );
1507}
1508
1509#[cfg(target_os = "linux")]
1510fn collect_linux_hwmon_metrics(out: &mut Vec<TelemetryMetric>) {
1511 let root = Path::new("/sys/class/hwmon");
1512 let Ok(entries) = std::fs::read_dir(root) else {
1513 return;
1514 };
1515
1516 for entry in entries.flatten() {
1517 let dir = entry.path();
1518 if !dir.is_dir() {
1519 continue;
1520 }
1521 let chip = read_trimmed(&dir.join("name"))
1522 .map(|s| normalize_key(&s))
1523 .unwrap_or_else(|| {
1524 normalize_key(
1525 dir.file_name()
1526 .and_then(|s| s.to_str())
1527 .unwrap_or("unknown_hwmon"),
1528 )
1529 });
1530
1531 let Ok(files) = std::fs::read_dir(&dir) else {
1532 continue;
1533 };
1534 for file in files.flatten() {
1535 let path = file.path();
1536 let Some(name_os) = path.file_name() else {
1537 continue;
1538 };
1539 let fname = name_os.to_string_lossy();
1540 if !fname.ends_with("_input") {
1541 continue;
1542 }
1543 let Some(raw) = read_trimmed(&path).and_then(|s| s.parse::<f64>().ok()) else {
1544 continue;
1545 };
1546
1547 let label_path = dir.join(fname.replace("_input", "_label"));
1548 let label = read_trimmed(&label_path)
1549 .map(|s| normalize_key(&s))
1550 .unwrap_or_else(|| normalize_key(fname.trim_end_matches("_input")));
1551 let metric_key = format!("{chip}.{label}");
1552
1553 if fname.starts_with("temp") {
1554 push_metric(out, "thermal", metric_key, raw / 1000.0, "C", "linux_hwmon");
1555 } else if fname.starts_with("in") {
1556 push_metric(out, "voltage", metric_key, raw / 1000.0, "V", "linux_hwmon");
1557 } else if fname.starts_with("curr") {
1558 push_metric(out, "current", metric_key, raw / 1000.0, "A", "linux_hwmon");
1559 } else if fname.starts_with("power") {
1560 push_metric(
1561 out,
1562 "power",
1563 metric_key,
1564 raw / 1_000_000.0,
1565 "W",
1566 "linux_hwmon",
1567 );
1568 } else if fname.starts_with("fan") {
1569 push_metric(out, "cooling", metric_key, raw, "rpm", "linux_hwmon");
1570 }
1571 }
1572 }
1573}
1574
1575#[cfg(target_os = "macos")]
1576fn collect_macos_metrics(out: &mut Vec<TelemetryMetric>) {
1577 collect_macos_uptime_metrics(out);
1578 collect_macos_sysctl_metrics(out);
1579 collect_macos_cp_time_metrics(out);
1580 collect_macos_vm_stat_metrics(out);
1581 collect_macos_network_metrics(out);
1582}
1583
1584pub fn collect_telemetry_snapshot() -> TelemetrySnapshot {
1586 let (load1, load5, load15) = collect_loadavg();
1587 let mut metrics = Vec::new();
1588
1589 #[cfg(target_os = "linux")]
1590 {
1591 collect_linux_proc_metrics(&mut metrics);
1592 collect_linux_proc_stat_metrics(&mut metrics);
1593 collect_linux_pressure_metrics(&mut metrics);
1594 collect_linux_entropy_metrics(&mut metrics);
1595 collect_linux_network_metrics(&mut metrics);
1596 collect_linux_disk_metrics(&mut metrics);
1597 collect_linux_power_supply_metrics(&mut metrics);
1598 collect_linux_freq_metrics(&mut metrics);
1599 collect_linux_hwmon_metrics(&mut metrics);
1600 }
1601 #[cfg(target_os = "macos")]
1602 {
1603 collect_macos_metrics(&mut metrics);
1604 }
1605
1606 metrics.sort_by(|a, b| {
1607 a.domain
1608 .cmp(&b.domain)
1609 .then(a.name.cmp(&b.name))
1610 .then(a.source.cmp(&b.source))
1611 .then(a.unit.cmp(&b.unit))
1612 });
1613
1614 TelemetrySnapshot {
1615 model_id: MODEL_ID.to_string(),
1616 model_version: MODEL_VERSION,
1617 collected_unix_ms: unix_ms_now(),
1618 os: std::env::consts::OS.to_string(),
1619 arch: std::env::consts::ARCH.to_string(),
1620 cpu_count: std::thread::available_parallelism()
1621 .map(std::num::NonZero::get)
1622 .unwrap_or(1),
1623 loadavg_1m: load1,
1624 loadavg_5m: load5,
1625 loadavg_15m: load15,
1626 metrics,
1627 }
1628}
1629
1630fn delta_key(metric: &TelemetryMetric) -> String {
1631 format!(
1632 "{}\u{1f}{}\u{1f}{}\u{1f}{}",
1633 metric.domain, metric.name, metric.unit, metric.source
1634 )
1635}
1636
1637pub fn build_telemetry_window(
1639 start: TelemetrySnapshot,
1640 end: TelemetrySnapshot,
1641) -> TelemetryWindowReport {
1642 let end_map: HashMap<String, &TelemetryMetric> =
1643 end.metrics.iter().map(|m| (delta_key(m), m)).collect();
1644 let mut deltas = Vec::new();
1645
1646 for sm in &start.metrics {
1647 if let Some(em) = end_map.get(&delta_key(sm)) {
1648 deltas.push(TelemetryMetricDelta {
1649 domain: sm.domain.clone(),
1650 name: sm.name.clone(),
1651 unit: sm.unit.clone(),
1652 source: sm.source.clone(),
1653 start_value: sm.value,
1654 end_value: em.value,
1655 delta_value: em.value - sm.value,
1656 });
1657 }
1658 }
1659
1660 deltas.sort_by(|a, b| {
1661 a.domain
1662 .cmp(&b.domain)
1663 .then(a.name.cmp(&b.name))
1664 .then(a.source.cmp(&b.source))
1665 });
1666
1667 TelemetryWindowReport {
1668 model_id: MODEL_ID.to_string(),
1669 model_version: MODEL_VERSION,
1670 elapsed_ms: end
1671 .collected_unix_ms
1672 .saturating_sub(start.collected_unix_ms),
1673 start,
1674 end,
1675 deltas,
1676 }
1677}
1678
1679pub fn collect_telemetry_window(start: TelemetrySnapshot) -> TelemetryWindowReport {
1681 let end = collect_telemetry_snapshot();
1682 build_telemetry_window(start, end)
1683}
1684
1685#[cfg(test)]
1686mod tests {
1687 use super::*;
1688
1689 #[test]
1690 fn snapshot_has_identity() {
1691 let s = collect_telemetry_snapshot();
1692 assert_eq!(s.model_id, MODEL_ID);
1693 assert_eq!(s.model_version, MODEL_VERSION);
1694 assert!(s.collected_unix_ms > 0);
1695 assert!(s.cpu_count >= 1);
1696 }
1697
1698 #[test]
1699 fn window_delta_aligns_metrics() {
1700 let start = TelemetrySnapshot {
1701 model_id: MODEL_ID.to_string(),
1702 model_version: MODEL_VERSION,
1703 collected_unix_ms: 1000,
1704 os: "test".to_string(),
1705 arch: "test".to_string(),
1706 cpu_count: 1,
1707 loadavg_1m: None,
1708 loadavg_5m: None,
1709 loadavg_15m: None,
1710 metrics: vec![TelemetryMetric {
1711 domain: "memory".to_string(),
1712 name: "free_bytes".to_string(),
1713 value: 100.0,
1714 unit: "bytes".to_string(),
1715 source: "test".to_string(),
1716 }],
1717 };
1718 let mut end = start.clone();
1719 end.collected_unix_ms = 1500;
1720 end.metrics[0].value = 85.0;
1721 let w = build_telemetry_window(start, end);
1722 assert_eq!(w.elapsed_ms, 500);
1723 assert_eq!(w.deltas.len(), 1);
1724 assert!((w.deltas[0].delta_value + 15.0).abs() < 1e-9);
1725 }
1726
1727 #[test]
1728 fn window_delta_keeps_distinct_sources() {
1729 let start = TelemetrySnapshot {
1730 model_id: MODEL_ID.to_string(),
1731 model_version: MODEL_VERSION,
1732 collected_unix_ms: 1000,
1733 os: "test".to_string(),
1734 arch: "test".to_string(),
1735 cpu_count: 1,
1736 loadavg_1m: None,
1737 loadavg_5m: None,
1738 loadavg_15m: None,
1739 metrics: vec![
1740 TelemetryMetric {
1741 domain: "thermal".to_string(),
1742 name: "sensor".to_string(),
1743 value: 40.0,
1744 unit: "C".to_string(),
1745 source: "a".to_string(),
1746 },
1747 TelemetryMetric {
1748 domain: "thermal".to_string(),
1749 name: "sensor".to_string(),
1750 value: 50.0,
1751 unit: "C".to_string(),
1752 source: "b".to_string(),
1753 },
1754 ],
1755 };
1756 let mut end = start.clone();
1757 end.collected_unix_ms = 1200;
1758 end.metrics[0].value = 42.0;
1759 end.metrics[1].value = 52.0;
1760 let w = build_telemetry_window(start, end);
1761 assert_eq!(w.deltas.len(), 2);
1762 assert!(
1763 w.deltas
1764 .iter()
1765 .any(|d| d.source == "a" && (d.delta_value - 2.0).abs() < 1e-9)
1766 );
1767 assert!(
1768 w.deltas
1769 .iter()
1770 .any(|d| d.source == "b" && (d.delta_value - 2.0).abs() < 1e-9)
1771 );
1772 }
1773}