1#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7pub struct Bytes {
8 pub written: u64,
9 pub logical: u64,
10 pub read: u64,
11}
12
13impl Bytes {
14 pub fn capture() -> Option<Self> {
15 #[cfg(target_os = "macos")]
16 {
17 let mut usage = std::mem::MaybeUninit::<libc::rusage_info_v4>::zeroed();
18 let rc = unsafe {
20 libc::proc_pid_rusage(
21 libc::getpid(),
22 libc::RUSAGE_INFO_V4,
23 usage.as_mut_ptr().cast(),
24 )
25 };
26 if rc != 0 {
27 return None;
28 }
29 let usage = unsafe { usage.assume_init() };
30 Some(Self {
31 written: usage.ri_diskio_byteswritten,
32 logical: usage.ri_logical_writes,
33 read: usage.ri_diskio_bytesread,
34 })
35 }
36 #[cfg(target_os = "linux")]
37 {
38 read_proc_self_io()
39 }
40 #[cfg(target_os = "windows")]
41 {
42 None
46 }
47 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
48 {
49 None
50 }
51 }
52
53 pub fn delta(self, before: Self) -> Option<Self> {
54 Some(Self {
55 written: self.written.checked_sub(before.written)?,
56 logical: self.logical.checked_sub(before.logical)?,
57 read: self.read.checked_sub(before.read)?,
58 })
59 }
60
61 pub fn add(self, other: Self) -> Self {
62 Self {
63 written: self.written + other.written,
64 logical: self.logical + other.logical,
65 read: self.read + other.read,
66 }
67 }
68
69 pub fn fields(value: Option<Self>, prefix: &str) -> String {
70 match value {
71 Some(v) => format!(
72 "{prefix}_physical_bytes_written={} {prefix}_logical_bytes_written={} {prefix}_bytes_read={}",
73 v.written, v.logical, v.read
74 ),
75 None => format!(
76 "{prefix}_physical_bytes_written=unknown {prefix}_logical_bytes_written=unknown {prefix}_bytes_read=unknown"
77 ),
78 }
79 }
80}
81
82#[cfg(target_os = "linux")]
83fn read_proc_self_io() -> Option<Bytes> {
84 let content = std::fs::read_to_string("/proc/self/io").ok()?;
85 parse_proc_self_io(&content)
86}
87
88pub fn parse_proc_self_io(content: &str) -> Option<Bytes> {
93 let mut read_bytes = None;
94 let mut write_bytes = None;
95 let mut wchar = None;
96
97 for line in content.lines() {
98 if let Some((key, value)) = line.split_once(':') {
99 let key = key.trim();
100 let value = value.trim().parse::<u64>().ok();
101 match key {
102 "read_bytes" => read_bytes = value,
103 "write_bytes" => write_bytes = value,
104 "wchar" => wchar = value,
105 _ => {}
106 }
107 }
108 }
109
110 Some(Bytes {
111 read: read_bytes?,
112 written: write_bytes?,
113 logical: wchar?,
114 })
115}
116
117#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
118pub struct ProcessIoSnapshot {
119 pub available: bool,
120 pub sampled_at_ms: u64,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub diskio_bytes_read: Option<u64>,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub diskio_bytes_written: Option<u64>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub logical_bytes_written: Option<u64>,
127}
128
129impl ProcessIoSnapshot {
130 pub fn from_sample(sample: Option<Bytes>, sampled_at_ms: u64) -> Self {
131 match sample {
132 Some(bytes) => Self {
133 available: true,
134 sampled_at_ms,
135 diskio_bytes_read: Some(bytes.read),
136 diskio_bytes_written: Some(bytes.written),
137 logical_bytes_written: Some(bytes.logical),
138 },
139 None => Self {
140 available: false,
141 sampled_at_ms,
142 diskio_bytes_read: None,
143 diskio_bytes_written: None,
144 logical_bytes_written: None,
145 },
146 }
147 }
148
149 pub fn capture() -> Self {
150 let sampled_at_ms = std::time::SystemTime::now()
151 .duration_since(std::time::UNIX_EPOCH)
152 .map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64)
153 .unwrap_or(0);
154 Self::from_sample(Bytes::capture(), sampled_at_ms)
155 }
156
157 pub fn to_value(&self) -> serde_json::Value {
158 serde_json::to_value(self).expect("process_io serializes")
159 }
160}
161
162#[cfg(test)]
163#[cfg(target_os = "macos")]
164pub fn assert_darwin_logical_bytes_observe_file_write() {
165 use std::io::Write;
166 let dir = tempfile::tempdir().unwrap();
167 let before = Bytes::capture().unwrap();
168 let mut file = std::fs::File::create(dir.path().join("bytes")).unwrap();
169 file.write_all(&vec![0x5a; 8 * 1024 * 1024]).unwrap();
170 file.sync_all().unwrap();
171 let delta = Bytes::capture().unwrap().delta(before).unwrap();
172 assert!(
173 delta.logical >= 8 * 1024 * 1024,
174 "observed logical delta: {delta:?}"
175 );
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use serde_json::Value;
182
183 #[test]
184 fn process_io_snapshot_with_sample_includes_all_fields() {
185 let sample = Some(Bytes {
186 read: 1024,
187 written: 2048,
188 logical: 4096,
189 });
190 let snapshot = ProcessIoSnapshot::from_sample(sample, 42_000);
191 let value = snapshot.to_value();
192 assert_eq!(value["available"], Value::Bool(true));
193 assert_eq!(value["sampled_at_ms"], Value::from(42_000u64));
194 assert_eq!(value["diskio_bytes_read"], Value::from(1024u64));
195 assert_eq!(value["diskio_bytes_written"], Value::from(2048u64));
196 assert_eq!(value["logical_bytes_written"], Value::from(4096u64));
197 }
198
199 #[test]
200 fn process_io_snapshot_unavailable_omits_numbers() {
201 let snapshot = ProcessIoSnapshot::from_sample(None, 42_000);
202 let value = snapshot.to_value();
203 assert_eq!(value["available"], Value::Bool(false));
204 assert_eq!(value["sampled_at_ms"], Value::from(42_000u64));
205 assert!(
206 value.get("diskio_bytes_read").is_none(),
207 "diskio_bytes_read must be omitted when unavailable: {value}"
208 );
209 assert!(
210 value.get("diskio_bytes_written").is_none(),
211 "diskio_bytes_written must be omitted when unavailable: {value}"
212 );
213 assert!(
214 value.get("logical_bytes_written").is_none(),
215 "logical_bytes_written must be omitted when unavailable: {value}"
216 );
217 }
218
219 #[test]
220 fn linux_proc_self_io_parser_maps_counters() {
221 let sample = "rchar: 123456\n\
222 wchar: 987654\n\
223 syscr: 10\n\
224 syscw: 20\n\
225 read_bytes: 111111\n\
226 write_bytes: 222222\n\
227 cancelled_write_bytes: 0\n";
228 let bytes = parse_proc_self_io(sample).expect("parse proc self io");
229 assert_eq!(bytes.read, 111111);
230 assert_eq!(bytes.written, 222222);
231 assert_eq!(bytes.logical, 987654);
232 }
233
234 #[test]
235 fn linux_proc_self_io_parser_incomplete_returns_none() {
236 let sample = "rchar: 123456\n\
237 wchar: 987654\n\
238 syscr: 10\n\
239 syscw: 20\n\
240 read_bytes: 111111\n";
241 assert!(parse_proc_self_io(sample).is_none());
242 }
243
244 #[cfg(target_os = "macos")]
245 #[test]
246 fn darwin_logical_bytes_observe_file_write() {
247 assert_darwin_logical_bytes_observe_file_write();
248 }
249}