Skip to main content

openentropy_core/
telemetry.rs

1//! Best-effort system telemetry snapshots for entropy benchmark context.
2//!
3//! `telemetry_v1` is intentionally operational:
4//! - works without elevated privileges where possible,
5//! - captures only values observable from user space,
6//! - leaves unavailable metrics as absent rather than guessing.
7
8use 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
20/// Telemetry model identifier.
21pub const MODEL_ID: &str = "telemetry_v1";
22/// Telemetry model version.
23pub const MODEL_VERSION: u32 = 1;
24
25/// A single observed telemetry metric.
26#[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/// Point-in-time system telemetry snapshot.
36#[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/// Delta for a metric observed in both start and end snapshots.
51#[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/// Start/end telemetry window with aligned metric deltas.
63#[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    // SAFETY: `sysconf` is thread-safe for this query and has no side effects.
148    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        // Most macOS netstat formats place these counters near the end.
662        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        // SAFETY: `getloadavg` writes up to `n` doubles to a valid buffer.
754        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            // power_supply temp is typically reported in 0.1 C.
1429            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
1584/// Capture a best-effort telemetry snapshot.
1585pub 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
1637/// Build a start/end telemetry report and aligned metric deltas.
1638pub 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
1679/// Capture the current end snapshot and compute a telemetry window.
1680pub 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}