Skip to main content

jugar_probar/perf/
export.rs

1//! Performance Trace Export Formats
2//!
3//! Export traces to Chrome Trace JSON, Flame Graphs, and CI metrics.
4
5use super::trace::Trace;
6use serde::{Deserialize, Serialize};
7
8/// Chrome Trace format for chrome://tracing
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ChromeTrace {
11    /// Trace events
12    #[serde(rename = "traceEvents")]
13    pub trace_events: Vec<ChromeTraceEvent>,
14}
15
16/// Single event in Chrome Trace format
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ChromeTraceEvent {
19    /// Event name
20    pub name: String,
21    /// Category
22    pub cat: String,
23    /// Phase (B=begin, E=end, X=complete)
24    pub ph: String,
25    /// Timestamp in microseconds
26    pub ts: u64,
27    /// Duration in microseconds (for X events)
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub dur: Option<u64>,
30    /// Process ID
31    pub pid: u32,
32    /// Thread ID
33    pub tid: u32,
34    /// Arguments
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub args: Option<serde_json::Value>,
37}
38
39impl ChromeTrace {
40    /// Create Chrome Trace from a trace
41    #[must_use]
42    pub fn from_trace(trace: &Trace) -> Self {
43        let mut events = Vec::new();
44
45        for span in &trace.spans {
46            if let Some(dur_ns) = span.duration_ns() {
47                events.push(ChromeTraceEvent {
48                    name: span.name.clone(),
49                    cat: span
50                        .category
51                        .clone()
52                        .unwrap_or_else(|| "default".to_string()),
53                    ph: "X".to_string(),      // Complete event
54                    ts: span.start_ns / 1000, // Convert to microseconds
55                    dur: Some(dur_ns / 1000),
56                    pid: 1,
57                    tid: 1,
58                    args: if span.metadata.is_empty() {
59                        None
60                    } else {
61                        Some(serde_json::json!(span.metadata))
62                    },
63                });
64            }
65        }
66
67        Self {
68            trace_events: events,
69        }
70    }
71
72    /// Export to JSON string
73    #[must_use]
74    pub fn to_json(&self) -> String {
75        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
76    }
77
78    /// Export to compact JSON
79    #[must_use]
80    pub fn to_json_compact(&self) -> String {
81        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
82    }
83}
84
85/// Flame graph data structure
86#[derive(Debug, Clone)]
87pub struct FlameGraph {
88    /// Stacks with counts
89    stacks: Vec<FlameStack>,
90}
91
92/// A stack in the flame graph
93#[derive(Debug, Clone)]
94pub struct FlameStack {
95    /// Stack frames (from root to leaf)
96    pub frames: Vec<String>,
97    /// Total time in this stack (ms)
98    pub value: f64,
99}
100
101impl FlameGraph {
102    /// Create flame graph from trace
103    #[must_use]
104    pub fn from_trace(trace: &Trace) -> Self {
105        // Build stack traces from spans
106        let mut stacks = Vec::new();
107
108        // Simple approach: each span is a stack
109        for span in &trace.spans {
110            if let Some(dur_ns) = span.duration_ns() {
111                let mut frames = Vec::new();
112
113                // Build frame stack by following parents
114                frames.push(span.name.clone());
115
116                // Find parent names
117                if let Some(parent_id) = span.parent {
118                    if let Some(parent) = trace.spans.iter().find(|s| s.id == parent_id) {
119                        frames.insert(0, parent.name.clone());
120                    }
121                }
122
123                stacks.push(FlameStack {
124                    frames,
125                    value: dur_ns as f64 / 1_000_000.0,
126                });
127            }
128        }
129
130        Self { stacks }
131    }
132
133    /// Export to collapsed stack format (for FlameGraph tools)
134    #[must_use]
135    pub fn to_collapsed(&self) -> String {
136        let mut output = String::new();
137
138        for stack in &self.stacks {
139            let stack_str = stack.frames.join(";");
140            output.push_str(&format!("{} {}\n", stack_str, stack.value as u64));
141        }
142
143        output
144    }
145
146    /// Generate simple SVG flame graph
147    #[must_use]
148    pub fn to_svg(&self, width: u32, height: u32) -> String {
149        let mut svg = format!(
150            r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">"#,
151            width, height, width, height
152        );
153
154        svg.push_str(
155            r#"
156  <style>
157    .frame { stroke: #333; stroke-width: 0.5; }
158    .frame:hover { stroke: #000; stroke-width: 1; }
159    text { font-family: monospace; font-size: 10px; fill: #333; }
160  </style>
161"#,
162        );
163
164        // Simple rendering: each span as a rectangle
165        let total_value: f64 = self.stacks.iter().map(|s| s.value).sum();
166        if total_value > 0.0 {
167            let mut y = 0.0;
168            let bar_height = 20.0;
169
170            for stack in &self.stacks {
171                let w = (stack.value / total_value) * width as f64;
172                if w > 1.0 {
173                    let color = random_color(stack.frames.last().unwrap_or(&String::new()));
174                    svg.push_str(&format!(
175                        r#"  <rect class="frame" x="0" y="{}" width="{}" height="{}" fill="{}"><title>{}: {:.2}ms</title></rect>"#,
176                        y, w, bar_height, color,
177                        stack.frames.join(" → "), stack.value
178                    ));
179                    svg.push('\n');
180                    y += bar_height;
181                }
182            }
183        }
184
185        svg.push_str("</svg>");
186        svg
187    }
188}
189
190/// Generate a deterministic color from a string
191fn random_color(s: &str) -> String {
192    let hash: u32 = s
193        .bytes()
194        .fold(0, |acc, b| acc.wrapping_add(b as u32).wrapping_mul(31));
195    let hue = hash % 360;
196    format!("hsl({}, 70%, 60%)", hue)
197}
198
199/// CI-friendly metrics export
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct CiMetrics {
202    /// Number of spans recorded
203    pub span_count: usize,
204    /// Total trace duration in ms
205    pub duration_ms: f64,
206    /// Function timing summaries
207    pub functions: Vec<FunctionMetric>,
208    /// Pass/fail status
209    pub passed: bool,
210    /// Failure reasons
211    pub failures: Vec<String>,
212}
213
214/// Metric for a single function/span type
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct FunctionMetric {
217    /// Function/span name
218    pub name: String,
219    /// Call count
220    pub count: usize,
221    /// Mean duration (ms)
222    pub mean_ms: f64,
223    /// P99 duration (ms)
224    pub p99_ms: f64,
225    /// Total time (ms)
226    pub total_ms: f64,
227}
228
229impl CiMetrics {
230    /// Create CI metrics from trace
231    #[must_use]
232    pub fn from_trace(trace: &Trace) -> Self {
233        let perf = super::metrics::PerformanceMetrics::from_trace(trace);
234
235        let functions: Vec<FunctionMetric> = perf
236            .function_times
237            .iter()
238            .map(|(name, stats)| FunctionMetric {
239                name: name.clone(),
240                count: stats.count,
241                mean_ms: stats.mean,
242                p99_ms: stats.p99,
243                total_ms: stats.mean * stats.count as f64,
244            })
245            .collect();
246
247        Self {
248            span_count: trace.span_count(),
249            duration_ms: trace
250                .duration
251                .map(|d| d.as_secs_f64() * 1000.0)
252                .unwrap_or(0.0),
253            functions,
254            passed: true,
255            failures: Vec::new(),
256        }
257    }
258
259    /// Check against thresholds
260    #[must_use]
261    pub fn check_thresholds(&self, max_p99_ms: f64) -> Self {
262        let mut result = self.clone();
263        result.failures.clear();
264        result.passed = true;
265
266        for func in &self.functions {
267            if func.p99_ms > max_p99_ms {
268                result.failures.push(format!(
269                    "{}: p99 {:.2}ms exceeds threshold {:.2}ms",
270                    func.name, func.p99_ms, max_p99_ms
271                ));
272                result.passed = false;
273            }
274        }
275
276        result
277    }
278
279    /// Export to JSON
280    #[must_use]
281    pub fn to_json(&self) -> String {
282        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
283    }
284}
285
286#[cfg(test)]
287#[allow(clippy::unwrap_used, clippy::expect_used)]
288mod tests {
289    use super::*;
290    use crate::perf::trace::Tracer;
291
292    fn create_test_trace() -> Trace {
293        let mut tracer = Tracer::new();
294        tracer.start();
295
296        {
297            let _outer = tracer.span("render");
298            std::thread::sleep(std::time::Duration::from_micros(100));
299            {
300                let _inner = tracer.span("draw");
301                std::thread::sleep(std::time::Duration::from_micros(50));
302            }
303        }
304
305        tracer.stop()
306    }
307
308    #[test]
309    fn test_chrome_trace_from_trace() {
310        let trace = create_test_trace();
311        let chrome = ChromeTrace::from_trace(&trace);
312
313        assert_eq!(chrome.trace_events.len(), 2);
314    }
315
316    #[test]
317    fn test_chrome_trace_to_json() {
318        let trace = create_test_trace();
319        let chrome = ChromeTrace::from_trace(&trace);
320        let json = chrome.to_json();
321
322        assert!(json.contains("traceEvents"));
323        assert!(json.contains("render"));
324        assert!(json.contains("draw"));
325    }
326
327    #[test]
328    fn test_chrome_trace_event_fields() {
329        let trace = create_test_trace();
330        let chrome = ChromeTrace::from_trace(&trace);
331
332        let event = &chrome.trace_events[0];
333        assert!(!event.name.is_empty());
334        assert_eq!(event.ph, "X");
335        assert!(event.dur.is_some());
336    }
337
338    #[test]
339    fn test_flame_graph_from_trace() {
340        let trace = create_test_trace();
341        let flame = FlameGraph::from_trace(&trace);
342
343        assert!(!flame.stacks.is_empty());
344    }
345
346    #[test]
347    fn test_flame_graph_to_collapsed() {
348        let trace = create_test_trace();
349        let flame = FlameGraph::from_trace(&trace);
350        let collapsed = flame.to_collapsed();
351
352        assert!(!collapsed.is_empty());
353        assert!(collapsed.contains("render") || collapsed.contains("draw"));
354    }
355
356    #[test]
357    fn test_flame_graph_to_svg() {
358        let trace = create_test_trace();
359        let flame = FlameGraph::from_trace(&trace);
360        let svg = flame.to_svg(800, 400);
361
362        assert!(svg.starts_with("<svg"));
363        assert!(svg.ends_with("</svg>"));
364        assert!(svg.contains("rect"));
365    }
366
367    #[test]
368    fn test_ci_metrics_from_trace() {
369        let trace = create_test_trace();
370        let metrics = CiMetrics::from_trace(&trace);
371
372        assert_eq!(metrics.span_count, 2);
373        assert!(metrics.passed);
374    }
375
376    #[test]
377    fn test_ci_metrics_to_json() {
378        let trace = create_test_trace();
379        let metrics = CiMetrics::from_trace(&trace);
380        let json = metrics.to_json();
381
382        assert!(json.contains("span_count"));
383        assert!(json.contains("functions"));
384    }
385
386    #[test]
387    fn test_ci_metrics_check_thresholds() {
388        let trace = create_test_trace();
389        let metrics = CiMetrics::from_trace(&trace);
390
391        // Very tight threshold may or may not fail depending on timing
392        let _checked = metrics.check_thresholds(0.001);
393
394        // Very loose threshold should pass
395        let checked = metrics.check_thresholds(10000.0);
396        assert!(checked.passed);
397    }
398
399    #[test]
400    fn test_random_color() {
401        let c1 = random_color("test");
402        let c2 = random_color("test");
403        let c3 = random_color("other");
404
405        // Same input = same output
406        assert_eq!(c1, c2);
407        // Different input = different output (usually)
408        assert_ne!(c1, c3);
409        // Valid HSL format
410        assert!(c1.starts_with("hsl("));
411    }
412}