/** * TraceTree — collapsible execution tree + latency waterfall (#119). * * Reconstructs the parent/child span hierarchy that OTLP ingest preserves in * each event's metadata (via `assembleTrace` in core) and renders it as an * audit-grade tree: per-node latency bar on a shared trace timescale, subtree * cost rolled up from descendant llm_response cost, verified-agent identity, * and delegation boundaries (a child whose verified identity differs from its * parent) flagged inline. Integrity is shown honestly — OTLP traces are * record-integrity / unchained, never a false "Chain Valid". * * Degrades to nothing useful when there's no span metadata; callers should only * offer the Tree view when `hasTraceData(events)` is true. */ import { useMemo, useState } from 'react'; import type { AgentLensEvent, TraceTree as TraceTreeData, TraceNode } from '@agentkitai/agentlens-core'; import { getEventStyle, formatMs, formatCost, ChainBadge } from './Timeline'; /** True when at least one event carries span context (so a tree is meaningful). */ export function hasTraceData(events: AgentLensEvent[]): boolean { return events.some((e) => typeof e.metadata?.spanId === 'string'); } export interface TraceTreeProps { /** Server-assembled tree (#119 — from GET /api/sessions/:id/trace). */ tree: TraceTreeData; /** Source events, used to map a clicked node back to its event. */ events: AgentLensEvent[]; /** false ⇒ OTLP/unchained telemetry (record-integrity only). */ chained?: boolean; selectedEventId?: string; onSelectEvent?: (event: AgentLensEvent) => void; } function shortId(id: string): string { return id.length > 12 ? `${id.slice(0, 10)}…` : id; } export function TraceTree({ tree, events, chained = true, selectedEventId, onSelectEvent }: TraceTreeProps) { const byId = useMemo(() => new Map(events.map((e) => [e.id, e])), [events]); // Collapsed span ids (default: everything expanded). const [collapsed, setCollapsed] = useState>(() => new Set()); if (!tree.hasSpanData) { return (
No span/trace data in this session — switch to List view.
); } const span = tree.startMs != null && tree.endMs != null && tree.endMs > tree.startMs ? tree.endMs - tree.startMs : 0; const totalDurationMs = span; function toggle(spanId: string) { setCollapsed((prev) => { const next = new Set(prev); if (next.has(spanId)) next.delete(spanId); else next.add(spanId); return next; }); } function renderNode(node: TraceNode) { const style = getEventStyle(node.eventType); const hasChildren = node.children.length > 0; const isCollapsed = collapsed.has(node.spanId); const isSelected = !!selectedEventId && node.eventIds.includes(selectedEventId); // Waterfall bar position on the shared trace timescale. let bar: { left: string; width: string } | null = null; if (span > 0 && node.startMs != null) { const offset = Math.min(((node.startMs - tree.startMs!) / span) * 100, 99); const rawWidth = ((node.selfDurationMs ?? 0) / span) * 100; const width = Math.min(Math.max(rawWidth, 1.5), 100 - offset); bar = { left: `${offset}%`, width: `${width}%` }; } const dur = node.totalDurationMs ?? node.selfDurationMs; const primary = byId.get(node.eventIds[0]); return (
{/* Label column (indented by depth) */}
primary && onSelectEvent?.(primary)} > {hasChildren ? ( ) : ( )} {style.icon} {node.name} {node.descendantCount > 0 && isCollapsed && ( +{node.descendantCount} )} {node.isDelegationBoundary && ( ⇄ delegated )} {node.verifiedAgentId && ( 🛡 {shortId(node.verifiedAgentId)} )}
{/* Waterfall track */}
{bar && (
)}
{/* Duration + subtree cost */} {dur != null ? formatMs(dur) : '—'} {node.subtreeCostUsd > 0 ? formatCost(node.subtreeCostUsd) : ''}
{hasChildren && !isCollapsed && node.children.map(renderNode)}
); } return (
{/* Header: honest integrity + trace summary */}
{tree.spanCount} span{tree.spanCount !== 1 ? 's' : ''} · {tree.eventCount} event {tree.eventCount !== 1 ? 's' : ''} {totalDurationMs > 0 && <> · {formatMs(totalDurationMs)}} {tree.totalCostUsd > 0 && <> · {formatCost(tree.totalCostUsd)}}
{/* Column hint */}
Span Waterfall Latency Cost
{tree.roots.map(renderNode)}
); }