Skip to content

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

Terminal window
pnpm dlx shadcn@latest add https://spotlightjs.com/r/span-tree.json

This installs:

  • span-tree.tsx - Main tree component
  • span-item.tsx - Individual span row component
  • span-resizer.tsx - Column resize handle
  • types.ts - TypeScript type definitions
  • duration.ts - Duration formatting utilities
  • utils.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

PropTypeDefaultDescription
spansSpanData[]requiredArray of root spans with children populated
traceStartTimestampnumberrequiredStart timestamp of the trace (for positioning)
traceDurationnumberrequiredTotal duration in milliseconds
selectedSpanIdstring-Currently selected span ID
onSpanSelect(id: string, span: SpanData) => void-Callback when span is clicked
highlightedSpanIdsSet<string>-Span IDs to highlight (e.g., search results)
initialNodeWidthnumber50Initial width of name column (percentage)
classNamestring-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:

DurationColorCSS Class
> 1000msRedtext-destructive
> 500msOrangetext-orange-500
> 100msYellowtext-yellow-500
< 100msGraytext-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}
/>
);
}