Skip to main content

oxirs_gql/
trace_visualization.rs

1//! Trace Visualization and Analysis Tools
2//!
3//! This module provides tools for visualizing and analyzing distributed traces,
4//! helping to understand system behavior and identify performance issues.
5//!
6//! # Features
7//!
8//! - **Timeline Visualization**: Gantt-chart style trace timeline
9//! - **Flame Graph**: CPU-style flame graph for span hierarchies
10//! - **Dependency Graph**: Service dependency visualization
11//! - **Critical Path**: Identify the critical path in trace execution
12//! - **Statistics**: Aggregate trace statistics and metrics
13//! - **Export Formats**: JSON, HTML, SVG, ASCII
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use oxirs_gql::trace_visualization::{TraceVisualizer, VisualizationFormat};
19//!
20//! let visualizer = TraceVisualizer::new();
21//! let timeline = visualizer.generate_timeline(&spans, VisualizationFormat::HTML)?;
22//! let critical_path = visualizer.find_critical_path(&spans)?;
23//! ```
24
25use crate::trace_correlation::Span;
26use serde::{Deserialize, Serialize};
27use std::collections::{HashMap, HashSet};
28
29/// Visualization format
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub enum VisualizationFormat {
32    /// JSON format
33    JSON,
34    /// HTML format
35    HTML,
36    /// SVG format
37    SVG,
38    /// ASCII art
39    ASCII,
40    /// Mermaid diagram
41    Mermaid,
42}
43
44/// Timeline entry
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct TimelineEntry {
47    /// Span ID
48    pub span_id: String,
49    /// Span name
50    pub name: String,
51    /// Start offset from trace start (ms)
52    pub start_offset_ms: u64,
53    /// Duration (ms)
54    pub duration_ms: u64,
55    /// Depth level
56    pub depth: usize,
57    /// Parent span ID
58    pub parent_id: Option<String>,
59}
60
61/// Flame graph node
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct FlameGraphNode {
64    /// Node name
65    pub name: String,
66    /// Value (duration in ms)
67    pub value: u64,
68    /// Children nodes
69    pub children: Vec<FlameGraphNode>,
70}
71
72/// Dependency edge
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct DependencyEdge {
75    /// Source service
76    pub from: String,
77    /// Target service
78    pub to: String,
79    /// Number of calls
80    pub call_count: usize,
81    /// Total duration (ms)
82    pub total_duration_ms: u64,
83}
84
85/// Critical path segment
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct CriticalPathSegment {
88    /// Span ID
89    pub span_id: String,
90    /// Span name
91    pub name: String,
92    /// Duration (ms)
93    pub duration_ms: u64,
94    /// Cumulative duration (ms)
95    pub cumulative_duration_ms: u64,
96}
97
98/// Trace statistics
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct TraceStatistics {
101    /// Total number of spans
102    pub total_spans: usize,
103    /// Total trace duration (ms)
104    pub total_duration_ms: u64,
105    /// Maximum depth
106    pub max_depth: usize,
107    /// Number of services
108    pub service_count: usize,
109    /// Number of errors
110    pub error_count: usize,
111    /// Average span duration (ms)
112    pub avg_span_duration_ms: f64,
113    /// Span duration percentiles (p50, p95, p99)
114    pub duration_percentiles: (u64, u64, u64),
115}
116
117/// Trace visualizer
118pub struct TraceVisualizer;
119
120impl TraceVisualizer {
121    /// Create new trace visualizer
122    pub fn new() -> Self {
123        Self
124    }
125
126    /// Generate timeline visualization
127    pub fn generate_timeline(
128        &self,
129        spans: &[Span],
130        format: VisualizationFormat,
131    ) -> Result<String, String> {
132        if spans.is_empty() {
133            return Err("No spans to visualize".to_string());
134        }
135
136        let entries = self.build_timeline_entries(spans)?;
137
138        match format {
139            VisualizationFormat::JSON => Ok(serde_json::to_string_pretty(&entries)
140                .map_err(|e| format!("JSON serialization failed: {}", e))?),
141            VisualizationFormat::HTML => Ok(self.timeline_to_html(&entries)),
142            VisualizationFormat::ASCII => Ok(self.timeline_to_ascii(&entries)),
143            VisualizationFormat::Mermaid => Ok(self.timeline_to_mermaid(&entries)),
144            _ => Err("Unsupported format for timeline".to_string()),
145        }
146    }
147
148    /// Build timeline entries from spans
149    fn build_timeline_entries(&self, spans: &[Span]) -> Result<Vec<TimelineEntry>, String> {
150        if spans.is_empty() {
151            return Ok(Vec::new());
152        }
153
154        // Find trace start time
155        let trace_start = spans
156            .iter()
157            .map(|s| s.start_time)
158            .min()
159            .ok_or("No start time found")?;
160
161        // Build span hierarchy
162        let hierarchy = self.build_hierarchy(spans);
163
164        // Calculate depths
165        let depths = self.calculate_depths(spans, &hierarchy);
166
167        // Create timeline entries
168        let mut entries: Vec<TimelineEntry> = spans
169            .iter()
170            .map(|span| {
171                let start_offset = span
172                    .start_time
173                    .duration_since(trace_start)
174                    .unwrap_or_default()
175                    .as_millis() as u64;
176
177                TimelineEntry {
178                    span_id: span.span_id.clone(),
179                    name: span.name.clone(),
180                    start_offset_ms: start_offset,
181                    duration_ms: span.duration_ms.unwrap_or(0),
182                    depth: *depths.get(&span.span_id).unwrap_or(&0),
183                    parent_id: span.parent_span_id.clone(),
184                }
185            })
186            .collect();
187
188        // Sort by start time
189        entries.sort_by_key(|e| e.start_offset_ms);
190
191        Ok(entries)
192    }
193
194    /// Build span hierarchy
195    fn build_hierarchy(&self, spans: &[Span]) -> HashMap<String, Vec<String>> {
196        let mut hierarchy: HashMap<String, Vec<String>> = HashMap::new();
197
198        for span in spans {
199            if let Some(parent_id) = &span.parent_span_id {
200                hierarchy
201                    .entry(parent_id.clone())
202                    .or_default()
203                    .push(span.span_id.clone());
204            }
205        }
206
207        hierarchy
208    }
209
210    /// Calculate depths for each span
211    fn calculate_depths(
212        &self,
213        spans: &[Span],
214        hierarchy: &HashMap<String, Vec<String>>,
215    ) -> HashMap<String, usize> {
216        let mut depths = HashMap::new();
217
218        // Find root spans (no parent)
219        let roots: Vec<_> = spans
220            .iter()
221            .filter(|s| s.parent_span_id.is_none())
222            .collect();
223
224        // Calculate depth recursively
225        for root in roots {
226            self.calculate_depth_recursive(&root.span_id, 0, hierarchy, &mut depths);
227        }
228
229        depths
230    }
231
232    /// Calculate depth recursively
233    #[allow(clippy::only_used_in_recursion)]
234    fn calculate_depth_recursive(
235        &self,
236        span_id: &str,
237        depth: usize,
238        hierarchy: &HashMap<String, Vec<String>>,
239        depths: &mut HashMap<String, usize>,
240    ) {
241        depths.insert(span_id.to_string(), depth);
242
243        if let Some(children) = hierarchy.get(span_id) {
244            for child_id in children {
245                self.calculate_depth_recursive(child_id, depth + 1, hierarchy, depths);
246            }
247        }
248    }
249
250    /// Convert timeline to HTML
251    fn timeline_to_html(&self, entries: &[TimelineEntry]) -> String {
252        let mut html = String::from("<html><head><style>\n");
253        html.push_str(".timeline { font-family: monospace; }\n");
254        html.push_str(".span { background: #4CAF50; color: white; padding: 2px 5px; margin: 2px; display: inline-block; }\n");
255        html.push_str("</style></head><body><div class='timeline'>\n");
256
257        for entry in entries {
258            let indent = "  ".repeat(entry.depth);
259            html.push_str(&format!(
260                "{}<div class='span'>{} ({}ms)</div><br>\n",
261                indent, entry.name, entry.duration_ms
262            ));
263        }
264
265        html.push_str("</div></body></html>");
266        html
267    }
268
269    /// Convert timeline to ASCII
270    fn timeline_to_ascii(&self, entries: &[TimelineEntry]) -> String {
271        let mut ascii = String::new();
272
273        for entry in entries {
274            let indent = "  ".repeat(entry.depth);
275            ascii.push_str(&format!(
276                "{}├─ {} ({}ms)\n",
277                indent, entry.name, entry.duration_ms
278            ));
279        }
280
281        ascii
282    }
283
284    /// Convert timeline to Mermaid diagram
285    fn timeline_to_mermaid(&self, entries: &[TimelineEntry]) -> String {
286        let mut mermaid = String::from(
287            "gantt\n    title Trace Timeline\n    dateFormat x\n    axisFormat %L\n\n",
288        );
289
290        for entry in entries {
291            mermaid.push_str(&format!(
292                "    {} : {}, {}ms\n",
293                entry.name, entry.start_offset_ms, entry.duration_ms
294            ));
295        }
296
297        mermaid
298    }
299
300    /// Generate flame graph
301    pub fn generate_flame_graph(&self, spans: &[Span]) -> Result<FlameGraphNode, String> {
302        if spans.is_empty() {
303            return Err("No spans to visualize".to_string());
304        }
305
306        // Find root spans
307        let roots: Vec<_> = spans
308            .iter()
309            .filter(|s| s.parent_span_id.is_none())
310            .collect();
311
312        if roots.is_empty() {
313            return Err("No root spans found".to_string());
314        }
315
316        // Build flame graph from first root
317        let root = roots[0];
318        Ok(self.build_flame_graph_node(root, spans))
319    }
320
321    /// Build flame graph node recursively
322    #[allow(clippy::only_used_in_recursion)]
323    fn build_flame_graph_node(&self, span: &Span, all_spans: &[Span]) -> FlameGraphNode {
324        let children: Vec<FlameGraphNode> = all_spans
325            .iter()
326            .filter(|s| s.parent_span_id.as_ref() == Some(&span.span_id))
327            .map(|child| self.build_flame_graph_node(child, all_spans))
328            .collect();
329
330        FlameGraphNode {
331            name: span.name.clone(),
332            value: span.duration_ms.unwrap_or(0),
333            children,
334        }
335    }
336
337    /// Generate dependency graph
338    pub fn generate_dependency_graph(&self, spans: &[Span]) -> Vec<DependencyEdge> {
339        let mut edges: HashMap<(String, String), DependencyEdge> = HashMap::new();
340
341        for span in spans {
342            if let Some(parent_id) = &span.parent_span_id {
343                // Find parent span
344                if let Some(parent) = spans.iter().find(|s| &s.span_id == parent_id) {
345                    let from = parent.name.clone();
346                    let to = span.name.clone();
347                    let key = (from.clone(), to.clone());
348
349                    edges
350                        .entry(key)
351                        .and_modify(|e| {
352                            e.call_count += 1;
353                            e.total_duration_ms += span.duration_ms.unwrap_or(0);
354                        })
355                        .or_insert(DependencyEdge {
356                            from,
357                            to,
358                            call_count: 1,
359                            total_duration_ms: span.duration_ms.unwrap_or(0),
360                        });
361                }
362            }
363        }
364
365        edges.into_values().collect()
366    }
367
368    /// Find critical path
369    pub fn find_critical_path(&self, spans: &[Span]) -> Result<Vec<CriticalPathSegment>, String> {
370        if spans.is_empty() {
371            return Err("No spans to analyze".to_string());
372        }
373
374        // Find root spans
375        let roots: Vec<_> = spans
376            .iter()
377            .filter(|s| s.parent_span_id.is_none())
378            .collect();
379
380        if roots.is_empty() {
381            return Err("No root spans found".to_string());
382        }
383
384        // Find critical path from first root
385        let root = roots[0];
386        let path = self.find_critical_path_recursive(root, spans, 0);
387
388        Ok(path)
389    }
390
391    /// Find critical path recursively
392    #[allow(clippy::only_used_in_recursion)]
393    fn find_critical_path_recursive(
394        &self,
395        span: &Span,
396        all_spans: &[Span],
397        cumulative: u64,
398    ) -> Vec<CriticalPathSegment> {
399        let duration = span.duration_ms.unwrap_or(0);
400        let new_cumulative = cumulative + duration;
401
402        let mut path = vec![CriticalPathSegment {
403            span_id: span.span_id.clone(),
404            name: span.name.clone(),
405            duration_ms: duration,
406            cumulative_duration_ms: new_cumulative,
407        }];
408
409        // Find child with longest duration
410        let longest_child = all_spans
411            .iter()
412            .filter(|s| s.parent_span_id.as_ref() == Some(&span.span_id))
413            .max_by_key(|s| s.duration_ms.unwrap_or(0));
414
415        if let Some(child) = longest_child {
416            path.extend(self.find_critical_path_recursive(child, all_spans, new_cumulative));
417        }
418
419        path
420    }
421
422    /// Calculate trace statistics
423    pub fn calculate_statistics(&self, spans: &[Span]) -> TraceStatistics {
424        if spans.is_empty() {
425            return TraceStatistics {
426                total_spans: 0,
427                total_duration_ms: 0,
428                max_depth: 0,
429                service_count: 0,
430                error_count: 0,
431                avg_span_duration_ms: 0.0,
432                duration_percentiles: (0, 0, 0),
433            };
434        }
435
436        let total_spans = spans.len();
437
438        // Total duration (root span duration)
439        let total_duration_ms = spans
440            .iter()
441            .filter(|s| s.parent_span_id.is_none())
442            .map(|s| s.duration_ms.unwrap_or(0))
443            .max()
444            .unwrap_or(0);
445
446        // Max depth
447        let hierarchy = self.build_hierarchy(spans);
448        let depths = self.calculate_depths(spans, &hierarchy);
449        let max_depth = depths.values().max().copied().unwrap_or(0);
450
451        // Service count (unique span names)
452        let services: HashSet<_> = spans.iter().map(|s| s.name.as_str()).collect();
453        let service_count = services.len();
454
455        // Error count
456        let error_count = spans
457            .iter()
458            .filter(|s| matches!(s.status, crate::trace_correlation::SpanStatus::Error))
459            .count();
460
461        // Average span duration
462        let total_span_duration: u64 = spans.iter().map(|s| s.duration_ms.unwrap_or(0)).sum();
463        let avg_span_duration_ms = total_span_duration as f64 / total_spans as f64;
464
465        // Duration percentiles
466        let mut durations: Vec<u64> = spans.iter().map(|s| s.duration_ms.unwrap_or(0)).collect();
467        durations.sort_unstable();
468
469        let p50 = durations[durations.len() / 2];
470        let p95 = durations[durations.len() * 95 / 100];
471        let p99 = durations[durations.len() * 99 / 100];
472
473        TraceStatistics {
474            total_spans,
475            total_duration_ms,
476            max_depth,
477            service_count,
478            error_count,
479            avg_span_duration_ms,
480            duration_percentiles: (p50, p95, p99),
481        }
482    }
483}
484
485impl Default for TraceVisualizer {
486    fn default() -> Self {
487        Self::new()
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::trace_correlation::{SpanKind, TraceContext};
495
496    fn create_test_spans() -> Vec<Span> {
497        let ctx = TraceContext::new("test");
498
499        let mut root = Span::new("root".to_string(), ctx.clone(), SpanKind::Server);
500        root.finish();
501
502        let mut child1 = Span::new("child1".to_string(), ctx.clone(), SpanKind::Internal)
503            .with_parent(root.span_id.clone());
504        child1.finish();
505
506        let mut child2 = Span::new("child2".to_string(), ctx, SpanKind::Internal)
507            .with_parent(root.span_id.clone());
508        child2.finish();
509
510        vec![root, child1, child2]
511    }
512
513    #[test]
514    fn test_visualization_format() {
515        let formats = [
516            VisualizationFormat::JSON,
517            VisualizationFormat::HTML,
518            VisualizationFormat::SVG,
519            VisualizationFormat::ASCII,
520            VisualizationFormat::Mermaid,
521        ];
522
523        assert_eq!(formats.len(), 5);
524    }
525
526    #[test]
527    fn test_timeline_entry_creation() {
528        let entry = TimelineEntry {
529            span_id: "span1".to_string(),
530            name: "operation".to_string(),
531            start_offset_ms: 0,
532            duration_ms: 100,
533            depth: 0,
534            parent_id: None,
535        };
536
537        assert_eq!(entry.span_id, "span1");
538        assert_eq!(entry.duration_ms, 100);
539    }
540
541    #[test]
542    fn test_trace_visualizer_creation() {
543        let _visualizer = TraceVisualizer::new();
544        // Just test that it can be created
545    }
546
547    #[test]
548    fn test_build_hierarchy() {
549        let visualizer = TraceVisualizer::new();
550        let spans = create_test_spans();
551
552        let hierarchy = visualizer.build_hierarchy(&spans);
553
554        assert!(hierarchy.contains_key(&spans[0].span_id));
555        assert_eq!(
556            hierarchy
557                .get(&spans[0].span_id)
558                .expect("should succeed")
559                .len(),
560            2
561        );
562    }
563
564    #[test]
565    fn test_calculate_depths() {
566        let visualizer = TraceVisualizer::new();
567        let spans = create_test_spans();
568
569        let hierarchy = visualizer.build_hierarchy(&spans);
570        let depths = visualizer.calculate_depths(&spans, &hierarchy);
571
572        assert_eq!(depths.get(&spans[0].span_id), Some(&0));
573        assert_eq!(depths.get(&spans[1].span_id), Some(&1));
574        assert_eq!(depths.get(&spans[2].span_id), Some(&1));
575    }
576
577    #[test]
578    fn test_build_timeline_entries() {
579        let visualizer = TraceVisualizer::new();
580        let spans = create_test_spans();
581
582        let entries = visualizer
583            .build_timeline_entries(&spans)
584            .expect("should succeed");
585
586        assert_eq!(entries.len(), 3);
587        assert_eq!(entries[0].depth, 0);
588    }
589
590    #[test]
591    fn test_generate_timeline_json() {
592        let visualizer = TraceVisualizer::new();
593        let spans = create_test_spans();
594
595        let result = visualizer.generate_timeline(&spans, VisualizationFormat::JSON);
596
597        assert!(result.is_ok());
598        let json = result.expect("should succeed");
599        assert!(json.contains("span_id"));
600    }
601
602    #[test]
603    fn test_generate_timeline_html() {
604        let visualizer = TraceVisualizer::new();
605        let spans = create_test_spans();
606
607        let result = visualizer.generate_timeline(&spans, VisualizationFormat::HTML);
608
609        assert!(result.is_ok());
610        let html = result.expect("should succeed");
611        assert!(html.contains("<html>"));
612        assert!(html.contains("timeline"));
613    }
614
615    #[test]
616    fn test_generate_timeline_ascii() {
617        let visualizer = TraceVisualizer::new();
618        let spans = create_test_spans();
619
620        let result = visualizer.generate_timeline(&spans, VisualizationFormat::ASCII);
621
622        assert!(result.is_ok());
623        let ascii = result.expect("should succeed");
624        assert!(ascii.contains("├─"));
625    }
626
627    #[test]
628    fn test_generate_timeline_mermaid() {
629        let visualizer = TraceVisualizer::new();
630        let spans = create_test_spans();
631
632        let result = visualizer.generate_timeline(&spans, VisualizationFormat::Mermaid);
633
634        assert!(result.is_ok());
635        let mermaid = result.expect("should succeed");
636        assert!(mermaid.contains("gantt"));
637    }
638
639    #[test]
640    fn test_generate_timeline_empty() {
641        let visualizer = TraceVisualizer::new();
642        let spans: Vec<Span> = vec![];
643
644        let result = visualizer.generate_timeline(&spans, VisualizationFormat::JSON);
645
646        assert!(result.is_err());
647    }
648
649    #[test]
650    fn test_generate_flame_graph() {
651        let visualizer = TraceVisualizer::new();
652        let spans = create_test_spans();
653
654        let result = visualizer.generate_flame_graph(&spans);
655
656        assert!(result.is_ok());
657        let root = result.expect("should succeed");
658        assert_eq!(root.name, "root");
659        assert_eq!(root.children.len(), 2);
660    }
661
662    #[test]
663    fn test_generate_flame_graph_empty() {
664        let visualizer = TraceVisualizer::new();
665        let spans: Vec<Span> = vec![];
666
667        let result = visualizer.generate_flame_graph(&spans);
668
669        assert!(result.is_err());
670    }
671
672    #[test]
673    fn test_generate_dependency_graph() {
674        let visualizer = TraceVisualizer::new();
675        let spans = create_test_spans();
676
677        let edges = visualizer.generate_dependency_graph(&spans);
678
679        assert_eq!(edges.len(), 2);
680        assert!(edges.iter().any(|e| e.from == "root" && e.to == "child1"));
681        assert!(edges.iter().any(|e| e.from == "root" && e.to == "child2"));
682    }
683
684    #[test]
685    fn test_find_critical_path() {
686        let visualizer = TraceVisualizer::new();
687        let spans = create_test_spans();
688
689        let result = visualizer.find_critical_path(&spans);
690
691        assert!(result.is_ok());
692        let path = result.expect("should succeed");
693        assert!(!path.is_empty());
694        assert_eq!(path[0].name, "root");
695    }
696
697    #[test]
698    fn test_find_critical_path_empty() {
699        let visualizer = TraceVisualizer::new();
700        let spans: Vec<Span> = vec![];
701
702        let result = visualizer.find_critical_path(&spans);
703
704        assert!(result.is_err());
705    }
706
707    #[test]
708    fn test_calculate_statistics() {
709        let visualizer = TraceVisualizer::new();
710        let spans = create_test_spans();
711
712        let stats = visualizer.calculate_statistics(&spans);
713
714        assert_eq!(stats.total_spans, 3);
715        assert_eq!(stats.max_depth, 1);
716        assert_eq!(stats.service_count, 3); // root, child1, child2
717        assert_eq!(stats.error_count, 0);
718    }
719
720    #[test]
721    fn test_calculate_statistics_empty() {
722        let visualizer = TraceVisualizer::new();
723        let spans: Vec<Span> = vec![];
724
725        let stats = visualizer.calculate_statistics(&spans);
726
727        assert_eq!(stats.total_spans, 0);
728        assert_eq!(stats.total_duration_ms, 0);
729    }
730
731    #[test]
732    fn test_critical_path_segment() {
733        let segment = CriticalPathSegment {
734            span_id: "span1".to_string(),
735            name: "operation".to_string(),
736            duration_ms: 100,
737            cumulative_duration_ms: 100,
738        };
739
740        assert_eq!(segment.duration_ms, 100);
741        assert_eq!(segment.cumulative_duration_ms, 100);
742    }
743
744    #[test]
745    fn test_dependency_edge() {
746        let edge = DependencyEdge {
747            from: "service1".to_string(),
748            to: "service2".to_string(),
749            call_count: 5,
750            total_duration_ms: 500,
751        };
752
753        assert_eq!(edge.call_count, 5);
754        assert_eq!(edge.total_duration_ms, 500);
755    }
756
757    #[test]
758    fn test_flame_graph_node() {
759        let node = FlameGraphNode {
760            name: "root".to_string(),
761            value: 100,
762            children: vec![],
763        };
764
765        assert_eq!(node.name, "root");
766        assert_eq!(node.value, 100);
767        assert!(node.children.is_empty());
768    }
769
770    #[test]
771    fn test_timeline_to_html_format() {
772        let visualizer = TraceVisualizer::new();
773        let entries = vec![TimelineEntry {
774            span_id: "span1".to_string(),
775            name: "test".to_string(),
776            start_offset_ms: 0,
777            duration_ms: 100,
778            depth: 0,
779            parent_id: None,
780        }];
781
782        let html = visualizer.timeline_to_html(&entries);
783
784        assert!(html.contains("<html>"));
785        assert!(html.contains("test"));
786        assert!(html.contains("100ms"));
787    }
788
789    #[test]
790    fn test_timeline_to_ascii_format() {
791        let visualizer = TraceVisualizer::new();
792        let entries = vec![TimelineEntry {
793            span_id: "span1".to_string(),
794            name: "test".to_string(),
795            start_offset_ms: 0,
796            duration_ms: 100,
797            depth: 0,
798            parent_id: None,
799        }];
800
801        let ascii = visualizer.timeline_to_ascii(&entries);
802
803        assert!(ascii.contains("├─"));
804        assert!(ascii.contains("test"));
805        assert!(ascii.contains("100ms"));
806    }
807
808    #[test]
809    fn test_statistics_percentiles() {
810        let visualizer = TraceVisualizer::new();
811        let spans = create_test_spans();
812
813        let stats = visualizer.calculate_statistics(&spans);
814
815        let (p50, p95, p99) = stats.duration_percentiles;
816        assert!(p95 >= p50);
817        assert!(p99 >= p95);
818    }
819}