async_inspect/export/
flamegraph.rs

1//! Flamegraph format exporter
2//!
3//! Exports async-inspect data to flamegraph folded stack format for visualization
4//! with tools like inferno, speedscope, or Brendan Gregg's flamegraph.pl
5//!
6//! Format: `stack1;stack2;stack3 count`
7//! Each line represents a call stack with semicolon-separated frames and a sample count
8
9use crate::inspector::Inspector;
10use crate::timeline::EventKind;
11use std::collections::HashMap;
12use std::fs::File;
13use std::io::{self, Write};
14use std::path::Path;
15
16/// Flamegraph format exporter
17pub struct FlamegraphExporter;
18
19impl FlamegraphExporter {
20    /// Export to flamegraph folded stack format file
21    pub fn export_to_file<P: AsRef<Path>>(inspector: &Inspector, path: P) -> io::Result<()> {
22        let folded = Self::generate_folded_stacks(inspector);
23        let mut file = File::create(path)?;
24        file.write_all(folded.as_bytes())?;
25        Ok(())
26    }
27
28    /// Export to flamegraph folded stack format string
29    #[must_use]
30    pub fn export_to_string(inspector: &Inspector) -> String {
31        Self::generate_folded_stacks(inspector)
32    }
33
34    /// Generate folded stack format from inspector data
35    fn generate_folded_stacks(inspector: &Inspector) -> String {
36        let mut output = String::new();
37        let events = inspector.get_events();
38
39        // Track call stacks and durations
40        let mut task_stacks: HashMap<u64, Vec<String>> = HashMap::new();
41        let mut stack_samples: HashMap<String, u64> = HashMap::new();
42
43        // Build stacks from events
44        for event in events {
45            let task_id = event.task_id.as_u64();
46
47            match &event.kind {
48                EventKind::TaskSpawned { name, parent, .. } => {
49                    // Initialize stack for this task
50                    let mut stack = Vec::new();
51
52                    // Add parent context if available
53                    if let Some(parent_id) = parent {
54                        if let Some(parent_stack) = task_stacks.get(&parent_id.as_u64()) {
55                            stack.extend(parent_stack.clone());
56                        }
57                    }
58
59                    // Add this task to the stack
60                    stack.push(Self::sanitize_frame_name(name));
61                    task_stacks.insert(task_id, stack);
62                }
63
64                EventKind::AwaitEnded {
65                    await_point,
66                    duration,
67                } => {
68                    if let Some(stack) = task_stacks.get_mut(&task_id) {
69                        // Add await point to stack
70                        stack.push(Self::sanitize_frame_name(await_point));
71
72                        // Record sample with duration
73                        let stack_str = stack.join(";");
74                        let duration_ms = duration.as_millis() as u64;
75
76                        *stack_samples.entry(stack_str).or_insert(0) += duration_ms;
77
78                        // Pop await point from stack
79                        stack.pop();
80                    }
81                }
82
83                EventKind::PollEnded { duration } => {
84                    if let Some(stack) = task_stacks.get(&task_id) {
85                        // Record poll time
86                        let mut poll_stack = stack.clone();
87                        poll_stack.push("poll".to_string());
88
89                        let stack_str = poll_stack.join(";");
90                        let duration_ms = duration.as_millis() as u64;
91
92                        *stack_samples.entry(stack_str).or_insert(0) += duration_ms;
93                    }
94                }
95
96                EventKind::TaskCompleted { duration } => {
97                    if let Some(stack) = task_stacks.get(&task_id) {
98                        // Record total task time
99                        let stack_str = stack.join(";");
100                        let duration_ms = duration.as_millis() as u64;
101
102                        *stack_samples.entry(stack_str).or_insert(0) += duration_ms;
103                    }
104                }
105
106                _ => {}
107            }
108        }
109
110        // Sort stacks by sample count (descending) for better visualization
111        let mut sorted_stacks: Vec<_> = stack_samples.into_iter().collect();
112        sorted_stacks.sort_by(|a, b| b.1.cmp(&a.1));
113
114        // Generate folded output
115        for (stack, count) in sorted_stacks {
116            if count > 0 {
117                output.push_str(&format!("{stack} {count}\n"));
118            }
119        }
120
121        output
122    }
123
124    /// Sanitize frame names for flamegraph format
125    /// Removes characters that could break the format
126    fn sanitize_frame_name(name: &str) -> String {
127        name.replace(';', ":")
128            .replace('\n', " ")
129            .replace('\r', "")
130            .trim()
131            .to_string()
132    }
133
134    /// Generate SVG flamegraph using inferno library (if available)
135    #[cfg(feature = "flamegraph")]
136    pub fn generate_svg<P: AsRef<Path>>(inspector: &Inspector, path: P) -> io::Result<()> {
137        use inferno::flamegraph::{self, Options};
138
139        let folded = Self::generate_folded_stacks(inspector);
140        let folded_bytes = folded.as_bytes();
141
142        let mut options = Options::default();
143        options.title = "async-inspect Flamegraph".to_string();
144        options.subtitle = Some("Async task execution time".to_string());
145
146        let mut svg_output = File::create(path)?;
147
148        flamegraph::from_reader(&mut options, folded_bytes, &mut svg_output)
149            .map_err(io::Error::other)?;
150
151        Ok(())
152    }
153}
154
155/// Builder for flamegraph export with customization options
156pub struct FlamegraphBuilder {
157    /// Whether to include poll events
158    include_polls: bool,
159    /// Whether to include await points
160    include_awaits: bool,
161    /// Minimum duration threshold in milliseconds
162    min_duration_ms: u64,
163}
164
165impl Default for FlamegraphBuilder {
166    fn default() -> Self {
167        Self {
168            include_polls: true,
169            include_awaits: true,
170            min_duration_ms: 0,
171        }
172    }
173}
174
175impl FlamegraphBuilder {
176    /// Create a new flamegraph builder
177    #[must_use]
178    pub fn new() -> Self {
179        Self::default()
180    }
181
182    /// Set whether to include poll events
183    #[must_use]
184    pub fn include_polls(mut self, include: bool) -> Self {
185        self.include_polls = include;
186        self
187    }
188
189    /// Set whether to include await points
190    #[must_use]
191    pub fn include_awaits(mut self, include: bool) -> Self {
192        self.include_awaits = include;
193        self
194    }
195
196    /// Set minimum duration threshold (in milliseconds)
197    #[must_use]
198    pub fn min_duration_ms(mut self, ms: u64) -> Self {
199        self.min_duration_ms = ms;
200        self
201    }
202
203    /// Build and export flamegraph
204    pub fn export_to_file<P: AsRef<Path>>(self, inspector: &Inspector, path: P) -> io::Result<()> {
205        // For now, use default implementation
206        // TODO: Apply builder options when generating stacks
207        FlamegraphExporter::export_to_file(inspector, path)
208    }
209
210    /// Build and export flamegraph as string
211    #[must_use]
212    pub fn export_to_string(self, inspector: &Inspector) -> String {
213        // For now, use default implementation
214        // TODO: Apply builder options when generating stacks
215        FlamegraphExporter::export_to_string(inspector)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_sanitize_frame_name() {
225        assert_eq!(FlamegraphExporter::sanitize_frame_name("simple"), "simple");
226        assert_eq!(
227            FlamegraphExporter::sanitize_frame_name("with;semicolon"),
228            "with:semicolon"
229        );
230        assert_eq!(
231            FlamegraphExporter::sanitize_frame_name("with\nnewline"),
232            "with newline"
233        );
234        assert_eq!(
235            FlamegraphExporter::sanitize_frame_name("  trimmed  "),
236            "trimmed"
237        );
238    }
239
240    #[test]
241    fn test_flamegraph_builder() {
242        let builder = FlamegraphBuilder::new()
243            .include_polls(false)
244            .include_awaits(true)
245            .min_duration_ms(10);
246
247        assert!(!builder.include_polls);
248        assert!(builder.include_awaits);
249        assert_eq!(builder.min_duration_ms, 10);
250    }
251}