grafos_profile/
flame_graph.rs

1//! Resource flame graph — interactive HTML visualization where width = lease cost.
2//!
3//! Color scheme: memory = blue, block = green, GPU = orange, CPU = purple, mixed = gray.
4
5use alloc::collections::BTreeMap;
6use alloc::string::String;
7use alloc::vec::Vec;
8
9use grafos_observe::event::ResourceType;
10
11use crate::span_tree::SpanTree;
12
13/// A single frame in the flame graph.
14#[derive(Debug, Clone)]
15pub struct FlameFrame {
16    /// Span name (or aggregated name for merged siblings).
17    pub name: String,
18    /// Depth in the call hierarchy (0 = bottom/root).
19    pub depth: usize,
20    /// Width as lease cost in byte-seconds.
21    pub cost: u64,
22    /// X offset within the parent (for positioning).
23    pub x_offset: u64,
24    /// Dominant resource type (for coloring).
25    pub resource_type: Option<ResourceType>,
26    /// Per-resource-type cost breakdown.
27    pub cost_by_type: BTreeMap<String, u64>,
28    /// Duration in microseconds.
29    pub duration_us: u64,
30    /// Lease IDs touched.
31    pub lease_ids: Vec<u128>,
32}
33
34/// Resource flame graph computed from a span tree.
35#[derive(Debug, Clone)]
36pub struct FlameGraph {
37    /// All frames in the graph.
38    pub frames: Vec<FlameFrame>,
39    /// Total cost across all root frames.
40    pub total_cost: u64,
41}
42
43impl FlameGraph {
44    /// Build a flame graph from a span tree.
45    pub fn from_span_tree(tree: &SpanTree) -> Self {
46        let mut frames = Vec::new();
47        let total_cost = tree.total_cost();
48
49        for &root_idx in &tree.roots {
50            Self::build_frames(tree, root_idx, 0, &mut frames);
51        }
52
53        FlameGraph { frames, total_cost }
54    }
55
56    fn build_frames(tree: &SpanTree, idx: usize, depth: usize, frames: &mut Vec<FlameFrame>) {
57        let node = &tree.nodes[idx];
58        let span = &node.span;
59
60        // Determine per-type cost breakdown
61        let mut cost_by_type: BTreeMap<String, u64> = BTreeMap::new();
62        let total_ops: u64 = span.ops.iter().map(|(_, c)| c).sum();
63        if total_ops > 0 {
64            for &(ref op_key, count) in &span.ops {
65                let key = alloc::format!("{}", op_key.resource_type);
66                let type_cost = span.lease_cost_byte_secs * count / total_ops;
67                *cost_by_type.entry(key).or_insert(0) += type_cost;
68            }
69        } else if span.lease_cost_byte_secs > 0 {
70            // No ops but has cost — attribute as "unknown"
71            cost_by_type.insert(String::from("unknown"), span.lease_cost_byte_secs);
72        }
73
74        // Determine dominant resource type
75        let resource_type = dominant_resource_type(&cost_by_type);
76
77        // Compute x_offset based on preceding siblings
78        let x_offset = frames
79            .iter()
80            .filter(|f| f.depth == depth)
81            .map(|f| f.cost)
82            .sum();
83
84        frames.push(FlameFrame {
85            name: span.name.clone(),
86            depth,
87            cost: tree.subtree_cost(idx),
88            x_offset,
89            resource_type,
90            cost_by_type,
91            duration_us: span.duration_us(),
92            lease_ids: span.lease_ids.clone(),
93        });
94
95        for &child_idx in &node.children {
96            Self::build_frames(tree, child_idx, depth + 1, frames);
97        }
98    }
99
100    /// Render as self-contained HTML with embedded JavaScript.
101    #[cfg(feature = "html")]
102    pub fn render_html(&self) -> String {
103        let mut html = String::new();
104        html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
105        html.push_str("<meta charset=\"utf-8\">\n");
106        html.push_str("<title>grafOS Resource Flame Graph</title>\n");
107        html.push_str("<style>\n");
108        html.push_str("body { font-family: monospace; margin: 0; padding: 20px; background: #1a1a2e; color: #eee; }\n");
109        html.push_str("h1 { font-size: 18px; margin-bottom: 10px; }\n");
110        html.push_str(".flame-container { position: relative; width: 100%; overflow-x: auto; }\n");
111        html.push_str(
112            ".frame { position: absolute; height: 20px; border: 1px solid rgba(0,0,0,0.3); ",
113        );
114        html.push_str("cursor: pointer; font-size: 11px; line-height: 20px; padding: 0 4px; ");
115        html.push_str("overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; }\n");
116        html.push_str(".frame:hover { border-color: #fff; z-index: 10; }\n");
117        html.push_str(
118            ".tooltip { position: fixed; background: #333; color: #fff; padding: 8px 12px; ",
119        );
120        html.push_str("border-radius: 4px; font-size: 12px; pointer-events: none; z-index: 100; ");
121        html.push_str("max-width: 400px; white-space: pre-wrap; }\n");
122        html.push_str(".legend { margin-bottom: 15px; }\n");
123        html.push_str(".legend span { display: inline-block; margin-right: 15px; }\n");
124        html.push_str(".legend .swatch { display: inline-block; width: 12px; height: 12px; margin-right: 4px; vertical-align: middle; }\n");
125        html.push_str("</style>\n</head>\n<body>\n");
126        html.push_str("<h1>grafOS Resource Flame Graph</h1>\n");
127        html.push_str(&alloc::format!(
128            "<p>Total lease cost: {} byte-seconds</p>\n",
129            self.total_cost
130        ));
131
132        // Legend
133        html.push_str("<div class=\"legend\">\n");
134        html.push_str(
135            "<span><span class=\"swatch\" style=\"background:#4a90d9\"></span>Memory</span>\n",
136        );
137        html.push_str(
138            "<span><span class=\"swatch\" style=\"background:#50c878\"></span>Block</span>\n",
139        );
140        html.push_str(
141            "<span><span class=\"swatch\" style=\"background:#ff8c42\"></span>GPU</span>\n",
142        );
143        html.push_str(
144            "<span><span class=\"swatch\" style=\"background:#9b59b6\"></span>CPU</span>\n",
145        );
146        html.push_str(
147            "<span><span class=\"swatch\" style=\"background:#999\"></span>Mixed/Unknown</span>\n",
148        );
149        html.push_str("</div>\n");
150
151        // Find max depth for container height
152        let max_depth = self.frames.iter().map(|f| f.depth).max().unwrap_or(0);
153        let container_height = (max_depth + 1) * 24 + 40;
154
155        html.push_str(&alloc::format!(
156            "<div class=\"flame-container\" style=\"height:{}px\">\n",
157            container_height
158        ));
159
160        if self.total_cost > 0 {
161            for frame in &self.frames {
162                let width_pct = (frame.cost as f64 / self.total_cost as f64) * 100.0;
163                let x_pct = (frame.x_offset as f64 / self.total_cost as f64) * 100.0;
164                // Flame graphs render bottom-up: root at bottom
165                let y = (max_depth - frame.depth) * 24;
166                let color = resource_type_color(frame.resource_type);
167
168                let lease_str: Vec<String> = frame
169                    .lease_ids
170                    .iter()
171                    .map(|id| alloc::format!("{:x}", id))
172                    .collect();
173
174                html.push_str(&alloc::format!(
175                    "<div class=\"frame\" style=\"left:{x_pct:.4}%;width:{width_pct:.4}%;top:{y}px;background:{color}\" \
176                     data-name=\"{}\" data-cost=\"{}\" data-dur=\"{}\" data-leases=\"{}\">{}</div>\n",
177                    frame.name,
178                    frame.cost,
179                    frame.duration_us,
180                    lease_str.join(","),
181                    frame.name,
182                ));
183            }
184        }
185
186        html.push_str("</div>\n");
187
188        // Tooltip JavaScript
189        html.push_str("<div class=\"tooltip\" id=\"tip\" style=\"display:none\"></div>\n");
190        html.push_str("<script>\n");
191        html.push_str("document.querySelectorAll('.frame').forEach(f => {\n");
192        html.push_str("  f.addEventListener('mouseenter', e => {\n");
193        html.push_str("    const t = document.getElementById('tip');\n");
194        html.push_str("    const name = f.dataset.name;\n");
195        html.push_str("    const cost = parseInt(f.dataset.cost);\n");
196        html.push_str("    const dur = parseInt(f.dataset.dur);\n");
197        html.push_str("    const leases = f.dataset.leases;\n");
198        html.push_str("    t.textContent = `${name}\\ncost: ${cost} byte-secs\\nduration: ${dur} us\\nleases: ${leases || 'none'}`;\n");
199        html.push_str("    t.style.display = 'block';\n");
200        html.push_str("    t.style.left = (e.clientX + 10) + 'px';\n");
201        html.push_str("    t.style.top = (e.clientY + 10) + 'px';\n");
202        html.push_str("  });\n");
203        html.push_str("  f.addEventListener('mouseleave', () => {\n");
204        html.push_str("    document.getElementById('tip').style.display = 'none';\n");
205        html.push_str("  });\n");
206        html.push_str("  f.addEventListener('mousemove', e => {\n");
207        html.push_str("    const t = document.getElementById('tip');\n");
208        html.push_str("    t.style.left = (e.clientX + 10) + 'px';\n");
209        html.push_str("    t.style.top = (e.clientY + 10) + 'px';\n");
210        html.push_str("  });\n");
211        html.push_str("});\n");
212        html.push_str("</script>\n");
213        html.push_str("</body>\n</html>\n");
214
215        html
216    }
217}
218
219fn dominant_resource_type(cost_by_type: &BTreeMap<String, u64>) -> Option<ResourceType> {
220    cost_by_type
221        .iter()
222        .max_by_key(|(_, &v)| v)
223        .and_then(|(k, _)| match k.as_str() {
224            "mem" => Some(ResourceType::Mem),
225            "block" => Some(ResourceType::Block),
226            "gpu" => Some(ResourceType::Gpu),
227            "cpu" => Some(ResourceType::Cpu),
228            _ => None,
229        })
230}
231
232fn resource_type_color(rt: Option<ResourceType>) -> &'static str {
233    match rt {
234        Some(ResourceType::Mem) => "#4a90d9",
235        Some(ResourceType::Block) => "#50c878",
236        Some(ResourceType::Gpu) => "#ff8c42",
237        Some(ResourceType::Cpu) => "#9b59b6",
238        Some(ResourceType::Net) => "#e74c3c",
239        None => "#999",
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use grafos_observe::event::{OpType, ResourceType};
247    use grafos_observe::span::ResourceSpan;
248    use grafos_observe::trace::TraceContext;
249
250    fn test_ctx() -> TraceContext {
251        let mut bytes = [0u8; 24];
252        for (i, b) in bytes.iter_mut().enumerate() {
253            *b = (i as u8).wrapping_add(0x42);
254        }
255        TraceContext::new_root(&bytes)
256    }
257
258    #[test]
259    fn flame_graph_from_tree() {
260        let root_ctx = test_ctx();
261        let child_ctx = root_ctx.child(&[0xAA; 8]);
262
263        let mut root = ResourceSpan::new("root", root_ctx);
264        root.lease_cost_byte_secs = 100;
265        root.record_op(ResourceType::Mem, OpType::Read, 10);
266
267        let mut child = ResourceSpan::new("child", child_ctx);
268        child.parent_span_id = Some(root_ctx.span_id);
269        child.lease_cost_byte_secs = 50;
270        child.record_op(ResourceType::Block, OpType::WriteBlock, 5);
271
272        let tree = crate::SpanTree::build(&[root, child]);
273        let fg = FlameGraph::from_span_tree(&tree);
274
275        assert_eq!(fg.total_cost, 150);
276        assert_eq!(fg.frames.len(), 2);
277
278        // Root frame should have subtree cost (150)
279        assert_eq!(fg.frames[0].name, "root");
280        assert_eq!(fg.frames[0].cost, 150);
281        assert_eq!(fg.frames[0].depth, 0);
282        assert_eq!(fg.frames[0].resource_type, Some(ResourceType::Mem));
283
284        // Child frame should have own cost (50)
285        assert_eq!(fg.frames[1].name, "child");
286        assert_eq!(fg.frames[1].cost, 50);
287        assert_eq!(fg.frames[1].depth, 1);
288        assert_eq!(fg.frames[1].resource_type, Some(ResourceType::Block));
289    }
290
291    #[test]
292    fn proportional_widths() {
293        let ctx = test_ctx();
294        let c1 = ctx.child(&[0xAA; 8]);
295        let c2 = ctx.child(&[0xBB; 8]);
296
297        let mut root = ResourceSpan::new("root", ctx);
298        root.lease_cost_byte_secs = 10;
299
300        let mut big_child = ResourceSpan::new("big", c1);
301        big_child.parent_span_id = Some(ctx.span_id);
302        big_child.lease_cost_byte_secs = 90;
303
304        let mut small_child = ResourceSpan::new("small", c2);
305        small_child.parent_span_id = Some(ctx.span_id);
306        small_child.lease_cost_byte_secs = 10;
307
308        let tree = crate::SpanTree::build(&[root, big_child, small_child]);
309        let fg = FlameGraph::from_span_tree(&tree);
310
311        // Root subtree cost = 10 + 90 + 10 = 110
312        assert_eq!(fg.total_cost, 110);
313
314        // Big child should have 9x the cost of small child
315        let big = fg.frames.iter().find(|f| f.name == "big").unwrap();
316        let small = fg.frames.iter().find(|f| f.name == "small").unwrap();
317        assert_eq!(big.cost, 90);
318        assert_eq!(small.cost, 10);
319    }
320
321    #[cfg(feature = "html")]
322    #[test]
323    fn html_output_structure() {
324        let ctx = test_ctx();
325        let mut span = ResourceSpan::new("test_span", ctx);
326        span.lease_cost_byte_secs = 100;
327
328        let tree = crate::SpanTree::build(&[span]);
329        let fg = FlameGraph::from_span_tree(&tree);
330        let html = fg.render_html();
331
332        assert!(html.contains("<!DOCTYPE html>"));
333        assert!(html.contains("grafOS Resource Flame Graph"));
334        assert!(html.contains("test_span"));
335        assert!(html.contains("</html>"));
336    }
337}