1use 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#[derive(Debug, Clone)]
15pub struct FlameFrame {
16 pub name: String,
18 pub depth: usize,
20 pub cost: u64,
22 pub x_offset: u64,
24 pub resource_type: Option<ResourceType>,
26 pub cost_by_type: BTreeMap<String, u64>,
28 pub duration_us: u64,
30 pub lease_ids: Vec<u128>,
32}
33
34#[derive(Debug, Clone)]
36pub struct FlameGraph {
37 pub frames: Vec<FlameFrame>,
39 pub total_cost: u64,
41}
42
43impl FlameGraph {
44 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 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 cost_by_type.insert(String::from("unknown"), span.lease_cost_byte_secs);
72 }
73
74 let resource_type = dominant_resource_type(&cost_by_type);
76
77 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 #[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 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 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 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 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 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 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 assert_eq!(fg.total_cost, 110);
313
314 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}