use crate::{ReportingError, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceSpan {
pub span_id: String,
pub parent_span_id: Option<String>,
pub operation_name: String,
pub service_name: String,
pub start_time: u64,
pub duration_us: u64,
pub tags: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceData {
pub trace_id: String,
pub spans: Vec<TraceSpan>,
}
pub struct FlamegraphGenerator {
collapse_threshold_us: u64,
}
#[allow(clippy::only_used_in_recursion)]
impl FlamegraphGenerator {
pub fn new() -> Self {
Self {
collapse_threshold_us: 100, }
}
pub fn with_threshold(mut self, threshold_us: u64) -> Self {
self.collapse_threshold_us = threshold_us;
self
}
pub fn generate(&self, trace: &TraceData, output_path: &str) -> Result<()> {
let hierarchy = self.build_hierarchy(trace)?;
let folded_stacks = self.generate_folded_stacks(&hierarchy, trace);
let folded_path = format!("{}.folded", output_path);
let mut file = File::create(&folded_path)?;
for stack in &folded_stacks {
writeln!(file, "{}", stack)?;
}
self.generate_svg(&folded_path, output_path)?;
Ok(())
}
fn build_hierarchy(&self, trace: &TraceData) -> Result<SpanNode> {
let mut span_map: HashMap<String, &TraceSpan> = HashMap::new();
let mut root_spans = Vec::new();
for span in &trace.spans {
span_map.insert(span.span_id.clone(), span);
}
for span in &trace.spans {
if span.parent_span_id.is_none() {
root_spans.push(span);
}
}
if root_spans.is_empty() {
return Err(ReportingError::Analysis("No root spans found in trace".to_string()));
}
let root_span = root_spans[0];
let root_node = self.build_node(root_span, &span_map, trace);
Ok(root_node)
}
fn build_node(
&self,
span: &TraceSpan,
_span_map: &HashMap<String, &TraceSpan>,
trace: &TraceData,
) -> SpanNode {
let mut children = Vec::new();
for candidate in &trace.spans {
if let Some(parent_id) = &candidate.parent_span_id {
if parent_id == &span.span_id {
let child_node = self.build_node(candidate, _span_map, trace);
children.push(child_node);
}
}
}
SpanNode {
span: span.clone(),
children,
}
}
fn generate_folded_stacks(&self, root: &SpanNode, _trace: &TraceData) -> Vec<String> {
let mut stacks = Vec::new();
self.collect_stacks(root, String::new(), &mut stacks);
stacks
}
fn collect_stacks(&self, node: &SpanNode, prefix: String, stacks: &mut Vec<String>) {
let label = format!("{}::{}", node.span.service_name, node.span.operation_name);
let current_stack = if prefix.is_empty() {
label.clone()
} else {
format!("{};{}", prefix, label)
};
if node.children.is_empty() {
stacks.push(format!("{} {}", current_stack, node.span.duration_us));
} else {
for child in &node.children {
self.collect_stacks(child, current_stack.clone(), stacks);
}
}
}
fn generate_svg(&self, folded_path: &str, output_path: &str) -> Result<()> {
use std::io::BufReader;
let folded_file = File::open(folded_path)?;
let reader = BufReader::new(folded_file);
let mut output_file = File::create(output_path)?;
let mut opts = inferno::flamegraph::Options::default();
opts.title = "Flamegraph - Trace Visualization".to_string();
opts.count_name = "microseconds".to_string();
inferno::flamegraph::from_reader(&mut opts, reader, &mut output_file)
.map_err(|e| ReportingError::Io(std::io::Error::other(e.to_string())))?;
Ok(())
}
}
impl Default for FlamegraphGenerator {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
struct SpanNode {
span: TraceSpan,
children: Vec<SpanNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlamegraphStats {
pub total_spans: usize,
pub max_depth: usize,
pub total_duration_us: u64,
pub hottest_path: Vec<String>,
}
impl FlamegraphGenerator {
pub fn generate_stats(&self, trace: &TraceData) -> Result<FlamegraphStats> {
let hierarchy = self.build_hierarchy(trace)?;
let total_spans = trace.spans.len();
let max_depth = self.calculate_max_depth(&hierarchy, 0);
let total_duration_us = hierarchy.span.duration_us;
let hottest_path = self.find_hottest_path(&hierarchy);
Ok(FlamegraphStats {
total_spans,
max_depth,
total_duration_us,
hottest_path,
})
}
#[allow(clippy::only_used_in_recursion)]
fn calculate_max_depth(&self, node: &SpanNode, current_depth: usize) -> usize {
if node.children.is_empty() {
current_depth
} else {
node.children
.iter()
.map(|child| self.calculate_max_depth(child, current_depth + 1))
.max()
.unwrap_or(current_depth)
}
}
fn find_hottest_path(&self, root: &SpanNode) -> Vec<String> {
let mut path = Vec::new();
let mut current = root;
loop {
path.push(format!("{}::{}", current.span.service_name, current.span.operation_name));
if current.children.is_empty() {
break;
}
current = current.children.iter().max_by_key(|child| child.span.duration_us).unwrap();
}
path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_flamegraph_generation() {
let trace = TraceData {
trace_id: "trace-123".to_string(),
spans: vec![
TraceSpan {
span_id: "span-1".to_string(),
parent_span_id: None,
operation_name: "api_request".to_string(),
service_name: "api-gateway".to_string(),
start_time: 0,
duration_us: 10000,
tags: HashMap::new(),
},
TraceSpan {
span_id: "span-2".to_string(),
parent_span_id: Some("span-1".to_string()),
operation_name: "database_query".to_string(),
service_name: "postgres".to_string(),
start_time: 1000,
duration_us: 5000,
tags: HashMap::new(),
},
TraceSpan {
span_id: "span-3".to_string(),
parent_span_id: Some("span-1".to_string()),
operation_name: "cache_lookup".to_string(),
service_name: "redis".to_string(),
start_time: 6000,
duration_us: 1000,
tags: HashMap::new(),
},
],
};
let generator = FlamegraphGenerator::new();
let stats = generator.generate_stats(&trace).unwrap();
assert_eq!(stats.total_spans, 3);
assert!(stats.max_depth >= 1);
assert_eq!(stats.total_duration_us, 10000);
}
#[test]
fn test_hottest_path() {
let trace = TraceData {
trace_id: "trace-456".to_string(),
spans: vec![
TraceSpan {
span_id: "span-1".to_string(),
parent_span_id: None,
operation_name: "root".to_string(),
service_name: "service-a".to_string(),
start_time: 0,
duration_us: 20000,
tags: HashMap::new(),
},
TraceSpan {
span_id: "span-2".to_string(),
parent_span_id: Some("span-1".to_string()),
operation_name: "slow_operation".to_string(),
service_name: "service-b".to_string(),
start_time: 1000,
duration_us: 15000,
tags: HashMap::new(),
},
TraceSpan {
span_id: "span-3".to_string(),
parent_span_id: Some("span-1".to_string()),
operation_name: "fast_operation".to_string(),
service_name: "service-c".to_string(),
start_time: 16000,
duration_us: 1000,
tags: HashMap::new(),
},
],
};
let generator = FlamegraphGenerator::new();
let stats = generator.generate_stats(&trace).unwrap();
assert!(stats.hottest_path.contains(&"service-b::slow_operation".to_string()));
}
}