diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.tsx new file mode 100644 index 00000000000..ef158626fc8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Copy } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { copyToClipboard } from "@/utils/dataUtils"; + +import { agentTraceCall } from "../../networking"; +import { AgentGraph } from "./AgentGraph"; +import { SpanDetail } from "./SpanDetail"; +import { SpanTree } from "./SpanTree"; +import { AgentTracePill, SpanStatusBadge } from "./TracePills"; +import { TraceSteps } from "./TraceSteps"; +import type { Trace } from "./traceTypes"; +import { + buildVisibleTree, + firstErrorSpan, + flattenTree, + fmtCost, + fmtMs, + fmtTok, + revealSpan, + sortStepsByCost, + spanRowIds, + stepsFromSpans, + subtreeStats, + totalCacheRead, + traceHasErrors, + type TreeUiState, +} from "./traceUtils"; +import { stepSelection, useTraceNavigation } from "./useTraceNavigation"; + +export type TraceViewMode = "steps" | "tree" | "graph"; + +interface TraceDrawerProps { + open: boolean; + traceId: string | null; + /** Span to select on open (e.g. a child LLM row was clicked in the table). */ + initialSpanId?: string | null; + accessToken: string; + onClose: () => void; + onOpenRequestLog?: (requestId: string) => void; +} + +const DRAWER_WIDTH = "min(1180px, 80vw)"; +const MODES: { id: TraceViewMode; label: string }[] = [ + { id: "steps", label: "Steps" }, + { id: "tree", label: "Tree" }, + { id: "graph", label: "Graph" }, +]; +const EMPTY_UI: TreeUiState = { collapsed: new Set(), groupShown: {} }; + +interface InitialView { + mode: TraceViewMode; + selectedId: string | null; +} + +/** Errors open on the first failed span in the Tree; otherwise the Steps narrative. */ +export function initialTraceView(trace: Trace, initialSpanId?: string | null): InitialView { + if (initialSpanId && trace.spans.some((s) => s.span_id === initialSpanId)) { + return { mode: "steps", selectedId: initialSpanId }; + } + if (traceHasErrors(trace)) { + const failed = firstErrorSpan(trace.spans); + if (failed) return { mode: "tree", selectedId: failed.span_id }; + } + const firstStep = stepsFromSpans(trace.spans)[0]?.span ?? trace.spans[0]; + return { mode: "steps", selectedId: firstStep?.span_id ?? null }; +} + +/** Tree state with the initially selected span (e.g. the first failure) scrolled into reach. */ +function initialTreeUi(trace: Trace, selectedId: string | null): TreeUiState { + if (!selectedId) return EMPTY_UI; + const { children } = buildVisibleTree(trace.spans, false); + return revealSpan(children, subtreeStats(trace.spans), EMPTY_UI, selectedId); +} + +function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+
{label}
+
+ {value} {sub && {sub}} +
+
+ ); +} + +export function TraceStats({ trace }: { trace: Trace }) { + const { summary } = trace; + return ( +
+ + + + + + + +
+ ); +} + +function AgentCostSplit({ trace }: { trace: Trace }) { + if (trace.agents.length < 2) return null; + const sorted = [...trace.agents].sort((a, b) => b.spend - a.spend); + return ( +
+ Cost by agent: + {sorted.map((agent) => ( + + {agent.name} {fmtCost(agent.spend)} + + ))} +
+ ); +} + +interface TraceBodyProps { + trace: Trace; + accessToken: string; + initialSpanId?: string | null; + onOpenRequestLog?: (requestId: string) => void; +} + +function TraceBody({ trace, accessToken, initialSpanId, onOpenRequestLog }: TraceBodyProps) { + const [initial] = useState(() => initialTraceView(trace, initialSpanId)); + const [mode, setMode] = useState(initial.mode); + const [selectedId, setSelectedId] = useState(initial.selectedId); + const [showFramework, setShowFramework] = useState(false); + const [sortByCost, setSortByCost] = useState(false); + const [ui, setUi] = useState(() => initialTreeUi(trace, initial.selectedId)); + + const { children, visibleCount } = useMemo( + () => buildVisibleTree(trace.spans, showFramework), + [trace.spans, showFramework], + ); + const stats = useMemo(() => subtreeStats(trace.spans), [trace.spans]); + const rows = useMemo(() => flattenTree(children, stats, ui), [children, stats, ui]); + const timeSteps = useMemo(() => stepsFromSpans(trace.spans), [trace.spans]); + const steps = useMemo(() => (sortByCost ? sortStepsByCost(timeSteps) : timeSteps), [timeSteps, sortByCost]); + const selectedSpan = useMemo( + () => trace.spans.find((s) => s.span_id === selectedId) ?? null, + [trace.spans, selectedId], + ); + + const order = useMemo( + () => (mode === "steps" ? steps.map((s) => s.span.span_id) : spanRowIds(rows)), + [mode, steps, rows], + ); + const handleStep = useCallback( + (delta: number) => setSelectedId((current) => stepSelection(order, current, delta)), + [order], + ); + useTraceNavigation(mode !== "graph", handleStep); + + const toggleCollapse = useCallback((spanId: string) => { + setUi((prev) => { + const collapsed = new Set(prev.collapsed); + if (collapsed.has(spanId)) collapsed.delete(spanId); + else collapsed.add(spanId); + return { ...prev, collapsed }; + }); + }, []); + const showGroup = useCallback((key: string, shown: number) => { + setUi((prev) => ({ ...prev, groupShown: { ...prev.groupShown, [key]: shown } })); + }, []); + const openInvocation = useCallback( + (spanId: string) => { + setUi((prev) => revealSpan(children, stats, prev, spanId)); + setSelectedId(spanId); + setMode("tree"); + }, + [children, stats], + ); + const changeMode = useCallback( + (next: TraceViewMode) => { + if (next === "tree" && selectedId) setUi((prev) => revealSpan(children, stats, prev, selectedId)); + setMode(next); + }, + [children, stats, selectedId], + ); + + return ( +
+
+
+ + {MODES.map((m) => ( + + ))} + + {mode === "tree" && ( + + )} + {mode === "steps" && ( + + )} + + {mode === "tree" ? `${visibleCount} of ${trace.spans.length} spans` : `${steps.length} steps`} + +
+ {mode === "steps" && } + {mode === "tree" && ( + + )} + {mode === "graph" && } +
+ {selectedSpan ? ( + + ) : ( +
Select a span to see its details.
+ )} +
+ ); +} + +function TraceHeader({ trace, onClose }: { trace: Trace; onClose: () => void }) { + const { summary } = trace; + return ( +
+
+ + {summary.name} + + + J /{" "} + K to move + + +
+
+ + + service {summary.service} + + · + trace {summary.trace_id} + + · + {new Date(summary.start_time).toLocaleString()} +
+
+ +
+
+ ); +} + +/** Right-side drawer for one agent trace: header, stats strip, Steps / Tree / Graph views and span details. */ +export function TraceDrawer({ + open, + traceId, + initialSpanId, + accessToken, + onClose, + onOpenRequestLog, +}: TraceDrawerProps) { + const traceQuery = useQuery({ + queryKey: ["agentTrace", traceId, accessToken], + queryFn: () => agentTraceCall(accessToken, traceId as string), + enabled: open && traceId !== null, + staleTime: 30_000, + }); + const trace = traceQuery.data; + + return ( + !nextOpen && onClose()}> + + {traceQuery.isLoading && ( +
+ Loading trace + +
+ )} + {traceQuery.isError && ( +
+ Could not load trace + {traceQuery.error.message} +
+ )} + {trace && ( +
+ + + +
+ )} +
+
+ ); +}