1use std::fmt::Debug;
39use std::path::Path;
40
41#[derive(Debug, Clone, Default, PartialEq)]
44pub struct TelemetrySample {
45 pub process_memory_gb: Option<f64>,
52
53 pub total_memory_gb: Option<f64>,
55
56 pub available_memory_gb: Option<f64>,
58
59 pub logical_cpus: Option<usize>,
61
62 pub load_average_per_cpu: Option<f64>,
65
66 pub process_count: Option<usize>,
68
69 pub gpu_devices: Option<usize>,
71
72 pub gpu_utilization: Option<f64>,
74
75 pub total_disk_gb: Option<f64>,
77
78 pub available_disk_gb: Option<f64>,
80
81 pub network_bandwidth_mbps: Option<f64>,
83
84 pub temperature_celsius: Option<f64>,
86
87 pub power_watts: Option<f64>,
89}
90
91impl TelemetrySample {
92 pub fn unknown() -> Self {
96 Self::default()
97 }
98
99 pub fn is_fully_unknown(&self) -> bool {
101 *self == Self::default()
102 }
103}
104
105pub trait TelemetrySource: Send + Sync + Debug {
112 fn name(&self) -> &str;
114
115 fn sample(&self) -> TelemetrySample;
117}
118
119#[derive(Debug, Clone, Default)]
122pub struct StdTelemetry;
123
124impl StdTelemetry {
125 pub fn new() -> Self {
127 Self
128 }
129}
130
131impl TelemetrySource for StdTelemetry {
132 fn name(&self) -> &str {
133 "std"
134 }
135
136 fn sample(&self) -> TelemetrySample {
137 let logical_cpus = std::thread::available_parallelism()
138 .ok()
139 .map(|count| count.get());
140
141 let process_memory_gb = read_to_string_if_present("/proc/self/status")
144 .and_then(|status| parse_vm_rss_kib(&status))
145 .map(|kib| kib as f64 / (1024.0 * 1024.0));
146
147 let (total_memory_gb, available_memory_gb) = match read_to_string_if_present(
148 "/proc/meminfo",
149 )
150 .and_then(|info| parse_meminfo(&info))
151 {
152 Some((total_kib, available_kib)) => (
153 Some(total_kib as f64 / (1024.0 * 1024.0)),
154 available_kib.map(|kib| kib as f64 / (1024.0 * 1024.0)),
155 ),
156 None => (None, None),
157 };
158
159 let load_average_per_cpu = read_to_string_if_present("/proc/loadavg")
160 .and_then(|loadavg| parse_loadavg(&loadavg))
161 .and_then(|load| logical_cpus.map(|cpus| load / cpus.max(1) as f64));
162
163 let process_count = count_proc_processes();
164
165 TelemetrySample {
166 process_memory_gb,
167 total_memory_gb,
168 available_memory_gb,
169 logical_cpus,
170 load_average_per_cpu,
171 process_count,
172 gpu_devices: None,
174 gpu_utilization: None,
175 total_disk_gb: None,
176 available_disk_gb: None,
177 network_bandwidth_mbps: None,
178 temperature_celsius: None,
179 power_watts: None,
180 }
181 }
182}
183
184fn read_to_string_if_present(path: &str) -> Option<String> {
187 let path = Path::new(path);
188 if !path.exists() {
189 return None;
190 }
191 std::fs::read_to_string(path).ok()
192}
193
194pub fn parse_vm_rss_kib(status: &str) -> Option<u64> {
199 for line in status.lines() {
200 let Some(rest) = line.strip_prefix("VmRSS:") else {
201 continue;
202 };
203 let mut fields = rest.split_whitespace();
204 let value: u64 = fields.next()?.parse().ok()?;
205 return match fields.next() {
208 Some(unit) if unit.eq_ignore_ascii_case("kb") => Some(value),
209 None => Some(value),
210 Some(_) => None,
211 };
212 }
213 None
214}
215
216pub fn parse_meminfo(meminfo: &str) -> Option<(u64, Option<u64>)> {
220 let mut total = None;
221 let mut available = None;
222 for line in meminfo.lines() {
223 let mut parts = line.split(':');
224 let key = parts.next()?.trim();
225 let Some(rest) = parts.next() else {
226 continue;
227 };
228 let Some(value) = rest.split_whitespace().next() else {
229 continue;
230 };
231 let Ok(parsed) = value.parse::<u64>() else {
232 continue;
233 };
234 match key {
235 "MemTotal" => total = Some(parsed),
236 "MemAvailable" => available = Some(parsed),
237 _ => {}
238 }
239 }
240 total.map(|total| (total, available))
241}
242
243pub fn parse_loadavg(loadavg: &str) -> Option<f64> {
245 loadavg
246 .split_whitespace()
247 .next()
248 .and_then(|field| field.parse::<f64>().ok())
249 .filter(|value| value.is_finite() && *value >= 0.0)
250}
251
252fn count_proc_processes() -> Option<usize> {
254 let entries = std::fs::read_dir("/proc").ok()?;
255 let count = entries
256 .filter_map(|entry| entry.ok())
257 .filter(|entry| {
258 entry
259 .file_name()
260 .to_str()
261 .map(|name| !name.is_empty() && name.bytes().all(|b| b.is_ascii_digit()))
262 .unwrap_or(false)
263 })
264 .count();
265 Some(count)
266}
267
268#[derive(Debug, Clone)]
271pub struct FixedTelemetry {
272 name: String,
273 sample: TelemetrySample,
274}
275
276impl FixedTelemetry {
277 pub fn new(name: impl Into<String>, sample: TelemetrySample) -> Self {
279 Self {
280 name: name.into(),
281 sample,
282 }
283 }
284}
285
286impl TelemetrySource for FixedTelemetry {
287 fn name(&self) -> &str {
288 &self.name
289 }
290
291 fn sample(&self) -> TelemetrySample {
292 self.sample.clone()
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 const STATUS_FIXTURE: &str = "Name:\tcargo\nUmask:\t0022\nState:\tR (running)\n\
301 VmPeak:\t 2097152 kB\nVmSize:\t 1048576 kB\n\
302 VmRSS:\t 524288 kB\nThreads:\t8\n";
303
304 const MEMINFO_FIXTURE: &str = "MemTotal: 32899072 kB\nMemFree: 1234567 kB\n\
305 MemAvailable: 20971520 kB\nBuffers: 123456 kB\n";
306
307 #[test]
308 fn vm_rss_is_parsed_from_the_status_fixture() {
309 assert_eq!(parse_vm_rss_kib(STATUS_FIXTURE), Some(524_288));
310 let gib: f64 = 524_288.0 / (1024.0 * 1024.0);
312 assert!((gib - 0.5).abs() < 1e-12);
313 }
314
315 #[test]
316 fn vm_rss_reports_unknown_rather_than_guessing() {
317 assert_eq!(parse_vm_rss_kib("Name:\tcargo\nThreads:\t8\n"), None);
318 assert_eq!(parse_vm_rss_kib(""), None);
319 assert_eq!(parse_vm_rss_kib("VmRSS:\t 512 pages\n"), None);
321 assert_eq!(parse_vm_rss_kib("VmRSS:\t 4096\n"), Some(4096));
323 assert_eq!(parse_vm_rss_kib("VmRSS:\tnot-a-number kB\n"), None);
324 }
325
326 #[test]
327 fn meminfo_is_parsed_from_the_fixture() {
328 let (total, available) = parse_meminfo(MEMINFO_FIXTURE).expect("MemTotal present");
329 assert_eq!(total, 32_899_072);
330 assert_eq!(available, Some(20_971_520));
331 }
332
333 #[test]
334 fn meminfo_tolerates_a_kernel_without_memavailable() {
335 let (total, available) =
336 parse_meminfo("MemTotal: 1048576 kB\nMemFree: 524288 kB\n").expect("MemTotal");
337 assert_eq!(total, 1_048_576);
338 assert_eq!(available, None);
339 assert_eq!(parse_meminfo("MemFree: 1 kB\n"), None);
340 assert_eq!(parse_meminfo(""), None);
341 }
342
343 #[test]
344 fn loadavg_is_parsed_and_validated() {
345 assert_eq!(parse_loadavg("0.52 0.58 0.59 2/1234 56789\n"), Some(0.52));
346 assert_eq!(parse_loadavg(""), None);
347 assert_eq!(parse_loadavg("garbage 0.1 0.2\n"), None);
348 assert_eq!(parse_loadavg("-1.0 0.1 0.2\n"), None);
349 }
350
351 #[test]
353 fn the_default_source_reports_only_measurable_values() {
354 let sample = StdTelemetry::new().sample();
355
356 assert!(
358 sample.logical_cpus.is_some_and(|cpus| cpus >= 1),
359 "available_parallelism must be reported"
360 );
361
362 assert_eq!(sample.gpu_devices, None, "the invented 4 GPUs must be gone");
365 assert_eq!(
366 sample.gpu_utilization, None,
367 "the invented 80% must be gone"
368 );
369 assert_eq!(sample.total_disk_gb, None, "the invented 1 TB must be gone");
370 assert_eq!(sample.available_disk_gb, None);
371 assert_eq!(
372 sample.network_bandwidth_mbps, None,
373 "the invented 1 GB/s must be gone"
374 );
375 assert_eq!(
376 sample.temperature_celsius, None,
377 "the invented 65 C must be gone"
378 );
379 assert_eq!(sample.power_watts, None, "the invented 250 W must be gone");
380
381 if let Some(total) = sample.total_memory_gb {
384 assert!(total > 0.0 && total.is_finite(), "got {total}");
385 assert_ne!(total, 32.0, "32.0 GB was the fabricated constant");
386 }
387 if let Some(process) = sample.process_memory_gb {
388 assert!(process > 0.0 && process.is_finite(), "got {process}");
389 assert_ne!(process, 16.0, "16.0 GB was the fabricated constant");
390 }
391 if let Some(load) = sample.load_average_per_cpu {
392 assert!(load >= 0.0 && load.is_finite());
393 }
394 }
395
396 #[test]
397 fn a_fixed_source_reports_exactly_what_it_was_given() {
398 let sample = TelemetrySample {
399 process_memory_gb: Some(3.5),
400 total_memory_gb: Some(64.0),
401 logical_cpus: Some(16),
402 temperature_celsius: Some(71.0),
403 ..TelemetrySample::unknown()
404 };
405 let source = FixedTelemetry::new("injected", sample.clone());
406 assert_eq!(source.name(), "injected");
407 assert_eq!(source.sample(), sample);
408 assert!(!sample.is_fully_unknown());
409 assert!(TelemetrySample::unknown().is_fully_unknown());
410 }
411}