/**
* Interactive Flame Graph for Trace Visualization
*
* Features:
* - Stack-based visualization showing execution hierarchy
* - Click to zoom into specific spans
* - Search and highlight functionality
* - Hover tooltips with span details
* - Color-coding by span type
*/
/* eslint-disable no-unused-vars */
class FlameGraph {
constructor(containerId, traceData) {
this.container = document.getElementById(containerId);
this.trace = traceData;
this.spans = this.buildSpanTree(traceData.spans);
this.width = 0;
this.height = 0;
this.cellHeight = 20;
this.textPadding = 5;
this.rootNode = null;
this.currentRoot = null;
this.searchTerm = "";
this.init();
}
/**
* Build hierarchical span tree from flat span list
*/
buildSpanTree(spans) {
const spanMap = new Map();
let rootSpan = null;
// Create map of all spans
spans.forEach((span) => {
spanMap.set(span.span_id, {
...span,
children: [],
});
});
// Build tree structure
spans.forEach((span) => {
const node = spanMap.get(span.span_id);
if (span.parent_span_id && spanMap.has(span.parent_span_id)) {
const parent = spanMap.get(span.parent_span_id);
parent.children.push(node);
} else {
// This is a root node
if (!rootSpan || node.start_time < rootSpan.start_time) {
rootSpan = node;
}
}
});
// Calculate total duration for each node (self + children)
const calculateTotalDuration = (node) => {
const total = node.duration_ms || 0;
node.children.forEach((child) => {
calculateTotalDuration(child);
});
node.totalDuration = total;
return total;
};
if (rootSpan) {
calculateTotalDuration(rootSpan);
}
return rootSpan;
}
/**
* Initialize the flame graph
*/
init() {
this.rootNode = this.spans;
this.currentRoot = this.rootNode;
this.render();
}
/**
* Render the complete flame graph
*/
render() {
if (!this.rootNode) {
this.container.innerHTML = `
<div style="padding: 2rem; text-align: center; color: #6b7280;">
No span data available for flame graph
</div>
`;
return;
}
// Calculate dimensions
this.width = this.container.clientWidth || 800;
const depth = this.calculateDepth(this.currentRoot);
this.height = depth * this.cellHeight + 100;
const html = `
<div class="flame-graph-container">
<!-- Toolbar -->
<div class="flame-toolbar">
<div class="flame-info">
<strong>Flame Graph</strong>
<span style="margin-left: 1rem; color: #6b7280;">
${this.currentRoot.name} - ${this.currentRoot.duration_ms?.toFixed(2) || 0} ms
</span>
</div>
<div class="flame-controls">
<input
type="text"
placeholder="Search spans..."
class="flame-search"
onkeyup="flameGraph.search(this.value)"
value="${this.searchTerm}"
/>
<button onclick="flameGraph.reset()" class="flame-btn" title="Reset Zoom">
⟲ Reset
</button>
</div>
</div>
<!-- SVG Canvas -->
<svg class="flame-svg" width="${this.width}" height="${this.height}">
${this.renderNode(this.currentRoot, 0, 0, this.width)}
</svg>
<!-- Legend -->
<div class="flame-legend">
<div class="legend-item">
<span class="legend-color" style="background: #3b82f6;"></span>
<span>Client</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #10b981;"></span>
<span>Server</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #8b5cf6;"></span>
<span>Internal</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #ef4444;"></span>
<span>Error</span>
</div>
<div class="legend-item" style="font-size: 0.75rem; color: #6b7280; margin-left: 1rem;">
💡 Click on any span to zoom in
</div>
</div>
</div>
`;
this.container.innerHTML = html;
}
/**
* Calculate the maximum depth of the tree
*/
calculateDepth(node, currentDepth = 0) {
if (!node || !node.children || node.children.length === 0) {
return currentDepth + 1;
}
let maxDepth = currentDepth + 1;
node.children.forEach((child) => {
const childDepth = this.calculateDepth(child, currentDepth + 1);
maxDepth = Math.max(maxDepth, childDepth);
});
return maxDepth;
}
/**
* Render a single node and its children recursively
*/
renderNode(node, x, y, width, parentDuration = null) {
if (!node) {
return "";
}
const duration = node.duration_ms || 0;
const totalParentDuration = parentDuration || duration;
// Calculate width based on duration percentage
const nodeWidth = (duration / totalParentDuration) * width;
if (nodeWidth < 0.5) {
return ""; // Too small to render
}
// Determine color based on span kind and search
const isSearchMatch =
this.searchTerm &&
node.name.toLowerCase().includes(this.searchTerm.toLowerCase());
let color = this.getSpanColor(node);
if (isSearchMatch) {
color = "#f59e0b"; // Highlight search matches in orange
}
// Truncate text if too long for the box
const availableWidth = nodeWidth - this.textPadding * 2;
const charWidth = 7; // Approximate character width
const maxChars = Math.floor(availableWidth / charWidth);
let displayText = node.name;
if (displayText.length > maxChars && maxChars > 3) {
displayText = displayText.substring(0, maxChars - 3) + "...";
}
// Generate SVG rectangle with text
let svg = `
<g class="flame-node ${isSearchMatch ? "search-match" : ""}"
data-span-id="${node.span_id}"
onclick="flameGraph.zoomTo('${node.span_id}')"
style="cursor: pointer;">
<rect
x="${x}"
y="${y}"
width="${nodeWidth}"
height="${this.cellHeight - 1}"
fill="${color}"
stroke="#fff"
stroke-width="1"
rx="2"
/>
<title>${node.name}\nDuration: ${duration.toFixed(2)}ms\nKind: ${node.kind}\nStatus: ${node.status}</title>
${
nodeWidth > 30
? `<text
x="${x + this.textPadding}"
y="${y + this.cellHeight / 2 + 4}"
fill="#fff"
font-size="12"
font-family="system-ui, -apple-system, sans-serif"
pointer-events="none"
>${displayText} (${duration.toFixed(1)}ms)</text>`
: ""
}
</g>
`;
// Render children below this node
if (node.children && node.children.length > 0) {
let childX = x;
node.children.forEach((child) => {
const childDuration = child.duration_ms || 0;
const childWidth = (childDuration / duration) * nodeWidth;
svg += this.renderNode(
child,
childX,
y + this.cellHeight,
childWidth,
duration,
);
childX += childWidth;
});
}
return svg;
}
/**
* Get color for a span based on its kind and status
*/
getSpanColor(span) {
if (span.status === "error") {
return "#ef4444"; // red
}
switch (span.kind) {
case "client":
return "#3b82f6"; // blue
case "server":
return "#10b981"; // green
case "internal":
return "#8b5cf6"; // purple
default:
return "#6b7280"; // gray
}
}
/**
* Find node by span_id
*/
findNode(node, spanId) {
if (node.span_id === spanId) {
return node;
}
if (node.children) {
for (const child of node.children) {
const found = this.findNode(child, spanId);
if (found) {
return found;
}
}
}
return null;
}
/**
* Zoom to a specific node
*/
zoomTo(spanId) {
const node = this.findNode(this.rootNode, spanId);
if (node) {
this.currentRoot = node;
this.render();
}
}
/**
* Reset zoom to root
*/
reset() {
this.currentRoot = this.rootNode;
this.searchTerm = "";
this.render();
}
/**
* Search for spans by name
*/
search(term) {
this.searchTerm = term;
this.render();
}
}
// Global instance (will be initialized from template)
// eslint-disable-next-line prefer-const
let flameGraph = null;