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 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 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 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#[derive(Debug, Clone)]
237struct SpanNode {
238 span: TraceSpan,
239 children: Vec<SpanNode>,
240}
241
242#[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 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 #[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 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 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 assert!(stats.hottest_path.contains(&"service-b::slow_operation".to_string()));
390 }
391}