From 5bb75433aa1dbdce255f6b5b5662186a0e791758 Mon Sep 17 00:00:00 2001 From: Vectry Date: Tue, 10 Feb 2026 00:06:01 +0000 Subject: [PATCH] feat: analytics tab with timeline waterfall, cost breakdown, token gauge --- apps/web/src/components/trace-analytics.tsx | 563 ++++++++++++++++++++ apps/web/src/components/trace-detail.tsx | 17 +- 2 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/trace-analytics.tsx diff --git a/apps/web/src/components/trace-analytics.tsx b/apps/web/src/components/trace-analytics.tsx new file mode 100644 index 0000000..790ec0e --- /dev/null +++ b/apps/web/src/components/trace-analytics.tsx @@ -0,0 +1,563 @@ +"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; +} + +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 traceEndTime = trace.endedAt + ? new Date(trace.endedAt).getTime() + : 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 ?? 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(() => { + // Try to get total tokens from various sources + const totalTokens = + (trace.metadata?.totalTokens as number | null | undefined) ?? + (trace.metadata?.tokenCount as number | null | undefined) ?? + null; + + const maxTokens = 128000; // Default context window + + 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 +

+ + )} +
+
+ ); +} diff --git a/apps/web/src/components/trace-detail.tsx b/apps/web/src/components/trace-detail.tsx index 027a910..ffd4156 100644 --- a/apps/web/src/components/trace-detail.tsx +++ b/apps/web/src/components/trace-detail.tsx @@ -21,9 +21,10 @@ import { } from "lucide-react"; import { cn, formatDuration, formatRelativeTime } from "@/lib/utils"; import { DecisionTree } from "./decision-tree"; +import { TraceAnalytics } from "./trace-analytics"; type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR"; -type TabType = "tree" | "decisions" | "spans" | "events"; +type TabType = "tree" | "analytics" | "decisions" | "spans" | "events"; interface DecisionPoint { id: string; @@ -253,6 +254,12 @@ export function TraceDetail({ icon={GitBranch} label="Tree" /> + setActiveTab("analytics")} + icon={Activity} + label="Analytics" + /> setActiveTab("decisions")} @@ -284,6 +291,14 @@ export function TraceDetail({ events={events} /> )} + {activeTab === "analytics" && ( + + )} {activeTab === "decisions" && ( )}