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
153    fn generate_svg(&self, folded_path: &str, output_path: &str) -> Result<()> {
154        // For now, generate a simple HTML representation
155        // In production, you'd use inferno or flamegraph crate
156        let svg_content = self.create_svg_content(folded_path)?;
157
158        let mut file = File::create(output_path)?;
159        file.write_all(svg_content.as_bytes())?;
160
161        Ok(())
162    }
163
164    /// Create SVG content (simplified version)
165    fn create_svg_content(&self, folded_path: &str) -> Result<String> {
166        use std::io::BufRead;
167
168        let file = File::open(folded_path)?;
169        let reader = std::io::BufReader::new(file);
170
171        let mut max_duration = 0u64;
172        let mut stacks_data = Vec::new();
173
174        for line in reader.lines() {
175            let line = line?;
176            let parts: Vec<&str> = line.rsplitn(2, ' ').collect();
177            if parts.len() == 2 {
178                let duration = parts[0].parse::<u64>().unwrap_or(0);
179                max_duration = max_duration.max(duration);
180                stacks_data.push((parts[1].to_string(), duration));
181            }
182        }
183
184        // Generate simple SVG representation
185        let mut svg = String::from(
186            r#"<?xml version="1.0" standalone="no"?>
187<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
188<svg version="1.1" width="1200" height="800" xmlns="http://www.w3.org/2000/svg">
189<style>
190  text { font-family: Verdana, sans-serif; font-size: 12px; }
191  rect { stroke: white; stroke-width: 1; }
192  .frame { fill: rgb(230,120,50); }
193  .frame:hover { fill: rgb(250,140,70); stroke: black; stroke-width: 2; }
194</style>
195<text x="600" y="30" text-anchor="middle" font-size="18" font-weight="bold">Flamegraph - Trace Visualization</text>
196"#,
197        );
198
199        let width = 1160.0;
200        let mut y = 50.0;
201        let height = 20.0;
202
203        for (stack, duration) in stacks_data {
204            let bar_width = (duration as f64 / max_duration as f64) * width;
205            let depth = stack.matches(';').count();
206            let x = 20.0 + (depth as f64 * 10.0);
207
208            svg.push_str(&format!(
209                r#"<rect class="frame" x="{}" y="{}" width="{}" height="{}" title="{} ({}μs)" />"#,
210                x, y, bar_width, height, stack, duration
211            ));
212
213            svg.push_str(&format!(
214                r#"<text x="{}" y="{}" fill="white">{}</text>"#,
215                x + 5.0,
216                y + 14.0,
217                stack.split(';').next_back().unwrap_or(&stack)
218            ));
219
220            y += height + 2.0;
221        }
222
223        svg.push_str("</svg>");
224
225        Ok(svg)
226    }
227}
228
229impl Default for FlamegraphGenerator {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235/// Span node in the hierarchy tree
236#[derive(Debug, Clone)]
237struct SpanNode {
238    span: TraceSpan,
239    children: Vec<SpanNode>,
240}
241
242/// Flamegraph statistics
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct FlamegraphStats {
245    pub total_spans: usize,
246    pub max_depth: usize,
247    pub total_duration_us: u64,
248    pub hottest_path: Vec<String>,
249}
250
251impl FlamegraphGenerator {
252    /// Generate statistics from trace
253    pub fn generate_stats(&self, trace: &TraceData) -> Result<FlamegraphStats> {
254        let hierarchy = self.build_hierarchy(trace)?;
255
256        let total_spans = trace.spans.len();
257        let max_depth = self.calculate_max_depth(&hierarchy, 0);
258        let total_duration_us = hierarchy.span.duration_us;
259        let hottest_path = self.find_hottest_path(&hierarchy);
260
261        Ok(FlamegraphStats {
262            total_spans,
263            max_depth,
264            total_duration_us,
265            hottest_path,
266        })
267    }
268
269    /// Calculate maximum depth of span tree
270    #[allow(clippy::only_used_in_recursion)]
271    fn calculate_max_depth(&self, node: &SpanNode, current_depth: usize) -> usize {
272        if node.children.is_empty() {
273            current_depth
274        } else {
275            node.children
276                .iter()
277                .map(|child| self.calculate_max_depth(child, current_depth + 1))
278                .max()
279                .unwrap_or(current_depth)
280        }
281    }
282
283    /// Find the path with the longest cumulative duration
284    fn find_hottest_path(&self, root: &SpanNode) -> Vec<String> {
285        let mut path = Vec::new();
286        let mut current = root;
287
288        loop {
289            path.push(format!("{}::{}", current.span.service_name, current.span.operation_name));
290
291            if current.children.is_empty() {
292                break;
293            }
294
295            // Follow the child with the longest duration
296            current = current.children.iter().max_by_key(|child| child.span.duration_us).unwrap();
297        }
298
299        path
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_flamegraph_generation() {
309        let trace = TraceData {
310            trace_id: "trace-123".to_string(),
311            spans: vec![
312                TraceSpan {
313                    span_id: "span-1".to_string(),
314                    parent_span_id: None,
315                    operation_name: "api_request".to_string(),
316                    service_name: "api-gateway".to_string(),
317                    start_time: 0,
318                    duration_us: 10000,
319                    tags: HashMap::new(),
320                },
321                TraceSpan {
322                    span_id: "span-2".to_string(),
323                    parent_span_id: Some("span-1".to_string()),
324                    operation_name: "database_query".to_string(),
325                    service_name: "postgres".to_string(),
326                    start_time: 1000,
327                    duration_us: 5000,
328                    tags: HashMap::new(),
329                },
330                TraceSpan {
331                    span_id: "span-3".to_string(),
332                    parent_span_id: Some("span-1".to_string()),
333                    operation_name: "cache_lookup".to_string(),
334                    service_name: "redis".to_string(),
335                    start_time: 6000,
336                    duration_us: 1000,
337                    tags: HashMap::new(),
338                },
339            ],
340        };
341
342        let generator = FlamegraphGenerator::new();
343        let stats = generator.generate_stats(&trace).unwrap();
344
345        assert_eq!(stats.total_spans, 3);
346        assert!(stats.max_depth >= 1);
347        assert_eq!(stats.total_duration_us, 10000);
348    }
349
350    #[test]
351    fn test_hottest_path() {
352        let trace = TraceData {
353            trace_id: "trace-456".to_string(),
354            spans: vec![
355                TraceSpan {
356                    span_id: "span-1".to_string(),
357                    parent_span_id: None,
358                    operation_name: "root".to_string(),
359                    service_name: "service-a".to_string(),
360                    start_time: 0,
361                    duration_us: 20000,
362                    tags: HashMap::new(),
363                },
364                TraceSpan {
365                    span_id: "span-2".to_string(),
366                    parent_span_id: Some("span-1".to_string()),
367                    operation_name: "slow_operation".to_string(),
368                    service_name: "service-b".to_string(),
369                    start_time: 1000,
370                    duration_us: 15000,
371                    tags: HashMap::new(),
372                },
373                TraceSpan {
374                    span_id: "span-3".to_string(),
375                    parent_span_id: Some("span-1".to_string()),
376                    operation_name: "fast_operation".to_string(),
377                    service_name: "service-c".to_string(),
378                    start_time: 16000,
379                    duration_us: 1000,
380                    tags: HashMap::new(),
381                },
382            ],
383        };
384
385        let generator = FlamegraphGenerator::new();
386        let stats = generator.generate_stats(&trace).unwrap();
387
388        // Hottest path should follow the slow_operation
389        assert!(stats.hottest_path.contains(&"service-b::slow_operation".to_string()));
390    }
391}