grafos_profile/
recording.rs

1//! Profile recording — ordered sequence of [`ResourceSpan`] records from a program run.
2
3use alloc::string::String;
4use alloc::vec::Vec;
5
6use grafos_observe::span::ResourceSpan;
7
8use crate::span_json::SpanJson;
9
10/// Header metadata for a profile recording file.
11#[derive(Debug, Clone)]
12#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))]
13pub struct RecordingHeader {
14    /// Format identifier.
15    pub format: String,
16    /// Program name (if known).
17    pub program: Option<String>,
18    /// Total recording duration in microseconds.
19    pub duration_us: Option<u64>,
20    /// Timestamp when recording started (Unix microseconds).
21    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/// An ordered sequence of [`ResourceSpan`] records from a program run.
36///
37/// Recordings can be created from in-memory spans or loaded from
38/// newline-delimited JSON files (when the `std` feature is enabled).
39#[derive(Debug, Clone)]
40pub struct ProfileRecording {
41    /// Header metadata.
42    pub header: RecordingHeader,
43    /// Ordered span records.
44    pub spans: Vec<ResourceSpan>,
45}
46
47impl ProfileRecording {
48    /// Create a recording from an in-memory span vector.
49    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    /// Save the recording to a file as newline-delimited JSON.
73    ///
74    /// First line is the header; subsequent lines are serialized [`SpanJson`] objects.
75    #[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    /// Load a recording from a newline-delimited JSON file.
98    ///
99    /// First line is parsed as header; subsequent lines as [`SpanJson`] objects.
100    #[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)); // 3000 - 1000
166        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}