Skip to main content

mockforge_reporting/
flamegraph.rs

1//! Flamegraph generation for trace analysis
2//!
3//! Generates flamegraph visualizations from distributed traces to help identify
4//! performance bottlenecks and understand call hierarchies.
5
6use crate::{ReportingError, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs::File;
10use std::io::Write;
11
12/// Span data for flamegraph generation
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TraceSpan {
15    pub span_id: String,
16    pub parent_span_id: Option<String>,
17    pub operation_name: String,
18    pub service_name: String,
19    pub start_time: u64,
20    pub duration_us: u64,
21    pub tags: HashMap<String, String>,
22}
23
24/// Trace data containing multiple spans
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TraceData {
27    pub trace_id: String,
28    pub spans: Vec<TraceSpan>,
29}
30
31/// Flamegraph generator
32pub struct FlamegraphGenerator {
33    collapse_threshold_us: u64,
34}
35
36#[allow(clippy::only_used_in_recursion)]
37impl FlamegraphGenerator {
38    /// Create a new flamegraph generator
39    pub fn new() -> Self {
40        Self {
41            collapse_threshold_us: 100, // Collapse spans shorter than 100μs
42        }
43    }
44
45    /// Set the collapse threshold in microseconds
46    pub fn with_threshold(mut self, threshold_us: u64) -> Self {
47        self.collapse_threshold_us = threshold_us;
48        self
49    }
50
51    /// Generate flamegraph from trace data
52    pub fn generate(&self, trace: &TraceData, output_path: &str) -> Result<()> {
53        // Build span hierarchy
54        let hierarchy = self.build_hierarchy(trace)?;
55
56        // Generate folded stack format
57        let folded_stacks = self.generate_folded_stacks(&hierarchy, trace);
58
59        // Write to intermediate file
60        let folded_path = format!("{}.folded", output_path);
61        let mut file = File::create(&folded_path)?;
62        for stack in &folded_stacks {
63            writeln!(file, "{}", stack)?;
64        }
65
66        // Generate SVG flamegraph
67        self.generate_svg(&folded_path, output_path)?;
68
69        Ok(())
70    }
71
72    /// Build span hierarchy from flat list
73    fn build_hierarchy(&self, trace: &TraceData) -> Result<SpanNode> {
74        let mut span_map: HashMap<String, &TraceSpan> = HashMap::new();
75        let mut root_spans = Vec::new();
76
77        // First pass: index all spans
78        for span in &trace.spans {
79            span_map.insert(span.span_id.clone(), span);
80        }
81
82        // Second pass: find roots and build tree
83        for span in &trace.spans {
84            if span.parent_span_id.is_none() {
85                root_spans.push(span);
86            }
87        }
88
89        if root_spans.is_empty() {
90            return Err(ReportingError::Analysis("No root spans found in trace".to_string()));
91        }
92
93        // Use first root span as the trace root
94        let root_span = root_spans[0];
95        let root_node = self.build_node(root_span, &span_map, trace);
96
97        Ok(root_node)
98    }
99
100    /// Build a span node recursively
101    fn build_node(
102        &self,
103        span: &TraceSpan,
104        _span_map: &HashMap<String, &TraceSpan>,
105        trace: &TraceData,
106    ) -> SpanNode {
107        let mut children = Vec::new();
108
109        // Find child spans
110        for candidate in &trace.spans {
111            if let Some(parent_id) = &candidate.parent_span_id {
112                if parent_id == &span.span_id {
113                    let child_node = self.build_node(candidate, _span_map, trace);
114                    children.push(child_node);
115                }
116            }
117        }
118
119        SpanNode {
120            span: span.clone(),
121            children,
122        }
123    }
124
125    /// Generate folded stack representation
126    fn generate_folded_stacks(&self, root: &SpanNode, _trace: &TraceData) -> Vec<String> {
127        let mut stacks = Vec::new();
128        self.collect_stacks(root, String::new(), &mut stacks);
129        stacks
130    }
131
132    /// Recursively collect stack traces
133    fn collect_stacks(&self, node: &SpanNode, prefix: String, stacks: &mut Vec<String>) {
134        let label = format!("{}::{}", node.span.service_name, node.span.operation_name);
135        let current_stack = if prefix.is_empty() {
136            label.clone()
137        } else {
138            format!("{};{}", prefix, label)
139        };
140
141        if node.children.is_empty() {
142            // Leaf node - emit stack with duration
143            stacks.push(format!("{} {}", current_stack, node.span.duration_us));
144        } else {
145            // Internal node - recurse to children
146            for child in &node.children {
147                self.collect_stacks(child, current_stack.clone(), stacks);
148            }
149        }
150    }
151
152    /// Generate SVG flamegraph from folded stacks using inferno
153    fn generate_svg(&self, folded_path: &str, output_path: &str) -> Result<()> {
154        use std::io::BufReader;
155
156        let folded_file = File::open(folded_path)?;
157        let reader = BufReader::new(folded_file);
158        let mut output_file = File::create(output_path)?;
159
160        let mut opts = inferno::flamegraph::Options::default();
161        opts.title = "Flamegraph - Trace Visualization".to_string();
162        opts.count_name = "microseconds".to_string();
163
164        inferno::flamegraph::from_reader(&mut opts, reader, &mut output_file)
165            .map_err(|e| ReportingError::Io(std::io::Error::other(e.to_string())))?;
166
167        Ok(())
168    }
169}
170
171impl Default for FlamegraphGenerator {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177/// Span node in the hierarchy tree
178#[derive(Debug, Clone)]
179struct SpanNode {
180    span: TraceSpan,
181    children: Vec<SpanNode>,
182}
183
184/// Flamegraph statistics
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct FlamegraphStats {
187    pub total_spans: usize,
188    pub max_depth: usize,
189    pub total_duration_us: u64,
190    pub hottest_path: Vec<String>,
191}
192
193impl FlamegraphGenerator {
194    /// Generate statistics from trace
195    pub fn generate_stats(&self, trace: &TraceData) -> Result<FlamegraphStats> {
196        let hierarchy = self.build_hierarchy(trace)?;
197
198        let total_spans = trace.spans.len();
199        let max_depth = self.calculate_max_depth(&hierarchy, 0);
200        let total_duration_us = hierarchy.span.duration_us;
201        let hottest_path = self.find_hottest_path(&hierarchy);
202
203        Ok(FlamegraphStats {
204            total_spans,
205            max_depth,
206            total_duration_us,
207            hottest_path,
208        })
209    }
210
211    /// Calculate maximum depth of span tree
212    #[allow(clippy::only_used_in_recursion)]
213    fn calculate_max_depth(&self, node: &SpanNode, current_depth: usize) -> usize {
214        if node.children.is_empty() {
215            current_depth
216        } else {
217            node.children
218                .iter()
219                .map(|child| self.calculate_max_depth(child, current_depth + 1))
220                .max()
221                .unwrap_or(current_depth)
222        }
223    }
224
225    /// Find the path with the longest cumulative duration
226    fn find_hottest_path(&self, root: &SpanNode) -> Vec<String> {
227        let mut path = Vec::new();
228        let mut current = root;
229
230        loop {
231            path.push(format!("{}::{}", current.span.service_name, current.span.operation_name));
232
233            if current.children.is_empty() {
234                break;
235            }
236
237            // Follow the child with the longest duration
238            current = current.children.iter().max_by_key(|child| child.span.duration_us).unwrap();
239        }
240
241        path
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_flamegraph_generation() {
251        let trace = TraceData {
252            trace_id: "trace-123".to_string(),
253            spans: vec![
254                TraceSpan {
255                    span_id: "span-1".to_string(),
256                    parent_span_id: None,
257                    operation_name: "api_request".to_string(),
258                    service_name: "api-gateway".to_string(),
259                    start_time: 0,
260                    duration_us: 10000,
261                    tags: HashMap::new(),
262                },
263                TraceSpan {
264                    span_id: "span-2".to_string(),
265                    parent_span_id: Some("span-1".to_string()),
266                    operation_name: "database_query".to_string(),
267                    service_name: "postgres".to_string(),
268                    start_time: 1000,
269                    duration_us: 5000,
270                    tags: HashMap::new(),
271                },
272                TraceSpan {
273                    span_id: "span-3".to_string(),
274                    parent_span_id: Some("span-1".to_string()),
275                    operation_name: "cache_lookup".to_string(),
276                    service_name: "redis".to_string(),
277                    start_time: 6000,
278                    duration_us: 1000,
279                    tags: HashMap::new(),
280                },
281            ],
282        };
283
284        let generator = FlamegraphGenerator::new();
285        let stats = generator.generate_stats(&trace).unwrap();
286
287        assert_eq!(stats.total_spans, 3);
288        assert!(stats.max_depth >= 1);
289        assert_eq!(stats.total_duration_us, 10000);
290    }
291
292    #[test]
293    fn test_hottest_path() {
294        let trace = TraceData {
295            trace_id: "trace-456".to_string(),
296            spans: vec![
297                TraceSpan {
298                    span_id: "span-1".to_string(),
299                    parent_span_id: None,
300                    operation_name: "root".to_string(),
301                    service_name: "service-a".to_string(),
302                    start_time: 0,
303                    duration_us: 20000,
304                    tags: HashMap::new(),
305                },
306                TraceSpan {
307                    span_id: "span-2".to_string(),
308                    parent_span_id: Some("span-1".to_string()),
309                    operation_name: "slow_operation".to_string(),
310                    service_name: "service-b".to_string(),
311                    start_time: 1000,
312                    duration_us: 15000,
313                    tags: HashMap::new(),
314                },
315                TraceSpan {
316                    span_id: "span-3".to_string(),
317                    parent_span_id: Some("span-1".to_string()),
318                    operation_name: "fast_operation".to_string(),
319                    service_name: "service-c".to_string(),
320                    start_time: 16000,
321                    duration_us: 1000,
322                    tags: HashMap::new(),
323                },
324            ],
325        };
326
327        let generator = FlamegraphGenerator::new();
328        let stats = generator.generate_stats(&trace).unwrap();
329
330        // Hottest path should follow the slow_operation
331        assert!(stats.hottest_path.contains(&"service-b::slow_operation".to_string()));
332    }
333}