grafos_profile/
recording.rs1use alloc::string::String;
4use alloc::vec::Vec;
5
6use grafos_observe::span::ResourceSpan;
7
8use crate::span_json::SpanJson;
9
10#[derive(Debug, Clone)]
12#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))]
13pub struct RecordingHeader {
14 pub format: String,
16 pub program: Option<String>,
18 pub duration_us: Option<u64>,
20 pub recorded_at: Option<u64>,
22}
23
24impl Default for RecordingHeader {
25 fn default() -> Self {
26 RecordingHeader {
27 format: String::from("grafos-profile-v1"),
28 program: None,
29 duration_us: None,
30 recorded_at: None,
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
40pub struct ProfileRecording {
41 pub header: RecordingHeader,
43 pub spans: Vec<ResourceSpan>,
45}
46
47impl ProfileRecording {
48 pub fn from_spans(spans: Vec<ResourceSpan>) -> Self {
50 let duration_us = if spans.is_empty() {
51 None
52 } else {
53 let min_start = spans
54 .iter()
55 .map(|s| s.start_time_unix_us)
56 .min()
57 .unwrap_or(0);
58 let max_end = spans.iter().map(|s| s.end_time_unix_us).max().unwrap_or(0);
59 Some(max_end.saturating_sub(min_start))
60 };
61
62 ProfileRecording {
63 header: RecordingHeader {
64 duration_us,
65 recorded_at: spans.first().map(|s| s.start_time_unix_us),
66 ..RecordingHeader::default()
67 },
68 spans,
69 }
70 }
71
72 #[cfg(feature = "std")]
76 pub fn save(&self, path: &str) -> Result<(), String> {
77 use std::io::Write;
78
79 let file = std::fs::File::create(path).map_err(|e| alloc::format!("create: {e}"))?;
80 let mut writer = std::io::BufWriter::new(file);
81
82 let header_json =
83 serde_json::to_string(&self.header).map_err(|e| alloc::format!("header: {e}"))?;
84 writeln!(writer, "{}", header_json).map_err(|e| alloc::format!("write header: {e}"))?;
85
86 for span in &self.spans {
87 let span_json = SpanJson::from_span(span);
88 let line =
89 serde_json::to_string(&span_json).map_err(|e| alloc::format!("span: {e}"))?;
90 writeln!(writer, "{}", line).map_err(|e| alloc::format!("write span: {e}"))?;
91 }
92
93 writer.flush().map_err(|e| alloc::format!("flush: {e}"))?;
94 Ok(())
95 }
96
97 #[cfg(feature = "std")]
101 pub fn load(path: &str) -> Result<Self, String> {
102 use std::io::BufRead;
103
104 let file = std::fs::File::open(path).map_err(|e| alloc::format!("open: {e}"))?;
105 let reader = std::io::BufReader::new(file);
106 let mut lines = reader.lines();
107
108 let header_line = lines
109 .next()
110 .ok_or_else(|| String::from("empty file"))?
111 .map_err(|e| alloc::format!("read header: {e}"))?;
112
113 let header: RecordingHeader =
114 serde_json::from_str(&header_line).map_err(|e| alloc::format!("parse header: {e}"))?;
115
116 let mut spans = Vec::new();
117 for line_result in lines {
118 let line = line_result.map_err(|e| alloc::format!("read: {e}"))?;
119 if line.trim().is_empty() {
120 continue;
121 }
122 let span_json: SpanJson =
123 serde_json::from_str(&line).map_err(|e| alloc::format!("parse span: {e}"))?;
124 spans.push(span_json.to_span());
125 }
126
127 Ok(ProfileRecording { header, spans })
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use grafos_observe::trace::TraceContext;
135
136 fn test_ctx() -> TraceContext {
137 let mut bytes = [0u8; 24];
138 for (i, b) in bytes.iter_mut().enumerate() {
139 *b = (i as u8).wrapping_add(0x42);
140 }
141 TraceContext::new_root(&bytes)
142 }
143
144 #[test]
145 fn from_spans_empty() {
146 let rec = ProfileRecording::from_spans(Vec::new());
147 assert!(rec.spans.is_empty());
148 assert_eq!(rec.header.format, "grafos-profile-v1");
149 assert!(rec.header.duration_us.is_none());
150 }
151
152 #[test]
153 fn from_spans_computes_duration() {
154 let ctx = test_ctx();
155 let mut s1 = ResourceSpan::new("a", ctx);
156 s1.start_time_unix_us = 1000;
157 s1.end_time_unix_us = 2000;
158
159 let child_ctx = ctx.child(&[0xAA; 8]);
160 let mut s2 = ResourceSpan::new("b", child_ctx);
161 s2.start_time_unix_us = 1500;
162 s2.end_time_unix_us = 3000;
163
164 let rec = ProfileRecording::from_spans(vec![s1, s2]);
165 assert_eq!(rec.header.duration_us, Some(2000)); assert_eq!(rec.header.recorded_at, Some(1000));
167 }
168
169 #[cfg(feature = "std")]
170 #[test]
171 fn save_load_roundtrip() {
172 let ctx = test_ctx();
173 let mut span = ResourceSpan::new("roundtrip", ctx);
174 span.start_time_unix_us = 100;
175 span.end_time_unix_us = 200;
176 span.bytes_read = 4096;
177 span.add_lease_id(42);
178
179 let rec = ProfileRecording::from_spans(vec![span]);
180
181 let dir = std::env::temp_dir();
182 let path = dir.join("grafos_profile_test.ndjson");
183 let path_str = path.to_str().unwrap();
184
185 rec.save(path_str).unwrap();
186 let loaded = ProfileRecording::load(path_str).unwrap();
187
188 assert_eq!(loaded.header.format, "grafos-profile-v1");
189 assert_eq!(loaded.spans.len(), 1);
190 assert_eq!(loaded.spans[0].name, "roundtrip");
191 assert_eq!(loaded.spans[0].bytes_read, 4096);
192 assert_eq!(loaded.spans[0].lease_ids, vec![42u128]);
193
194 let _ = std::fs::remove_file(path_str);
195 }
196}