1use crate::{ReportingError, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs::File;
10use std::io::Write;
11
12#[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#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TraceData {
27 pub trace_id: String,
28 pub spans: Vec<TraceSpan>,
29}
30
31pub struct FlamegraphGenerator {
33 collapse_threshold_us: u64,
34}
35
36#[allow(clippy::only_used_in_recursion)]
37impl FlamegraphGenerator {
38 pub fn new() -> Self {
40 Self {
41 collapse_threshold_us: 100, }
43 }
44
45 pub fn with_threshold(mut self, threshold_us: u64) -> Self {
47 self.collapse_threshold_us = threshold_us;
48 self
49 }
50
51 pub fn generate(&self, trace: &TraceData, output_path: &str) -> Result<()> {
53 let hierarchy = self.build_hierarchy(trace)?;
55
56 let folded_stacks = self.generate_folded_stacks(&hierarchy, trace);
58
59 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 self.generate_svg(&folded_path, output_path)?;
68
69 Ok(())
70 }
71
72 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 for span in &trace.spans {
79 span_map.insert(span.span_id.clone(), span);
80 }
81
82 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 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 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 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 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 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 stacks.push(format!("{} {}", current_stack, node.span.duration_us));
144 } else {
145 for child in &node.children {
147 self.collect_stacks(child, current_stack.clone(), stacks);
148 }
149 }
150 }
151
152 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#[derive(Debug, Clone)]
179struct SpanNode {
180 span: TraceSpan,
181 children: Vec<SpanNode>,
182}
183
184#[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 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 #[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 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 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 assert!(stats.hottest_path.contains(&"service-b::slow_operation".to_string()));
332 }
333}