diff --git a/ui/litellm-dashboard/public/assets/agent-traces-preview.png b/ui/litellm-dashboard/public/assets/agent-traces-preview.png new file mode 100644 index 00000000000..34569e26331 Binary files /dev/null and b/ui/litellm-dashboard/public/assets/agent-traces-preview.png differ diff --git a/ui/litellm-dashboard/public/assets/logos/crewai-color.svg b/ui/litellm-dashboard/public/assets/logos/crewai-color.svg new file mode 100644 index 00000000000..95cb17f9364 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/crewai-color.svg @@ -0,0 +1 @@ +CrewAI \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/logos/langchain.svg b/ui/litellm-dashboard/public/assets/logos/langchain.svg new file mode 100644 index 00000000000..939b79989a7 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/langchain.svg @@ -0,0 +1 @@ +LangChain \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/logos/langgraph-color.svg b/ui/litellm-dashboard/public/assets/logos/langgraph-color.svg new file mode 100644 index 00000000000..14f16e3cd1d --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/langgraph-color.svg @@ -0,0 +1 @@ +LangGraph \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/logos/llamaindex-color.svg b/ui/litellm-dashboard/public/assets/logos/llamaindex-color.svg new file mode 100644 index 00000000000..99be517874e --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/llamaindex-color.svg @@ -0,0 +1 @@ +LlamaIndex \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/logos/openai-agents.svg b/ui/litellm-dashboard/public/assets/logos/openai-agents.svg new file mode 100644 index 00000000000..78caf4fa20f --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/openai-agents.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/logos/opentelemetry.svg b/ui/litellm-dashboard/public/assets/logos/opentelemetry.svg new file mode 100644 index 00000000000..606165cf788 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/opentelemetry.svg @@ -0,0 +1 @@ +OpenTelemetry \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/logos/pydantic-ai-color.svg b/ui/litellm-dashboard/public/assets/logos/pydantic-ai-color.svg new file mode 100644 index 00000000000..85827432f0c --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/pydantic-ai-color.svg @@ -0,0 +1 @@ +PydanticAI \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index 3b52a2eac33..cc497677a1a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -23,7 +23,9 @@ vi.mock("@/components/DashboardHeader", () => ({ })); vi.mock("@/app/(dashboard)/components/SidebarProvider", () => ({ - default: () =>
, + default: ({ sidebarCollapsed }: { sidebarCollapsed: boolean }) => ( +
+ ), })); vi.mock("@/components/DebugWarningBanner", () => ({ @@ -112,6 +114,27 @@ describe("(dashboard) Layout", () => { }, ); + it("collapses the sidebar on Logs for a full-screen view and expands it again after leaving", async () => { + const dashboard = () => ( + + +
+ + + ); + const { rerender } = render(dashboard()); + pendingUiConfig.resolve(); + expect(await screen.findByTestId("sidebar")).toHaveAttribute("data-collapsed", "false"); + + vi.mocked(usePathname).mockReturnValue("/ui/logs"); + rerender(dashboard()); + expect(screen.getByTestId("sidebar")).toHaveAttribute("data-collapsed", "true"); + + vi.mocked(usePathname).mockReturnValue("/ui/api-keys"); + rerender(dashboard()); + expect(screen.getByTestId("sidebar")).toHaveAttribute("data-collapsed", "false"); + }); + it("does not mount route content until getUiConfig has resolved", async () => { render( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 406a323fbfb..72f26919060 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -99,11 +99,18 @@ export function AgentControlPlaneView() { ); } +const FULL_BLEED_SEGMENTS = new Set(["logs"]); + function DashboardShell({ children }: { children: React.ReactNode }) { const { accessToken } = useAuth(); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const { mode } = usePluginMode(); - const isPlayground = routeSegmentForPathname(usePathname()) === "playground"; + const routeSegment = routeSegmentForPathname(usePathname()); + const isPlayground = routeSegment === "playground"; + const isFullBleed = FULL_BLEED_SEGMENTS.has(routeSegment); + // A manual toggle holds only for the route it was made on; full-bleed routes default to collapsed. + const [sidebarOverride, setSidebarOverride] = useState<{ segment: string; collapsed: boolean } | null>(null); + const sidebarCollapsed = sidebarOverride?.segment === routeSegment ? sidebarOverride.collapsed : isFullBleed; + const toggleSidebar = () => setSidebarOverride({ segment: routeSegment, collapsed: !sidebarCollapsed }); const isGateway = mode === "ai-gateway"; @@ -133,7 +140,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) { // so the page can't be dragged past the end of the nav. return (
- setSidebarCollapsed((v) => !v)} /> +
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 42dc5350b49..d0a5349364f 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -115,6 +115,7 @@ import type { ComplexityRouterConfigPayload } from "./add_model/build_complexity import type { AutoRouterPresetsResponse } from "@/lib/autorouter_presets"; import type { VectorStoreIndex } from "@/app/(dashboard)/vector-stores/_components/IndexesTab"; import type { RoutingDecision } from "./view_logs/LogDetailsDrawer/RoutingDecisionCard"; +import type { SpanDetail, Trace, TracePage } from "./view_logs/TraceView/traceTypes"; import { createApiClient, deriveErrorMessage, @@ -2101,6 +2102,33 @@ export const uiSpendLogsCall = async ({ } }; +/** + * Agent tracing. All three respond 501 `{detail}` when `general_settings.tracing` is not + * configured; callers can detect that through the thrown `ApiError`'s `status`. + */ +export const agentTraceListCall = async ({ + accessToken, + startMs, + endMs, + cursor, +}: { + accessToken: string; + startMs: number; + endMs: number; + cursor?: string | null; +}): Promise => { + const query = { start_ms: startMs, end_ms: endMs, cursor: cursor ?? undefined }; + return apiClient.get(`/v1/traces`, { accessToken, query }); +}; + +export const agentTraceCall = async (accessToken: string, traceId: string): Promise => + apiClient.get(`/v1/traces/${encodeURIComponent(traceId)}`, { accessToken }); + +export const agentTraceSpanCall = async (accessToken: string, traceId: string, spanId: string): Promise => + apiClient.get(`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}`, { + accessToken, + }); + export const adminSpendLogsCall = async (accessToken: string) => { try { const data = await apiClient.get(`/global/spend/logs`, { accessToken }); diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesPage.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesPage.tsx new file mode 100644 index 00000000000..5b506cadbb0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesPage.tsx @@ -0,0 +1,42 @@ +"use client"; + +import moment from "moment"; +import { useMemo, useState } from "react"; + +import { AgentTracesSection } from "./AgentTracesSection"; + +const DEFAULT_RANGE_HOURS = 24; +const TIME_FORMAT = "YYYY-MM-DDTHH:mm"; + +/** Agent Traces: agent runs (developer view). Lives as the "Agent Traces" tab of the Logs page. */ +export default function AgentTracesPage({ accessToken }: { accessToken: string }) { + const [rangeHours, setRangeHours] = useState(DEFAULT_RANGE_HOURS); + const [live, setLive] = useState(true); + const [anchor, setAnchor] = useState(() => moment()); + const { startTime, endTime } = useMemo( + () => ({ + startTime: anchor.clone().subtract(rangeHours, "hours").format(TIME_FORMAT), + endTime: anchor.format(TIME_FORMAT), + }), + [anchor, rangeHours], + ); + + const changeRange = (hours: number) => { + setRangeHours(hours); + setAnchor(moment()); + }; + + return ( +
+ +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesSection.test.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesSection.test.tsx new file mode 100644 index 00000000000..6d28e8a53e9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesSection.test.tsx @@ -0,0 +1,265 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiError } from "@/lib/http/client"; + +import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils"; +import traceList from "./__fixtures__/trace_list.json"; +import AgentTracesPage from "./AgentTracesPage"; +import { AgentTracesSection, filterRuns } from "./AgentTracesSection"; +import type { TracePage, TraceSummary } from "./traceTypes"; + +vi.mock("../../networking", () => ({ + agentTraceListCall: vi.fn(), + agentTraceCall: vi.fn(), + agentTraceSpanCall: vi.fn(), + getProxyBaseUrl: () => "http://localhost:4000", +})); + +vi.mock("./TraceDrawer", () => ({ + RunView: ({ traceId, onBack }: { traceId: string; onBack: () => void }) => ( +
+ run {traceId} + +
+ ), +})); + +import { agentTraceListCall } from "../../networking"; + +const runs = (traceList as TracePage).data as TraceSummary[]; + +const renderSection = () => + renderWithProviders( + , + ); + +// A UTC-pinned day around the fixture runs (2026-09-30 ~06:43 UTC), so they land in the same bucket in any timezone. +const renderWindowed = () => + renderWithProviders( + , + ); + +const bucketRunCounts = () => + screen.getAllByTestId("timeline-bucket").map((bucket) => Number(bucket.getAttribute("data-runs"))); + +describe("AgentTracesSection", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + beforeEach(() => { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + left: 0, + width: 600, + top: 0, + height: 56, + right: 600, + bottom: 56, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + testQueryClient.clear(); + vi.mocked(agentTraceListCall).mockReset(); + }); + + it("renders the setup snippet when the proxy answers 501", async () => { + vi.mocked(agentTraceListCall).mockRejectedValue( + new ApiError("Agent tracing is not enabled", 501, { detail: "Agent tracing is not enabled" }), + ); + renderSection(); + + const card = await screen.findByTestId("tracing-setup-card"); + expect(card).toHaveTextContent("Tracing is not enabled"); + expect(card).toHaveTextContent("store: clickhouse"); + expect(card).toHaveTextContent("OTEL_EXPORTER_OTLP_ENDPOINT="); + expect(card).not.toHaveTextContent(/langsmith/i); + expect(card).toHaveTextContent('OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $LITELLM_API_KEY"'); + expect(card).toHaveTextContent("Let Claude Code or Codex set it up"); + }); + + it("shows the waiting guide when tracing is on but no runs have arrived", async () => { + vi.mocked(agentTraceListCall).mockResolvedValue({ ...(traceList as TracePage), data: [] }); + renderSection(); + + const card = await screen.findByTestId("tracing-setup-card"); + expect(card).toHaveTextContent("Waiting for traces"); + expect(card).toHaveTextContent("No traces detected yet"); + expect(card).not.toHaveTextContent("store: clickhouse"); + }); + + it("treats a proxy without the trace routes (404) like tracing being off", async () => { + vi.mocked(agentTraceListCall).mockRejectedValue(new ApiError("Not Found", 404, { detail: "Not Found" })); + renderSection(); + + const card = await screen.findByTestId("tracing-setup-card"); + expect(card).toHaveTextContent("Tracing is not enabled"); + expect(card).toHaveTextContent("CLICKHOUSE_READER_URL"); + }); + + it("lists every run with its input, counts and failed column", async () => { + vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + renderSection(); + + const rows = await screen.findAllByTestId("agent-trace-row"); + expect(rows).toHaveLength(runs.length); + const lead = rows.find((row) => row.textContent?.includes("Should we store OTEL agent spans")); + expect(lead).toBeDefined(); + const failed = rows.find((row) => row.textContent?.includes("acme-404")) as HTMLElement; + expect(within(failed).getByLabelText("2 errors")).toBeInTheDocument(); + expect(screen.getByText(`${runs.length} runs`)).toBeInTheDocument(); + expect(screen.queryByRole("columnheader", { name: "Cost" })).not.toBeInTheDocument(); + }); + + it("filters by input text and by trace id", async () => { + vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + renderSection(); + await screen.findAllByTestId("agent-trace-row"); + + const search = screen.getByLabelText("Search runs"); + fireEvent.change(search, { target: { value: "acme-404" } }); + expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1); + + const lead = runs.find((r) => r.name === "research_lead") as TraceSummary; + fireEvent.change(search, { target: { value: lead.trace_id.slice(0, 10) } }); + const rows = screen.getAllByTestId("agent-trace-row"); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveTextContent("Should we store OTEL agent spans"); + }); + + it("status filter 'Failed' keeps only runs with errors", () => { + const failed = filterRuns(runs, "", "all", "error"); + expect(failed.length).toBeGreaterThan(0); + expect(failed.every((r) => r.error_count > 0)).toBe(true); + const ok = filterRuns(runs, "", "all", "ok"); + expect(ok.every((r) => r.error_count === 0)).toBe(true); + expect(failed.length + ok.length).toBe(runs.length); + }); + + it("opens the run in place and goes back to the list", async () => { + vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + renderSection(); + const rows = await screen.findAllByTestId("agent-trace-row"); + + fireEvent.click(rows[0]); + expect(screen.getByTestId("run-view")).toHaveTextContent(`run ${runs[0].trace_id}`); + expect(screen.queryByTestId("runs-table")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("back")); + expect(screen.getByTestId("runs-table")).toBeInTheDocument(); + }); + + it("plots every loaded run on the timeline", async () => { + vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + renderWindowed(); + await screen.findAllByTestId("agent-trace-row"); + + expect(screen.getByTestId("traces-timeline")).toBeInTheDocument(); + const counts = bucketRunCounts(); + expect(counts).toHaveLength(60); + expect(counts.reduce((a, b) => a + b, 0)).toBe(runs.length); + }); + + it("zooms by dragging, resizes and pans the bracket, and clears with Esc", async () => { + vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + renderWindowed(); + await screen.findAllByTestId("agent-trace-row"); + const area = screen.getByTestId("timeline-area"); + const x = (bucket: number) => bucket * 10 + 5; + const drag = (target: HTMLElement, from: number, to: number) => { + fireEvent.pointerDown(target, { clientX: x(from), pointerId: 1 }); + fireEvent.pointerMove(area, { clientX: x(to), pointerId: 1 }); + fireEvent.pointerUp(area, { clientX: x(to), pointerId: 1 }); + }; + const rowCount = () => screen.queryAllByTestId("agent-trace-row").length; + const withRuns = bucketRunCounts().flatMap((count, i) => (count > 0 ? [i] : [])); + const first = withRuns[0]; + // The pan below moves a [0, first] bracket to the far right; it must end up clear of every run. + expect(first).toBeGreaterThan(1); + expect(first).toBeLessThan(30); + + drag(area, 0, 1); + expect(screen.getByTestId("timeline-selection")).toBeInTheDocument(); + expect(rowCount()).toBe(0); + + drag(screen.getByTestId("timeline-handle-hi"), 1, first); + expect(rowCount()).toBeGreaterThan(0); + + drag(screen.getByTestId("timeline-selection"), 1, 1 - first); + expect(rowCount()).toBeGreaterThan(0); + drag(screen.getByTestId("timeline-selection"), 0, 59); + expect(rowCount()).toBe(0); + + fireEvent.keyDown(screen.getByTestId("traces-timeline"), { key: "Escape" }); + expect(screen.queryByTestId("timeline-selection")).not.toBeInTheDocument(); + expect(rowCount()).toBe(runs.length); + }); +}); + +describe("AgentTracesPage", () => { + beforeEach(() => { + testQueryClient.clear(); + vi.mocked(agentTraceListCall).mockReset(); + }); + + it("shows the actual range, switches presets from the popover, and toggles Live", async () => { + vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + renderWithProviders(); + await screen.findByTestId("runs-table"); + + const trigger = screen.getByRole("button", { name: "Time range" }); + expect(trigger).toHaveTextContent(/ to /); + expect(screen.getByTestId("traces-timeline")).toHaveTextContent("Total 1d"); + + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last 7 days" })); + expect(await screen.findByText("Total 7d")).toBeInTheDocument(); + const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0]; + expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000); + + const live = screen.getByRole("button", { name: "Live" }); + expect(live).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(live); + expect(live).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByRole("button", { name: "Reset zoom" })).toBeDisabled(); + }); + + it("keeps the time controls on an empty range the user picked, instead of showing onboarding", async () => { + vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + renderWithProviders(); + await screen.findByTestId("runs-table"); + + vi.mocked(agentTraceListCall).mockResolvedValue({ ...(traceList as TracePage), data: [] }); + fireEvent.click(screen.getByRole("button", { name: "Time range" })); + fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" })); + + expect(await screen.findByText("No runs match these filters.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Time range" })).toBeInTheDocument(); + expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument(); + }); + + it("asks the proxy for the last 24 hours by default", async () => { + vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + renderWithProviders(); + await screen.findByTestId("runs-table"); + + const { startMs, endMs } = vi.mocked(agentTraceListCall).mock.calls[0][0]; + expect(endMs - startMs).toBeGreaterThanOrEqual(24 * 3600 * 1000 - 60_000); + expect(endMs - startMs).toBeLessThan(24 * 3600 * 1000 + 120_000); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesSection.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesSection.tsx new file mode 100644 index 00000000000..2bfa3c320a7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesSection.tsx @@ -0,0 +1,185 @@ +"use client"; + +import moment from "moment"; +import { useMemo, useState } from "react"; + +import { AgentTracesTable } from "./AgentTracesTable"; +import { ALL_SERVICES, RunsToolbar, type RunStatusFilter } from "./RunsToolbar"; +import { RunView } from "./TraceDrawer"; +import type { TraceSummary } from "./traceTypes"; +import { previewText } from "./traceUtils"; +import { TimeRangeControls } from "./TimeRangeControls"; +import { TracesTimeline, type TimeWindow } from "./TracesTimeline"; +import { TracingSetupCard } from "./TracingSetupCard"; +import { traceWindowStartMs, useAgentTraces } from "./useAgentTraces"; + +/** Client-side search (input text or trace id) plus service / status filters over the loaded runs. */ +export function filterRuns( + runs: TraceSummary[], + query: string, + service: string, + status: RunStatusFilter, +): TraceSummary[] { + const q = query.trim().toLowerCase(); + return runs.filter((run) => { + const haystack = [run.trace_id, previewText(run.input_preview), run.name].map((s) => s.toLowerCase()); + const matchesQuery = !q || haystack.some((text) => text.includes(q)); + const matchesService = service === ALL_SERVICES || run.service === service; + const failed = run.error_count > 0; + const matchesStatus = status === "all" || (status === "error" ? failed : !failed); + return matchesQuery && matchesService && matchesStatus; + }); +} + +const filterByWindow = (runs: TraceSummary[], range: TimeWindow): TraceSummary[] => + runs.filter((run) => { + const t = moment(run.start_time).valueOf(); + return t >= range.startMs && t < range.endMs; + }); + +export interface TimeControls { + rangeHours: number; + onRangeHoursChange: (hours: number) => void; + onLiveChange: (live: boolean) => void; +} + +interface AgentTracesSectionProps { + accessToken: string; + isActive: boolean; + startTime: string; + endTime: string; + isCustomDate: boolean; + isLiveTail: boolean; + /** Page-owned time range + live state; when given, the toolbar shows the range / Live control group. */ + timeControls?: TimeControls; + /** Called when a run opens / closes, so the page can hide its own header while a run fills the view. */ + onRunOpenChange?: (open: boolean) => void; +} + +/** The Runs view: filters, the runs table and footer — or one run, in place, once a row is clicked. */ +export function AgentTracesSection({ + accessToken, + isActive, + startTime, + endTime, + isCustomDate, + isLiveTail, + timeControls, + onRunOpenChange, +}: AgentTracesSectionProps) { + const [openTraceId, setOpenTraceId] = useState(null); + const [query, setQuery] = useState(""); + const [service, setService] = useState(ALL_SERVICES); + const [status, setStatus] = useState("all"); + const [showSetup, setShowSetup] = useState(false); + const [zoom, setZoom] = useState(null); + const [rangeChanged, setRangeChanged] = useState(false); + const traceQuery = { accessToken, startTime, endTime, isCustomDate, isLiveTail, enabled: isActive }; + const traces = useAgentTraces(traceQuery); + + const services = useMemo(() => Array.from(new Set(traces.traces.map((t) => t.service))).sort(), [traces.traces]); + // Relative ranges end "now" (the list query uses Date.now() too); round to the minute so the histogram is stable. + const endMs = isCustomDate ? moment(endTime).valueOf() : moment().endOf("minute").valueOf(); + const range = useMemo( + () => ({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, endMs), endMs }), + [startTime, endTime, isCustomDate, endMs], + ); + const filtered = useMemo( + () => filterRuns(traces.traces, query, service, status), + [traces.traces, query, service, status], + ); + const runs = useMemo(() => (zoom ? filterByWindow(filtered, zoom) : filtered), [filtered, zoom]); + + const changeRange = (hours: number, apply: (hours: number) => void) => { + setZoom(null); + setRangeChanged(true); + apply(hours); + }; + + const openRun = (traceId: string | null) => { + setOpenTraceId(traceId); + onRunOpenChange?.(traceId !== null); + }; + + if (traces.notEnabledDetail !== null) return ; + // Onboarding only on the first, default view; an empty range the user picked keeps its controls. + const isEmpty = !traces.isLoading && !traces.error && traces.traces.length === 0; + if (isEmpty && !rangeChanged) return ; + if (showSetup) { + return ( +
+ + +
+ ); + } + + if (openTraceId !== null) { + return openRun(null)} />; + } + + return ( +
+ + + {timeControls && ( + changeRange(hours, timeControls.onRangeHoursChange)} + live={isLiveTail} + onLiveChange={timeControls.onLiveChange} + zoomed={zoom !== null} + onResetZoom={() => setZoom(null)} + /> + )} + + + +
+ {runs.length} {runs.length === 1 ? "run" : "runs"} + {zoom && ( + + )} + {traces.isFetching ? "Updating…" : "Updated just now"} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesTable.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesTable.tsx new file mode 100644 index 00000000000..e4faff1621a --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesTable.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { ArrowDown, ChevronRight } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +import { StatusMark } from "./StatusMark"; +import type { TraceSummary } from "./traceTypes"; +import { fmtMs, previewText, traceDisplayName } from "./traceUtils"; + +interface AgentTracesTableProps { + traces: TraceSummary[]; + isLoading: boolean; + error: Error | null; + hasMore: boolean; + onLoadMore: () => void; + onOpenTrace: (traceId: string) => void; +} + +/** Spend is only on summaries once the spend-enrichment PR lands; show Cost when it's there. */ +type SummaryWithSpend = TraceSummary & { spend?: number }; + +const SECOND_MS = 1000; +const MINUTE_S = 60; +const HOUR_M = 60; +const DAY_H = 24; + +export function relativeTime(iso: string, now: number = Date.now()): string { + const diffS = Math.round((now - new Date(iso).getTime()) / SECOND_MS); + if (diffS < 5) return "just now"; + if (diffS < MINUTE_S) return `${diffS}s ago`; + const diffM = Math.round(diffS / MINUTE_S); + if (diffM < HOUR_M) return `${diffM}m ago`; + const diffH = Math.round(diffM / HOUR_M); + if (diffH < DAY_H) return `${diffH}h ago`; + return `${Math.round(diffH / DAY_H)}d ago`; +} + +export const formatCost = (cost: number): string => { + if (cost === 0) return "$0.00"; + if (cost < 0.01) return `$${cost.toFixed(4)}`; + return `$${cost.toFixed(2)}`; +}; + +const firstLine = (text: string): string => text.split("\n")[0] ?? text; + +const TH = "px-3 font-medium"; +const TH_NUM = "px-3 text-right font-medium"; +const TD_NUM = "px-3 text-right font-mono tabular-nums text-muted-foreground"; + +/** Devtool-dense runs list: one row per agent run, newest first. */ +export function AgentTracesTable({ + traces, + isLoading, + error, + hasMore, + onLoadMore, + onOpenTrace, +}: AgentTracesTableProps) { + const showCost = traces.some((t) => typeof (t as SummaryWithSpend).spend === "number"); + const isEmpty = !isLoading && !error && traces.length === 0; + return ( +
+ + + + + + + + + + {showCost && } + + + + + {traces.map((run) => ( + onOpenTrace(run.trace_id)} + className="h-9 cursor-pointer border-b border-border/60 text-[12px] hover:bg-accent/50" + > + + + + + + + {showCost && ( + + )} + + + + ))} + +
+ + Time + + ServiceInputAgentsStepsDurationCostFailed +
+ {relativeTime(run.start_time)} + + {run.service} + +
+ 0 ? "error" : "ok"} subtle /> + + {firstLine(previewText(run.input_preview)) || traceDisplayName(run)} + + + {run.trace_id} + +
+
{run.agent_count.toLocaleString()}{run.span_count.toLocaleString()}{fmtMs(run.duration_ms)} + {formatCost((run as SummaryWithSpend).spend ?? 0)} + + {run.error_count > 0 ? ( + + ) : ( + 0 + )} + + +
+ {isLoading &&
Loading runs…
} + {error && ( +
Could not load runs: {error.message}
+ )} + {isEmpty && ( +
No runs match these filters.
+ )} + {hasMore && ( +
+ +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/AttributesDetail.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/AttributesDetail.tsx new file mode 100644 index 00000000000..9001b1470a2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/AttributesDetail.tsx @@ -0,0 +1,33 @@ +"use client"; + +import type { Span } from "./traceTypes"; + +interface AttributesDetailProps { + traceId: string; + span: Span; + attributes: Record | undefined; + isLoading: boolean; +} + +/** Raw OTEL attributes as a key / value grid, ids first. */ +export function AttributesDetail({ traceId, span, attributes, isLoading }: AttributesDetailProps) { + const entries: [string, string][] = [ + ["trace_id", traceId], + ["span_id", span.span_id], + ["parent_span_id", span.parent_span_id ?? "—"], + ...Object.entries(attributes ?? {}).sort(([a], [b]) => a.localeCompare(b)), + ]; + return ( +
+
+ {entries.map(([key, value]) => ( +
+
{key}
+
{value}
+
+ ))} +
+ {isLoading &&
Loading attributes…
} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/CopyButton.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/CopyButton.tsx new file mode 100644 index 00000000000..60dda4ce72d --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/CopyButton.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; + +const COPIED_RESET_MS = 1600; + +interface CopyButtonProps { + value: string; + label?: string; + copiedLabel?: string; + iconOnly?: boolean; + className?: string; +} + +/** Copy → check for a moment. `iconOnly` renders a bare icon button with a tooltip. */ +export function CopyButton({ + value, + label = "Copy", + copiedLabel = "Copied", + iconOnly = false, + className, +}: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const timeout = window.setTimeout(() => setCopied(false), COPIED_RESET_MS); + return () => window.clearTimeout(timeout); + }, [copied]); + + const button = ( + + ); + + if (!iconOnly) return button; + return ( + + + + {copied ? copiedLabel : label} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailContent.tsx new file mode 100644 index 00000000000..d60805e8f45 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailContent.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { AlertTriangle, Bot, CornerDownRight, Wrench } from "lucide-react"; + +import { agentTraceSpanCall } from "../../networking"; +import type { ErrorSource } from "./traceTree"; +import type { Span, SpanDetail, TraceMessage } from "./traceTypes"; +import { errorSource, parseMessages, prettyPayload } from "./traceUtils"; + +const ERROR_SOURCE_LABEL: Record = { tool: "Tool", model: "Model", litellm: "LiteLLM" }; +const TRACEBACK_MARKER = "Traceback (most recent call last):"; + +/** LangSmith records `repr(exc)` + traceback with no separator; keep the exception line. */ +export const errorHeadline = (error: string): string => + (error.split(TRACEBACK_MARKER, 1)[0].split("\n")[0] ?? "").trim() || error.trim(); + +/** `ValueError('x not found')` → "ValueError"; plain text → "error". */ +const errorReason = (headline: string): string => /^([A-Za-z_][\w.]*)\(/.exec(headline)?.[1] ?? "error"; + +/** Shared lazy fetch of one span's full input / output / attributes. */ +export function useSpanDetail(accessToken: string, traceId: string, spanId: string | null) { + const queryOptions: UseQueryOptions = { + queryKey: ["agentTraceSpan", traceId, spanId, accessToken], + queryFn: () => agentTraceSpanCall(accessToken, traceId, spanId as string), + enabled: spanId !== null, + staleTime: Infinity, + }; + return useQuery(queryOptions); +} + +export function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function TextBlock({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} +
+ {value} +
+
+ ); +} + +function RoleIcon({ role }: { role: string }) { + if (role === "assistant") return ; + if (role === "tool") return ; + return ; +} + +export function MessageBlock({ message }: { message: TraceMessage }) { + return ( +
+
+ + {message.role} + {message.name ? · {message.name} : null} +
+ {(message.tool_calls ?? []).map((call, i) => ( +
+ {call.name} + ( + {JSON.stringify(call.args)} + ) +
+ ))} + {message.content && ( +
+ {message.content} +
+ )} +
+ ); +} + +export function ErrorBlock({ span }: { span: Span }) { + const source = errorSource(span); + if (!source) return null; + const headline = errorHeadline(span.error ?? "") || "Span reported an error status."; + return ( +
+
+ + {ERROR_SOURCE_LABEL[source]} · {errorReason(headline)} +
+
+        {headline}
+      
+
+ ); +} + +function Payload({ label, value, mono }: { label: string; value: string; mono: boolean }) { + const messages = parseMessages(value); + if (messages) { + return ( + <> + {`${label}${messages.length > 1 ? ` · ${messages.length} messages` : ""}`} + {messages.map((message, i) => ( + + ))} + + ); + } + return ; +} + +interface DetailContentProps { + accessToken: string; + traceId: string; + span: Span; +} + +/** Content tab: the error first (if any), then what went in and what came out. */ +export function DetailContent({ accessToken, traceId, span }: DetailContentProps) { + const detailQuery = useSpanDetail(accessToken, traceId, span.span_id); + const detail = detailQuery.data; + const isTool = span.type === "tool"; + const empty = detail && !detail.input && !detail.output; + + return ( +
+ + {detailQuery.isLoading &&
Loading span…
} + {detailQuery.isError && ( +
+ Could not load span: {detailQuery.error.message} +
+ )} + {detail?.input ? : null} + {detail?.output ? : null} + {empty && span.status !== "error" && ( +
+ No content recorded for this span. +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailPane.test.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailPane.test.tsx new file mode 100644 index 00000000000..a15584e6d41 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailPane.test.tsx @@ -0,0 +1,191 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils"; +import { DetailPane } from "./DetailPane"; +import type { GroupRowData, SpanRowData } from "./traceTree"; +import type { Span, SpanDetail, Trace } from "./traceTypes"; + +vi.mock("../../networking", () => ({ + agentTraceSpanCall: vi.fn(), + getProxyBaseUrl: () => "http://proxy.test/", +})); + +import { agentTraceSpanCall } from "../../networking"; + +const span = (overrides: Partial & Pick): Span => ({ + parent_span_id: "root", + name: overrides.span_id, + type: "chain", + agent: "support_triage_agent", + start_offset_ms: 0, + duration_ms: 1300, + status: "ok", + error: null, + input_preview: "", + model: null, + input_tokens: 0, + output_tokens: 0, + litellm_request_id: null, + ...overrides, +}); + +const rootFields: SpanFields = { span_id: "root", parent_span_id: null, name: "support_triage_agent", type: "agent" }; +const llmFields: SpanFields = { + span_id: "llm1", + name: "ChatOpenAI", + type: "llm", + model: "claude-sonnet-4-5", + input_tokens: 659, + output_tokens: 60, + litellm_request_id: "chatcmpl-abc", +}; +const failedToolFields: SpanFields = { + span_id: "tool1", + name: "get_customer_plan", + type: "tool", + status: "error", + error: + "ValueError('customer acme-404 not found in billing DB')Traceback (most recent call last):\n File \"x.py\", line 1", +}; +const root = span(rootFields); +const llm = span(llmFields); +const failedTool = span(failedToolFields); + +const trace: Trace = { + summary: { + trace_id: "t1", + name: "support_triage_agent", + service: "research-agent", + input_preview: '[{"role": "user", "content": "Customer acme-404 says billing is wrong."}]', + start_time: "2026-09-30T06:43:52.928000+00:00", + duration_ms: 1310, + status: "ok", + span_count: 3, + agent_count: 1, + agent_invocations: 1, + llm_calls: 1, + tool_calls: 1, + error_count: 1, + input_tokens: 659, + output_tokens: 60, + models: ["claude-sonnet-4-5"], + } as Trace["summary"], + agents: [], + spans: [root, llm, failedTool], +}; + +const details: Record = { + llm1: { + span_id: "llm1", + input: JSON.stringify([ + { role: "system", content: "You are a LiteLLM support agent." }, + { role: "user", content: "Customer acme-404 says billing is wrong." }, + ]), + output: JSON.stringify({ + role: "assistant", + content: "", + tool_calls: [{ name: "get_customer_plan", args: { customer_id: "acme-404" } }], + }), + attributes: { "gen_ai.request.model": "claude-sonnet-4-5" }, + }, + tool1: { span_id: "tool1", input: '{"customer_id":"acme-404"}', output: "", attributes: {} }, + root: { + span_id: "root", + input: JSON.stringify([{ role: "user", content: "Customer acme-404 says billing is wrong." }]), + output: JSON.stringify({ role: "assistant", content: "Customer acme-404 is on the Enterprise plan." }), + attributes: {}, + }, +}; + +const spanRow = (s: Span): SpanRowData => ({ + kind: "span", + id: s.span_id, + span: s, + depth: 1, + hasChildren: false, + collapsed: false, +}); + +const renderPane = (row: SpanRowData | GroupRowData) => + renderWithProviders(); + +describe("DetailPane", () => { + beforeEach(() => { + testQueryClient.clear(); + vi.mocked(agentTraceSpanCall).mockReset(); + vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, spanId) => details[spanId]); + }); + + it("renders the span tabs and the fetched LLM conversation with its tool call", async () => { + renderPane(spanRow(llm)); + expect(screen.getByRole("tab", { name: "Content" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Request" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Attributes" })).toBeInTheDocument(); + expect(await screen.findByText("You are a LiteLLM support agent.")).toBeInTheDocument(); + expect(screen.getByText("get_customer_plan")).toBeInTheDocument(); + expect(vi.mocked(agentTraceSpanCall)).toHaveBeenCalledWith("sk-test", "t1", "llm1"); + }); + + it("shows a tool failure as 'Tool · ' with the exception line and no traceback", async () => { + renderPane(spanRow(failedTool)); + const error = screen.getByRole("region", { name: "Error" }); + expect(error).toHaveTextContent("Tool · ValueError"); + expect(error).toHaveTextContent("ValueError('customer acme-404 not found in billing DB')"); + expect(error).not.toHaveTextContent("Traceback"); + // tool args render as pretty JSON under "Input" + expect(await screen.findByText(/"customer_id": "acme-404"/)).toBeInTheDocument(); + }); + + it("shows the LiteLLM request facts on the Request tab", async () => { + const user = userEvent.setup(); + renderPane(spanRow(llm)); + await user.click(screen.getByRole("tab", { name: "Request" })); + expect(await screen.findByText("chatcmpl-abc")).toBeInTheDocument(); + expect(screen.getByText("659")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Open request log/ })).toBeInTheDocument(); + }); + + it("summarizes a ×N group with its failure pattern", () => { + const members = Array.from({ length: 12 }, (_, i) => { + const timedOut: SpanFields = { + span_id: `f${i}`, + name: "lookup_benchmark", + type: "tool", + status: "error", + error: "TimeoutError('slow')", + }; + return span(timedOut); + }); + const groupRow: GroupRowData = { + kind: "group", + id: "grp", + depth: 1, + name: "lookup_benchmark", + type: "tool", + agent: "researcher", + members, + failedCount: 12, + p50Duration: 640, + isFailureGroup: true, + expanded: false, + }; + renderPane(groupRow); + const pane = screen.getByRole("complementary", { name: "Group details" }); + expect(pane).toHaveTextContent("lookup_benchmark ×12"); + expect(pane).toHaveTextContent("Invocations12"); + expect(pane).toHaveTextContent("Failed12"); + expect(pane).toHaveTextContent("TimeoutError('slow')"); + }); + + it("'Copy step' copies a curl for just this span as Markdown", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + renderPane(spanRow(llm)); + await user.click(screen.getByRole("button", { name: "Copy step" })); + await waitFor(() => expect(writeText).toHaveBeenCalled()); + expect(writeText.mock.calls[0][0]).toContain("http://proxy.test/v1/traces/t1?format=md&span_id=llm1"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailPane.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailPane.tsx new file mode 100644 index 00000000000..0a4dd3c5029 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailPane.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { PanelRightClose } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/cva.config"; + +import { AttributesDetail } from "./AttributesDetail"; +import { CopyButton } from "./CopyButton"; +import { DetailContent, errorHeadline, useSpanDetail } from "./DetailContent"; +import { RequestDetail } from "./RequestDetail"; +import { agentHandoffText } from "./TraceDrawer"; +import type { GroupRowData, TreeRow } from "./traceTree"; +import type { Span, Trace } from "./traceTypes"; +import { fmtMs, fmtTok } from "./traceUtils"; + +interface DetailPaneProps { + trace: Trace; + row: TreeRow | undefined; + accessToken: string; + onClose: () => void; +} + +type Tab = "content" | "request" | "attributes"; + +const TABS: { id: Tab; label: string }[] = [ + { id: "content", label: "Content" }, + { id: "request", label: "Request" }, + { id: "attributes", label: "Attributes" }, +]; + +function PaneHeader({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + return ( +
+ {children} + +
+ ); +} + +function PaneFooter({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Meta({ label, value }: { label: string; value: string }) { + return ( + + {label}= + {value} + + ); +} + +function SpanPane({ + trace, + span, + accessToken, + onClose, +}: { + trace: Trace; + span: Span; + accessToken: string; + onClose: () => void; +}) { + const [tab, setTab] = useState("content"); + const traceId = trace.summary.trace_id; + const detailQuery = useSpanDetail(accessToken, traceId, tab === "attributes" ? span.span_id : null); + const tokens = span.input_tokens + span.output_tokens; + return ( + + ); +} + +function GroupMetric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +/** ×N group: rollup of every invocation plus the first failure's message. */ +function GroupPane({ trace, row, onClose }: { trace: Trace; row: GroupRowData; onClose: () => void }) { + const tokens = row.members.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0); + const firstFailure = row.members.find((m) => m.status === "error" && m.error); + return ( + + ); +} + +/** Right pane of the run view: switches on the selected tree row. */ +export function DetailPane({ trace, row, accessToken, onClose }: DetailPaneProps) { + if (!row || row.kind === "load-more") { + return ( +
+ Select a span to inspect it. +
+ ); + } + if (row.kind === "group") return ; + return ; +} diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/DurationBar.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/DurationBar.tsx new file mode 100644 index 00000000000..205712f4eec --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/DurationBar.tsx @@ -0,0 +1,27 @@ +import { cn } from "@/lib/cva.config"; + +interface DurationBarProps { + startMs: number; + durationMs: number; + totalMs: number; + error?: boolean; + className?: string; +} + +/** Waterfall bar: a hairline track with the span's slice of the run's timeline. */ +export function DurationBar({ startMs, durationMs, totalMs, error = false, className }: DurationBarProps) { + const left = totalMs > 0 ? Math.max(0, Math.min(98, (startMs / totalMs) * 100)) : 0; + const width = totalMs > 0 ? Math.max(1.5, Math.min(100 - left, (durationMs / totalMs) * 100)) : 1.5; + return ( +