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            "gpu_mem" => Some(ResourceType::GpuMem),
228            "cpu" => Some(ResourceType::Cpu),
229            _ => None,
230        })
231}
232
233fn resource_type_color(rt: Option<ResourceType>) -> &'static str {
234    match rt {
235        Some(ResourceType::Mem) => "#4a90d9",
236        Some(ResourceType::Block) => "#50c878",
237        Some(ResourceType::Gpu) => "#ff8c42",
238        Some(ResourceType::GpuMem) => "#d97706",
239        Some(ResourceType::Cpu) => "#9b59b6",
240        Some(ResourceType::Net) => "#e74c3c",
241        None => "#999",
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use grafos_observe::event::{OpType, ResourceType};
249    use grafos_observe::span::ResourceSpan;
250    use grafos_observe::trace::TraceContext;
251
252    fn test_ctx() -> TraceContext {
253        let mut bytes = [0u8; 24];
254        for (i, b) in bytes.iter_mut().enumerate() {
255            *b = (i as u8).wrapping_add(0x42);
256        }
257        TraceContext::new_root(&bytes)
258    }
259
260    #[test]
261    fn flame_graph_from_tree() {
262        let root_ctx = test_ctx();
263        let child_ctx = root_ctx.child(&[0xAA; 8]);
264
265        let mut root = ResourceSpan::new("root", root_ctx);
266        root.lease_cost_byte_secs = 100;
267        root.record_op(ResourceType::Mem, OpType::Read, 10);
268
269        let mut child = ResourceSpan::new("child", child_ctx);
270        child.parent_span_id = Some(root_ctx.span_id);
271        child.lease_cost_byte_secs = 50;
272        child.record_op(ResourceType::Block, OpType::WriteBlock, 5);
273
274        let tree = crate::SpanTree::build(&[root, child]);
275        let fg = FlameGraph::from_span_tree(&tree);
276
277        assert_eq!(fg.total_cost, 150);
278        assert_eq!(fg.frames.len(), 2);
279
280        // Root frame should have subtree cost (150)
281        assert_eq!(fg.frames[0].name, "root");
282        assert_eq!(fg.frames[0].cost, 150);
283        assert_eq!(fg.frames[0].depth, 0);
284        assert_eq!(fg.frames[0].resource_type, Some(ResourceType::Mem));
285
286        // Child frame should have own cost (50)
287        assert_eq!(fg.frames[1].name, "child");
288        assert_eq!(fg.frames[1].cost, 50);
289        assert_eq!(fg.frames[1].depth, 1);
290        assert_eq!(fg.frames[1].resource_type, Some(ResourceType::Block));
291    }
292
293    #[test]
294    fn proportional_widths() {
295        let ctx = test_ctx();
296        let c1 = ctx.child(&[0xAA; 8]);
297        let c2 = ctx.child(&[0xBB; 8]);
298
299        let mut root = ResourceSpan::new("root", ctx);
300        root.lease_cost_byte_secs = 10;
301
302        let mut big_child = ResourceSpan::new("big", c1);
303        big_child.parent_span_id = Some(ctx.span_id);
304        big_child.lease_cost_byte_secs = 90;
305
306        let mut small_child = ResourceSpan::new("small", c2);
307        small_child.parent_span_id = Some(ctx.span_id);
308        small_child.lease_cost_byte_secs = 10;
309
310        let tree = crate::SpanTree::build(&[root, big_child, small_child]);
311        let fg = FlameGraph::from_span_tree(&tree);
312
313        // Root subtree cost = 10 + 90 + 10 = 110
314        assert_eq!(fg.total_cost, 110);
315
316        // Big child should have 9x the cost of small child
317        let big = fg.frames.iter().find(|f| f.name == "big").unwrap();
318        let small = fg.frames.iter().find(|f| f.name == "small").unwrap();
319        assert_eq!(big.cost, 90);
320        assert_eq!(small.cost, 10);
321    }
322
323    #[cfg(feature = "html")]
324    #[test]
325    fn html_output_structure() {
326        let ctx = test_ctx();
327        let mut span = ResourceSpan::new("test_span", ctx);
328        span.lease_cost_byte_secs = 100;
329
330        let tree = crate::SpanTree::build(&[span]);
331        let fg = FlameGraph::from_span_tree(&tree);
332        let html = fg.render_html();
333
334        assert!(html.contains("<!DOCTYPE html>"));
335        assert!(html.contains("grafOS Resource Flame Graph"));
336        assert!(html.contains("test_span"));
337        assert!(html.contains("</html>"));
338    }
339}