"use client"; import { useMemo } from "react"; import { Activity, DollarSign } from "lucide-react"; import { cn, formatDuration } from "@/lib/utils"; interface DecisionPoint { id: string; type: string; chosenAction: string; alternatives: string[]; reasoning: string | null; contextSnapshot: Record | null; confidence: number | null; timestamp: string; parentSpanId?: string | null; } interface Span { id: string; name: string; type: string; status: "OK" | "ERROR" | "CANCELLED"; startedAt: string; endedAt: string | null; durationMs: number | null; input: unknown; output: unknown; metadata: Record; parentSpanId?: string | null; } interface Event { id: string; type: string; name: string; timestamp: string; metadata: Record; spanId?: string | null; } interface Trace { id: string; name: string; status: "RUNNING" | "COMPLETED" | "ERROR"; startedAt: string; endedAt: string | null; durationMs: number | null; tags: string[]; metadata: Record; costUsd: number | null; totalTokens: number | null; totalCost: number | null; } interface TraceAnalyticsProps { trace: Trace; spans: Span[]; decisionPoints: DecisionPoint[]; events: Event[]; } const spanTypeColors: Record = { LLM_CALL: { bg: "bg-purple-500/10", text: "text-purple-400" }, TOOL_CALL: { bg: "bg-blue-500/10", text: "text-blue-400" }, DEFAULT: { bg: "bg-neutral-700/30", text: "text-neutral-400" }, }; const statusBarColors: Record = { OK: "bg-emerald-500", ERROR: "bg-red-500", CANCELLED: "bg-amber-500", }; export function TraceAnalytics({ trace, spans, decisionPoints, }: TraceAnalyticsProps) { return (
{/* Section A: Execution Timeline */}

Execution Timeline

{/* Section B: Cost Breakdown */}

Cost Breakdown

{/* Section C: Token Usage Gauge */}

Token Usage

); } // Section A: Execution Timeline (Waterfall Chart) function ExecutionTimeline({ trace, spans }: { trace: Trace; spans: Span[] }) { const timelineData = useMemo(() => { if (!trace.startedAt || spans.length === 0) { return null; } const traceStartTime = new Date(trace.startedAt).getTime(); const lastSpanEnd = spans.reduce((max, s) => { const end = s.endedAt ? new Date(s.endedAt).getTime() : 0; return end > max ? end : max; }, 0); const traceEndTime = trace.endedAt ? new Date(trace.endedAt).getTime() : trace.status === "COMPLETED" || trace.status === "ERROR" ? lastSpanEnd || traceStartTime : Date.now(); const totalDuration = traceEndTime - traceStartTime; // Build span hierarchy for nesting const spanMap = new Map(); const rootSpans: (Span & { depth: number })[] = []; // Sort spans by start time first const sortedSpans = [...spans].sort( (a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime() ); // Calculate depth for each span sortedSpans.forEach((span) => { let depth = 0; let parentId = span.parentSpanId; while (parentId) { depth++; const parent = spans.find((s) => s.id === parentId); parentId = parent?.parentSpanId; } const spanWithDepth = { ...span, depth }; spanMap.set(span.id, spanWithDepth); if (!span.parentSpanId) { rootSpans.push(spanWithDepth); } }); // Build nested display order const displaySpans: (Span & { depth: number })[] = []; const addSpanAndChildren = (spanId: string) => { const span = spanMap.get(spanId); if (span) { displaySpans.push(span); // Find children const children = sortedSpans.filter((s) => s.parentSpanId === spanId); children.forEach((child) => addSpanAndChildren(child.id)); } }; rootSpans.forEach((root) => addSpanAndChildren(root.id)); return { traceStartTime, totalDuration, displaySpans, }; }, [trace, spans]); if (!timelineData) { return (
No spans to visualize
); } const { traceStartTime, totalDuration, displaySpans } = timelineData; // Generate time markers const timeMarkers = useMemo(() => { const markers: { label: string; percent: number }[] = []; const step = totalDuration <= 1000 ? 100 : totalDuration <= 5000 ? 500 : 1000; const count = Math.ceil(totalDuration / step); for (let i = 0; i <= count; i++) { const timeMs = i * step; if (timeMs <= totalDuration) { const percent = (timeMs / totalDuration) * 100; let label: string; if (timeMs < 1000) { label = `${timeMs}ms`; } else { label = `${(timeMs / 1000).toFixed(timeMs % 1000 === 0 ? 0 : 1)}s`; } markers.push({ label, percent }); } } return markers; }, [totalDuration]); return (
{/* Ruler */}
{timeMarkers.map((marker, idx) => (
{marker.label}
))}
{/* Span rows */}
{displaySpans.map((span) => { const spanStartTime = new Date(span.startedAt).getTime(); const offsetPercent = ((spanStartTime - traceStartTime) / totalDuration) * 100; const durationPercent = ((span.durationMs || 0) / totalDuration) * 100; const isRunning = !span.endedAt; const typeColors = spanTypeColors[span.type] || spanTypeColors.DEFAULT; const statusColor = statusBarColors[span.status] || statusBarColors.OK; return (
{/* Span info (left column) */}
{span.name.length > 20 ? `${span.name.slice(0, 20)}...` : span.name} {span.type}
{/* Timeline bar area */}
); })}
{/* Duration info */}
Total Duration: {formatDuration(totalDuration)}
); } // Section B: Cost Breakdown function CostBreakdown({ trace, spans, decisionPoints, }: { trace: Trace; spans: Span[]; decisionPoints: DecisionPoint[]; }) { const { spanCosts, decisionCosts, totalCost, hasCostData } = useMemo(() => { const spanCostsData = spans .map((span) => ({ id: span.id, name: span.name, type: span.type, cost: (span.metadata?.costUsd as number | null | undefined) ?? null, })) .filter((s) => s.cost !== null && s.cost > 0) .sort((a, b) => (b.cost || 0) - (a.cost || 0)); // Decision costs - use null since costUsd may not be in the API response yet const decisionCostsData = decisionPoints .map((dp) => ({ id: dp.id, name: dp.chosenAction, type: dp.type, cost: null as number | null, })) .filter((d) => d.cost !== null && d.cost > 0); const totalSpanCost = spanCostsData.reduce((sum, s) => sum + (s.cost || 0), 0); const totalDecisionCost = decisionCostsData.reduce( (sum, d) => sum + (d.cost || 0), 0 ); const totalCostValue = trace.costUsd ?? trace.totalCost ?? totalSpanCost + totalDecisionCost; const hasData = totalCostValue > 0 || spanCostsData.length > 0 || decisionCostsData.length > 0; const maxSpanCost = spanCostsData.length > 0 ? Math.max(...spanCostsData.map((s) => s.cost || 0)) : 0; const maxDecisionCost = decisionCostsData.length > 0 ? Math.max(...decisionCostsData.map((d) => d.cost || 0)) : 0; return { spanCosts: spanCostsData.map((s) => ({ ...s, percent: maxSpanCost > 0 ? ((s.cost || 0) / maxSpanCost) * 100 : 0, })), decisionCosts: decisionCostsData.map((d) => ({ ...d, percent: maxDecisionCost > 0 ? ((d.cost || 0) / maxDecisionCost) * 100 : 0, })), totalCost: totalCostValue, hasCostData: hasData, }; }, [trace, spans, decisionPoints]); if (!hasCostData) { return (
No cost data available
); } return (
{/* Total cost summary */}
Total: ${totalCost.toFixed(4)}
{/* Cost breakdown columns */}
{/* Span Costs */}

Span Costs

{spanCosts.length > 0 ? ( spanCosts.map((span) => { const typeColors = spanTypeColors[span.type] || spanTypeColors.DEFAULT; return (
{span.type} {span.name.length > 20 ? `${span.name.slice(0, 20)}...` : span.name}
${(span.cost || 0).toFixed(4)}
); }) ) : (

No span cost data

)}
{/* Decision Costs */}

Decision Costs

{decisionCosts.length > 0 ? ( decisionCosts.map((decision) => { const typeColors = spanTypeColors[decision.type] || spanTypeColors.DEFAULT; return (
{decision.type} {decision.name.length > 20 ? `${decision.name.slice(0, 20)}...` : decision.name}
{decision.cost !== null ? `$${decision.cost.toFixed(4)}` : "N/A"}
); }) ) : (

No decision cost data

)}
); } // Section C: Token Usage Gauge function TokenUsageGauge({ trace }: { trace: Trace }) { const tokenData = useMemo(() => { const totalTokens = trace.totalTokens ?? (trace.metadata?.totalTokens as number | null | undefined) ?? (trace.metadata?.tokenCount as number | null | undefined) ?? null; const modelContextWindows: Record = { "gpt-5.2": 128000, "gpt-5.1": 128000, "gpt-5": 128000, "gpt-5-mini": 128000, "gpt-5-nano": 128000, "gpt-4.1": 1047576, "gpt-4.1-mini": 1047576, "gpt-4.1-nano": 1047576, "o3": 200000, "o3-mini": 200000, "o4-mini": 200000, "gpt-4": 8192, "gpt-4-32k": 32768, "gpt-4-turbo": 128000, "gpt-4o": 128000, "gpt-4o-mini": 128000, "gpt-3.5-turbo": 16385, "claude-opus-4-6": 200000, "claude-4.5-opus": 200000, "claude-4.5-sonnet": 200000, "claude-4.5-haiku": 200000, "claude-3-opus": 200000, "claude-3-sonnet": 200000, "claude-3-haiku": 200000, "claude-3.5-sonnet": 200000, "claude-4-opus": 200000, "claude-4-sonnet": 200000, }; const model = (trace.metadata?.model as string | undefined) ?? ""; const modelLower = model.toLowerCase(); let maxTokens = 128000; for (const [prefix, ctx] of Object.entries(modelContextWindows)) { if (modelLower.startsWith(prefix)) { maxTokens = ctx; break; } } return { totalTokens, maxTokens, percent: totalTokens !== null ? (totalTokens / maxTokens) * 100 : 0, hasData: totalTokens !== null && totalTokens > 0, }; }, [trace]); const { totalTokens, maxTokens, percent, hasData } = tokenData; // Determine color based on usage percentage const getUsageColor = (pct: number): string => { if (pct <= 50) return "#10b981"; // emerald-500 if (pct <= 80) return "#f59e0b"; // amber-500 return "#ef4444"; // red-500 }; const usageColor = getUsageColor(percent); // Create conic gradient for the ring const ringStyle = { background: hasData ? `conic-gradient(${usageColor} ${percent * 3.6}deg, #262626 ${percent * 3.6}deg)` : "conic-gradient(#404040 0deg, #404040 360deg)", }; return (
{/* Ring container */}
{/* Outer ring with conic gradient */}
{/* Inner circle (background) */}
{/* Center content */}
{hasData ? ( <>
{totalTokens?.toLocaleString()}
tokens
) : ( )}
{/* Percentage indicator */} {hasData && (
{Math.round(percent)}%
)}
{/* Token info below */}
{hasData ? ( <>

{totalTokens?.toLocaleString()} / {maxTokens.toLocaleString()} tokens

{percent.toFixed(1)}% of context window used

{percent > 80 && (

High token usage - consider context optimization

)} ) : ( <>

No token data

Token usage information not available for this trace

)}
); }