SpanTree Component
The SpanTree component renders a hierarchical waterfall visualization for distributed trace spans. It displays spans with timing bars, collapsible children, and a resizable layout.
Installation
pnpm dlx shadcn@latest add https://spotlightjs.com/r/span-tree.jsonThis installs:
span-tree.tsx- Main tree componentspan-item.tsx- Individual span row componentspan-resizer.tsx- Column resize handletypes.ts- TypeScript type definitionsduration.ts- Duration formatting utilitiesutils.ts- Utility functions (cn, truncateId)
Basic Usage
import { SpanTree } from "@/components/span-tree";
function TraceView({ trace }) { const [selectedSpanId, setSelectedSpanId] = useState<string>();
return ( <SpanTree spans={trace.spanTree} traceStartTimestamp={trace.start_timestamp} traceDuration={trace.timestamp - trace.start_timestamp} selectedSpanId={selectedSpanId} onSpanSelect={(id, span) => { setSelectedSpanId(id); console.log("Selected span:", span); }} /> );}Props
SpanTree Props
| Prop | Type | Default | Description |
|---|---|---|---|
spans | SpanData[] | required | Array of root spans with children populated |
traceStartTimestamp | number | required | Start timestamp of the trace (for positioning) |
traceDuration | number | required | Total duration in milliseconds |
selectedSpanId | string | - | Currently selected span ID |
onSpanSelect | (id: string, span: SpanData) => void | - | Callback when span is clicked |
highlightedSpanIds | Set<string> | - | Span IDs to highlight (e.g., search results) |
initialNodeWidth | number | 50 | Initial width of name column (percentage) |
className | string | - | Additional CSS classes |
SpanData Interface
interface SpanData { span_id: string; trace_id?: string; parent_span_id?: string | null; op?: string | null; description?: string | null; start_timestamp: number; timestamp: number; status?: "ok" | "error" | string; children?: SpanData[]; data?: Record<string, unknown>; tags?: Record<string, string>;}Features
Collapsible Children
Spans with children display a collapse/expand button. The tree auto-collapses:
- Spans at depth >= 10
- Spans with more than 10 children
// Children are automatically rendered when collapsed state changes<SpanTree spans={deepTrace.spanTree} traceStartTimestamp={deepTrace.start_timestamp} traceDuration={traceDuration}/>Resizable Columns
Users can drag the divider between the span name column and the waterfall to resize:
<SpanTree spans={spans} traceStartTimestamp={start} traceDuration={duration} initialNodeWidth={40} // Start with 40% for name column/>Highlighting
Highlight specific spans (useful for search):
const searchResults = useMemo(() => { const matchingIds = new Set<string>(); const findMatches = (spans: SpanData[]) => { for (const span of spans) { if (span.op?.includes(query) || span.description?.includes(query)) { matchingIds.add(span.span_id); } if (span.children) findMatches(span.children); } }; findMatches(trace.spanTree); return matchingIds;}, [trace, query]);
<SpanTree spans={trace.spanTree} traceStartTimestamp={trace.start_timestamp} traceDuration={traceDuration} highlightedSpanIds={searchResults}/>Duration Color Coding
Span durations are color-coded based on thresholds:
| Duration | Color | CSS Class |
|---|---|---|
| > 1000ms | Red | text-destructive |
| > 500ms | Orange | text-orange-500 |
| > 100ms | Yellow | text-yellow-500 |
| < 100ms | Gray | text-muted-foreground |
To customize these thresholds, modify duration.ts after installation.
Styling
The component uses these CSS variables:
/* Tree connector lines */.span-tree.deep > li { border-left: 1px solid hsl(var(--border));}
/* Selected state */.span-item.selected { background: hsl(var(--muted));}
/* Highlighted state (search results) */.span-item.highlighted { background: hsl(var(--primary) / 0.1);}Custom Styling
<SpanTree spans={spans} traceStartTimestamp={start} traceDuration={duration} className="rounded-lg border"/>Building Span Trees
The component expects spans with children already populated. Here’s a utility to build the tree:
function buildSpanTree(spans: SpanData[]): SpanData[] { const spanMap = new Map(spans.map(s => [s.span_id, { ...s, children: [] }])); const roots: SpanData[] = [];
for (const span of spanMap.values()) { if (span.parent_span_id && spanMap.has(span.parent_span_id)) { spanMap.get(span.parent_span_id)!.children!.push(span); } else { roots.push(span); } }
// Sort children by start time const sortChildren = (span: SpanData) => { span.children?.sort((a, b) => a.start_timestamp - b.start_timestamp); span.children?.forEach(sortChildren); }; roots.forEach(sortChildren); roots.sort((a, b) => a.start_timestamp - b.start_timestamp);
return roots;}Examples
With Span Details Panel
function TraceViewer({ trace }) { const [selectedSpan, setSelectedSpan] = useState<SpanData>();
return ( <div className="flex h-full"> <div className="flex-1 overflow-auto"> <SpanTree spans={trace.spanTree} traceStartTimestamp={trace.start_timestamp} traceDuration={trace.timestamp - trace.start_timestamp} selectedSpanId={selectedSpan?.span_id} onSpanSelect={(_, span) => setSelectedSpan(span)} /> </div> {selectedSpan && ( <div className="w-80 border-l p-4"> <h3 className="font-bold">{selectedSpan.op}</h3> <p className="text-sm text-muted-foreground"> {selectedSpan.description} </p> <pre className="mt-4 text-xs"> {JSON.stringify(selectedSpan.data, null, 2)} </pre> </div> )} </div> );}Filtered View
function FilteredSpanTree({ trace, filter }) { const filteredSpans = useMemo(() => { const filterSpans = (spans: SpanData[]): SpanData[] => { return spans .filter(span => !filter || span.op?.includes(filter)) .map(span => ({ ...span, children: span.children ? filterSpans(span.children) : undefined, })); }; return filterSpans(trace.spanTree); }, [trace, filter]);
return ( <SpanTree spans={filteredSpans} traceStartTimestamp={trace.start_timestamp} traceDuration={trace.timestamp - trace.start_timestamp} /> );}