From b99c6d40d22cbc11261e86621e58b31323c8617d Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sun, 4 Oct 2026 15:52:23 -0700 Subject: [PATCH] feat(ui): search and plot agent runs on the server The runs list sends its search and zoom window to /v1/traces, and the timeline plots /v1/traces/histogram, so every matching run is counted and listed instead of only the pages already loaded. The demo data answers the same calls locally An empty result from a search or a zoom keeps the runs controls. Before, the empty page looked like a proxy that never received a trace and replaced the list with onboarding --- .../lens/data/demo/createLensDemo.test.ts | 51 ++++- .../lens/data/demo/createLensDemo.ts | 58 ++++- .../src/components/lens/traces/api.ts | 33 ++- .../AgentTracesSection.integration.test.tsx | 104 ++++++--- .../lens/traces/list/AgentTracesSection.tsx | 23 +- .../lens/traces/list/AgentTracesTable.tsx | 11 +- .../lens/traces/list/TracesTimeline.test.ts | 46 +--- .../lens/traces/list/TracesTimeline.tsx | 39 +--- .../lens/traces/list/useAgentTraces.ts | 28 ++- .../lens/traces/list/useTraceHistogram.ts | 40 ++++ .../src/components/lens/traces/types.ts | 2 + .../src/components/networking.tsx | 4 +- ui/litellm-dashboard/src/lib/http/api.test.ts | 8 + ui/litellm-dashboard/src/lib/http/api.ts | 6 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 208 +++++++++++++++++- 15 files changed, 505 insertions(+), 156 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/lens/traces/list/useTraceHistogram.ts diff --git a/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.test.ts b/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.test.ts index be7b39607f7..bc042572a1d 100644 --- a/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.test.ts +++ b/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.test.ts @@ -1,7 +1,52 @@ import { describe, expect, it, vi } from "vitest"; -import { createLensDemo } from "./createLensDemo"; +import { createLensDemo, demoHistogram } from "./createLensDemo"; import { createLensDemoData } from "./fixtures"; import { evidenceTarget } from "../../model/findings"; +import type { TraceSummary } from "../../traces/types"; + +const HOUR = 3600 * 1000; +const START = Date.UTC(2026, 8, 30, 0, 0, 0); +const range = { startMs: START, endMs: START + 10 * HOUR }; + +const run = (offsetMs: number, errorCount = 0, agent = "researcher"): TraceSummary => + ({ + trace_id: `t${offsetMs}`, + start_time: new Date(START + offsetMs).toISOString(), + error_count: errorCount, + agent_names: [agent], + service: "svc", + }) as TraceSummary; + +describe("demoHistogram", () => { + it("splits the window into equal buckets that tile it exactly", () => { + const { buckets } = demoHistogram([], range, 10); + expect(buckets).toHaveLength(10); + expect(buckets[0].start_ms).toBe(range.startMs); + expect(buckets.at(-1)?.end_ms).toBe(range.endMs); + expect(buckets[3]).toMatchObject({ start_ms: START + 3 * HOUR, end_ms: START + 4 * HOUR, total: 0, failed: 0 }); + }); + + it("puts each run in the bucket covering its start time", () => { + const { buckets } = demoHistogram([run(0), run(30 * 60 * 1000), run(2.5 * HOUR), run(10 * HOUR - 1)], range, 10); + expect(buckets.map((b) => b.total)).toEqual([2, 0, 1, 0, 0, 0, 0, 0, 0, 1]); + }); + + it("counts failed runs apart and groups the rest by agent", () => { + const { buckets } = demoHistogram( + [run(HOUR, 3), run(HOUR + 1, 0, "writer"), run(HOUR + 2, 1), run(HOUR + 3), run(HOUR + 4, 0, "writer")], + range, + 10, + ); + expect(buckets[1]).toMatchObject({ + total: 5, + failed: 2, + agents: [ + { agent: "researcher", runs: 1 }, + { agent: "writer", runs: 2 }, + ], + }); + }); +}); describe("Lens demo data", () => { it("links every finding to the quoted original step and assessed run", () => { @@ -57,8 +102,8 @@ describe("Lens demo data", () => { const network = vi.spyOn(globalThis, "fetch"); const now = Date.now(); const services = createLensDemo(now); - const all = await services.traces.list({ startMs: 0, endMs: now }); - const recent = await services.traces.list({ startMs: now - 3600_000, endMs: now }); + const all = await services.traces.list({ startMs: 0, endMs: now + 1, q: "" }); + const recent = await services.traces.list({ startMs: now - 3600_000, endMs: now + 1, q: "" }); expect(recent.data.length).toBeGreaterThan(0); expect(recent.data.length).toBeLessThan(all.data.length); expect(recent.data.every((trace) => Date.parse(trace.start_time) >= now - 3600_000)).toBe(true); diff --git a/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.ts b/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.ts index c0b3aa50f41..e77e6c7bf0a 100644 --- a/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.ts +++ b/ui/litellm-dashboard/src/components/lens/data/demo/createLensDemo.ts @@ -1,5 +1,8 @@ import { ApiError } from "@/lib/http/client"; -import type { TracesApi } from "@/components/lens/traces/api"; +import type { TimeRange, TracesApi } from "@/components/lens/traces/api"; +import { filterRuns, RUN_INDEX } from "@/components/lens/traces/list/runSearch/runQuery"; +import type { TraceHistogram, TraceSummary } from "@/components/lens/traces/types"; +import { traceAgentNames } from "@/components/lens/traces/utils"; import type { LensServices } from "../LensServices"; import type { LensApi } from "../service"; import { createLensDemoData, type LensDemoData } from "./fixtures"; @@ -38,21 +41,62 @@ function demoLensApi(data: LensDemoData): LensApi { }; } +const startedWithin = (run: TraceSummary, range: TimeRange): boolean => { + const startMs = Date.parse(run.start_time); + return startMs >= range.startMs && startMs < range.endMs; +}; + +/** Runs per equal-width slice of the window, the shape the server's histogram returns. */ +export function demoHistogram(runs: readonly TraceSummary[], range: TimeRange, buckets: number): TraceHistogram { + const width = (range.endMs - range.startMs) / buckets; + const placed = runs.map((run) => ({ + index: Math.floor((Date.parse(run.start_time) - range.startMs) / width), + failed: run.error_count > 0, + agent: traceAgentNames(run)[0] ?? run.service, + })); + return { + buckets: Array.from({ length: buckets }, (_, index) => { + const hits = placed.filter((run) => run.index === index); + const agents = [...new Set(hits.filter((run) => !run.failed).map((run) => run.agent))].sort(); + return { + start_ms: range.startMs + index * width, + end_ms: range.startMs + (index + 1) * width, + total: hits.length, + failed: hits.filter((run) => run.failed).length, + agents: agents.map((agent) => ({ + agent, + runs: hits.filter((run) => !run.failed && run.agent === agent).length, + })), + }; + }), + }; +} + function demoTracesApi(data: LensDemoData): TracesApi { const run = (traceId: string) => data.runs.find(({ trace }) => trace.summary.trace_id === traceId); + const summaries = data.runs.map((item) => item.trace.summary); + const matching = (range: TimeRange, q: string) => + filterRuns( + summaries.filter((summary) => startedWithin(summary, range)), + q, + ); return { + scope: "demo", live: false, handoff: (traceId, spanId) => { const found = run(traceId); const step = spanId ? found?.details.find((span) => span.span_id === spanId) : found; return { text: JSON.stringify(step, null, 2), copied: spanId ? "Step copied" : "Trace copied" }; }, - list: async ({ startMs, endMs }) => ({ - data: data.runs - .map((item) => item.trace.summary) - .filter((trace) => Date.parse(trace.start_time) >= startMs && Date.parse(trace.start_time) <= endMs), - next_cursor: null, - }), + list: async ({ startMs, endMs, q }) => ({ data: matching({ startMs, endMs }, q), next_cursor: null }), + histogram: async (range, q, buckets) => demoHistogram(matching(range, q), range, buckets), + values: async (field, contains, range) => { + const read = field in RUN_INDEX.read ? RUN_INDEX.read[field as keyof typeof RUN_INDEX.read] : null; + if (!read) return []; + const needle = contains.toLowerCase(); + const values = matching(range, "").flatMap(read); + return [...new Set(values)].filter((value) => value && value.toLowerCase().includes(needle)).sort(); + }, anyRecorded: async () => data.runs.length > 0, trace: (traceId) => found(run(traceId)?.trace), span: (traceId, spanId) => found(run(traceId)?.details.find((span) => span.span_id === spanId)), diff --git a/ui/litellm-dashboard/src/components/lens/traces/api.ts b/ui/litellm-dashboard/src/components/lens/traces/api.ts index 03ea4d068bb..aca4d7d0aa9 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/api.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/api.ts @@ -9,24 +9,36 @@ import { apiClient, getProxyBaseUrl, } from "../../networking"; -import type { SpanDetail, SpanErrorPage, Trace, TracePage } from "./types"; +import type { RunField, SpanDetail, SpanErrorPage, Trace, TraceHistogram, TracePage } from "./types"; -export interface TraceWindow { +export interface TimeRange { readonly startMs: number; readonly endMs: number; +} + +export interface TraceWindow extends TimeRange { readonly cursor?: string | null; } +/** One page of the runs list: the window, the `q` search the server applies, and where to resume. */ +export interface RunListRequest extends TraceWindow { + readonly q: string; +} + export interface TraceHandoff { readonly text: string; readonly copied: string; } export interface TracesApi { + /** Partitions query caches between backends (one token, or the demo). */ + readonly scope: string; /** False for a fixed snapshot: nothing new arrives, so live tail and tracing setup don't apply. */ readonly live: boolean; handoff(traceId: string, spanId?: string | null, traceRef?: string): TraceHandoff; - list(window: TraceWindow): Promise; + list(request: RunListRequest): Promise; + histogram(range: TimeRange, q: string, buckets: number): Promise; + values(field: RunField, contains: string, range: TimeRange): Promise; anyRecorded(): Promise; trace(traceId: string, traceRef?: string, cursor?: string | null): Promise; span(traceId: string, spanId: string, traceRef?: string): Promise; @@ -46,12 +58,25 @@ export const agentHandoffText = (traceId: string, spanId?: string | null, traceR export function liveTracesApi(accessToken: string): TracesApi { return { + scope: accessToken, live: true, handoff: (traceId, spanId, traceRef) => ({ text: agentHandoffText(traceId, spanId, traceRef), copied: "Command copied", }), - list: (window) => agentTraceListCall({ accessToken, ...window }), + list: (request) => agentTraceListCall({ accessToken, ...request }), + histogram: (range, q, buckets) => + apiClient.get("/v1/traces/histogram", { + accessToken, + query: { start_ms: range.startMs, end_ms: range.endMs, q: q || undefined, buckets }, + }), + values: async (field, contains, range) => { + const found = await apiClient.get<{ values: string[] }>(`/v1/traces/values/${field}`, { + accessToken, + query: { start_ms: range.startMs, end_ms: range.endMs, contains: contains || undefined }, + }); + return found.values; + }, anyRecorded: async () => { const page = await apiClient.get("/v1/traces", { accessToken, query: { start_ms: 0 } }); return page.data.length > 0; diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx index dd7631ba703..ed5c7a1016e 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx @@ -10,6 +10,7 @@ import traceList from "../__fixtures__/trace_list.json"; import { LensPreviewContext } from "../../ui/LensPreviewButton"; import AgentTracesPage from "./AgentTracesPage"; import { filterRuns } from "./runSearch/runQuery"; +import { demoHistogram } from "../../data/demo/createLensDemo"; import { AgentTracesSection, type TimeControls } from "./AgentTracesSection"; import type { TracePage, TraceSummary } from "../types"; @@ -35,6 +36,33 @@ import { agentTraceListCall, apiClient } from "../../../networking"; const runs = (traceList as TracePage).data as TraceSummary[]; +const startedWithin = (run: TraceSummary, startMs: number, endMs: number) => { + const startedMs = Date.parse(run.start_time); + return startedMs >= startMs && startedMs < endMs; +}; + +/** The relative views end now; pin it to the fixtures' day so their runs fall in the default window. */ +const pinNowToFixtures = () => vi.useFakeTimers({ toFake: ["Date"], now: Date.parse("2026-09-30T12:00:00Z") }); + +/** Answers the list and histogram the way the proxy does: the window and `q` are applied before paging. */ +const serve = (data: readonly TraceSummary[]) => { + const matching = (startMs: number, endMs: number, q: string) => + filterRuns( + data.filter((run) => startedWithin(run, startMs, endMs)), + q, + ); + vi.mocked(agentTraceListCall).mockImplementation(async ({ startMs, endMs, q }) => ({ + data: matching(startMs, endMs, q), + next_cursor: null, + })); + vi.mocked(apiClient.get).mockImplementation(async (path: string, options?: { query?: Record }) => { + const query = options?.query ?? {}; + if (path !== "/v1/traces/histogram") return { data: [] }; + const range = { startMs: Number(query.start_ms), endMs: Number(query.end_ms) }; + return demoHistogram(matching(range.startMs, range.endMs, String(query.q ?? "")), range, Number(query.buckets)); + }); +}; + const lastUrl = (onUrlUpdate: ReturnType) => new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? "")); @@ -109,24 +137,6 @@ describe("AgentTracesSection", () => { expect(agentTraceListCall).toHaveBeenCalledTimes(2); }); - it("waits for the user before paging when filters hide every loaded run", async () => { - const user = userEvent.setup(); - vi.mocked(agentTraceListCall) - .mockResolvedValueOnce({ data: runs.slice(0, 1), next_cursor: "next" }) - .mockResolvedValueOnce({ data: runs.slice(1, 2), next_cursor: "later" }); - renderSection(); - expect(await screen.findByTestId("agent-trace-row")).toBeVisible(); - await user.type(screen.getByRole("combobox", { name: "Search runs" }), "no-such-run"); - await waitFor(() => expect(screen.queryAllByTestId("agent-trace-row")).toHaveLength(0)); - act(() => mockAllIsIntersecting(true)); - expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument(); - expect(agentTraceListCall).toHaveBeenCalledOnce(); - await user.click(screen.getByRole("button", { name: "Load older runs" })); - await waitFor(() => expect(agentTraceListCall).toHaveBeenCalledTimes(2)); - act(() => mockAllIsIntersecting(true)); - expect(agentTraceListCall).toHaveBeenCalledTimes(2); - }); - it("keeps the original time window and loaded rows when another page fails", async () => { const user = userEvent.setup(); const now = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-10-01T00:00Z")); @@ -200,6 +210,30 @@ describe("AgentTracesSection", () => { expect(card).not.toHaveTextContent("store: clickhouse"); }); + it.each([ + ["a search that matches nothing", "?q=no-such-run"], + ["a zoom over an empty slice", "?from=1790726400000&to=1790727000000"], + ])("keeps the runs controls instead of onboarding for %s", async (_, searchParams) => { + pinNowToFixtures(); + serve(runs); + renderWithProviders( + , + { searchParams }, + ); + expect(await screen.findByTestId("runs-table")).toBeInTheDocument(); + await waitFor(() => expect(agentTraceListCall).toHaveBeenCalled()); + expect(screen.queryAllByTestId("agent-trace-row")).toHaveLength(0); + expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Search runs" })).toBeInTheDocument(); + }); + it("keeps the trace list available when traces exist outside the current time window", async () => { vi.mocked(agentTraceListCall).mockResolvedValue({ ...(traceList as TracePage), data: [] }); vi.mocked(apiClient.get).mockResolvedValue(traceList); @@ -326,7 +360,8 @@ describe("AgentTracesSection", () => { it("filters by input text and by trace id", async () => { const user = userEvent.setup(); - vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + pinNowToFixtures(); + serve(runs); renderSection(); await screen.findAllByTestId("agent-trace-row"); @@ -343,13 +378,11 @@ describe("AgentTracesSection", () => { }); it("uses recorded agent names for the column, suggestions and filter even when services are shared", async () => { - vi.mocked(agentTraceListCall).mockResolvedValue({ - ...(traceList as TracePage), - data: [ - ...runs.slice(1).map((run) => ({ ...run, service: "shared-app", agent_names: ["research-agent"] })), - { ...runs[0], service: "shared-app", agent_names: ["billing-agent", "review-agent"] }, - ], - }); + pinNowToFixtures(); + serve([ + ...runs.slice(1).map((run) => ({ ...run, service: "shared-app", agent_names: ["research-agent"] })), + { ...runs[0], service: "shared-app", agent_names: ["billing-agent", "review-agent"] }, + ]); const user = userEvent.setup(); renderSection(); await screen.findAllByTestId("agent-trace-row"); @@ -464,7 +497,7 @@ describe("AgentTracesSection", () => { }); it("narrows the list to the zoom window named in the URL and clears it on request", async () => { - vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + serve(runs); const startMs = Date.parse(runs[0].start_time); const inWindow = runs.filter((run) => Math.abs(Date.parse(run.start_time) - startMs) <= 1); const onUrlUpdate = vi.fn(); @@ -518,7 +551,8 @@ describe("AgentTracesSection", () => { it("reads the query from the URL and writes typed changes back", async () => { const user = userEvent.setup(); - vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + pinNowToFixtures(); + serve(runs); const onUrlUpdate = vi.fn(); const failed = filterRuns(runs, "status:error"); renderWithProviders( @@ -545,7 +579,7 @@ describe("AgentTracesSection", () => { }); it("plots every loaded run on the timeline", async () => { - vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + serve(runs); renderWindowed(); await screen.findAllByTestId("agent-trace-row"); @@ -556,7 +590,7 @@ describe("AgentTracesSection", () => { }); it("zooms by dragging, resizes and pans the bracket, and clears with Esc", async () => { - vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage); + serve(runs); renderWindowed(); await screen.findAllByTestId("agent-trace-row"); const area = screen.getByTestId("timeline-area"); @@ -575,19 +609,19 @@ describe("AgentTracesSection", () => { drag(area, 0, 1); expect(screen.getByTestId("timeline-selection")).toBeInTheDocument(); - expect(rowCount()).toBe(0); + await waitFor(() => expect(rowCount()).toBe(0)); drag(screen.getByTestId("timeline-handle-hi"), 1, first); - expect(rowCount()).toBeGreaterThan(0); + await waitFor(() => expect(rowCount()).toBeGreaterThan(0)); drag(screen.getByTestId("timeline-selection"), 1, 1 - first); - expect(rowCount()).toBeGreaterThan(0); + await waitFor(() => expect(rowCount()).toBeGreaterThan(0)); drag(screen.getByTestId("timeline-selection"), 0, 59); - expect(rowCount()).toBe(0); + await waitFor(() => expect(rowCount()).toBe(0)); fireEvent.keyDown(screen.getByTestId("traces-timeline"), { key: "Escape" }); expect(screen.queryByTestId("timeline-selection")).not.toBeInTheDocument(); - expect(rowCount()).toBe(runs.length); + await waitFor(() => expect(rowCount()).toBe(runs.length)); }); }); diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx index ffdb3a959a5..fb66f473984 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx @@ -3,7 +3,6 @@ import moment from "moment"; import { useMemo, useState } from "react"; -import { filterRuns } from "./runSearch/runQuery"; import { RunsToolbar } from "./runSearch/RunsToolbar"; import { Inspector } from "@/components/shared/Inspector"; import { Button } from "@/components/ui/button"; @@ -20,19 +19,14 @@ import { import type { TraceSummary } from "../types"; import { RunView } from "../detail/run/RunView"; import { TimeRangeControls } from "./TimeRangeControls"; -import { TracesTimeline, type TimeWindow } from "./TracesTimeline"; +import { TracesTimeline } from "./TracesTimeline"; import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard"; import { useTracesLive } from "../api"; import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces"; +import { useTraceHistogram } from "./useTraceHistogram"; const DRAWER_WIDTH_KEY = "litellm.agentTraces.drawerWidth"; -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; @@ -97,10 +91,11 @@ export function AgentTracesSection({ const [showSetup, setShowSetup] = useState(false); const [zoom, setZoom] = useZoomRouting(); const [rangeChanged, setRangeChanged] = useState(false); - const traceQuery = { accessToken, startTime, endTime, isCustomDate, isLiveTail, enabled: isActive }; + const traceQuery = { accessToken, startTime, endTime, isCustomDate, isLiveTail, enabled: isActive, q: query, zoom }; const traces = useAgentTraces(traceQuery); - const setup = useTracingSetup(traces, isActive, rangeChanged); - const checkHistory = setup.isEmpty && !rangeChanged; + const narrowed = rangeChanged || zoom !== null || query.trim() !== ""; + const setup = useTracingSetup(traces, isActive, narrowed); + const checkHistory = setup.isEmpty && !narrowed; const history = useTraceAvailability(accessToken, isActive && checkHistory && setup.disabledDetail == null); const checkTraces = () => { @@ -114,8 +109,8 @@ export function AgentTracesSection({ () => ({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, endMs), endMs }), [startTime, endTime, isCustomDate, endMs], ); - const filtered = useMemo(() => filterRuns(traces.traces, query), [traces.traces, query]); - const runs = useMemo(() => (zoom ? filterByWindow(filtered, zoom) : filtered), [filtered, zoom]); + const buckets = useTraceHistogram(accessToken, range, query, isActive); + const runs = traces.traces; const runRefs = useMemo(() => runs.map(traceRefOf), [runs]); const changeRange = (hours: number, apply: (hours: number) => void) => { @@ -196,7 +191,7 @@ export function AgentTracesSection({ /> )} - + 0; + const autoContinue = settled && hasMore && traces.length > 0; const tableOptions: TableOptions = { data: traces, columns: RUN_COLUMNS, @@ -246,14 +245,6 @@ export function AgentTracesTable({ )} )} - {canContinue && traces.length === 0 && ( -
- No loaded runs match these filters. - -
- )} {isEmpty && } ); diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.test.ts b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.test.ts index 4da827ae370..8ec8fa69fab 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.test.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.test.ts @@ -1,47 +1,11 @@ import { describe, expect, it } from "vitest"; -import { bandForWindow, bucketRuns, dragUpdate, formatSpan } from "./TracesTimeline"; -import type { TraceSummary } from "../types"; +import { bandForWindow, type Bucket, dragUpdate, formatSpan } from "./TracesTimeline"; const HOUR = 3600 * 1000; const START = Date.UTC(2026, 8, 30, 0, 0, 0); const range = { startMs: START, endMs: START + 10 * HOUR }; -const run = (offsetMs: number, errorCount = 0): TraceSummary => - ({ - trace_id: `t${offsetMs}`, - start_time: new Date(START + offsetMs).toISOString(), - error_count: errorCount, - }) as TraceSummary; - -describe("bucketRuns", () => { - it("splits the window into equal buckets that tile it exactly", () => { - const buckets = bucketRuns([], range, 10); - expect(buckets).toHaveLength(10); - expect(buckets[0].startMs).toBe(range.startMs); - expect(buckets.at(-1)?.endMs).toBe(range.endMs); - const fourthBucket = { startMs: START + 3 * HOUR, endMs: START + 4 * HOUR, total: 0, failed: 0 }; - expect(buckets[3]).toMatchObject(fourthBucket); - }); - - it("puts each run in the bucket covering its start time", () => { - const buckets = bucketRuns([run(0), run(30 * 60 * 1000), run(2.5 * HOUR), run(10 * HOUR - 1)], range, 10); - expect(buckets.map((b) => b.total)).toEqual([2, 0, 1, 0, 0, 0, 0, 0, 0, 1]); - }); - - it("drops runs that start before or at/after the window", () => { - const buckets = bucketRuns([run(-1), run(10 * HOUR), run(20 * HOUR), run(HOUR)], range, 10); - expect(buckets.reduce((sum, b) => sum + b.total, 0)).toBe(1); - expect(buckets[1].total).toBe(1); - }); - - it("counts runs with any errors as failed", () => { - const buckets = bucketRuns([run(HOUR, 3), run(HOUR + 1), run(HOUR + 2, 1), run(5 * HOUR)], range, 10); - expect(buckets[1]).toMatchObject({ total: 3, failed: 2 }); - expect(buckets[5]).toMatchObject({ total: 1, failed: 0 }); - }); -}); - describe("formatSpan", () => { it("prints the largest two units, dropping zero parts", () => { expect(formatSpan(45 * 60 * 1000)).toBe("45m"); @@ -76,7 +40,13 @@ describe("dragUpdate", () => { describe("bandForWindow", () => { it("maps a selected window back to the buckets it covers", () => { - const buckets = bucketRuns([], range, 10); + const buckets: Bucket[] = Array.from({ length: 10 }, (_, i) => ({ + startMs: range.startMs + i * HOUR, + endMs: range.startMs + (i + 1) * HOUR, + total: 0, + failed: 0, + series: [], + })); expect(bandForWindow(buckets, { startMs: START + 2 * HOUR, endMs: START + 5 * HOUR })).toEqual({ lo: 2, hi: 4 }); expect(bandForWindow(buckets, null)).toBeNull(); }); diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx index 0cc580152fe..43e1441ef4a 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx @@ -2,53 +2,29 @@ import { X } from "lucide-react"; import moment from "moment"; -import { useMemo, useRef, useState, type RefObject } from "react"; +import { useRef, useState, type RefObject } from "react"; import { useResizeObserver } from "usehooks-ts"; import { DotFieldCanvas, DotFieldRoot } from "@/components/shared/dotField/DotField"; import type { DotBand, DotColumn } from "@/components/shared/dotField/dots"; import { cn } from "@/lib/cva.config"; -import type { TraceSummary } from "../types"; -import { traceAgentNames } from "../utils"; - -const BUCKETS = 60; +export const BUCKETS = 60; const TICKS = 6; const MINUTE_MS = 60 * 1000; const HOUR_MS = 60 * MINUTE_MS; const DAY_MS = 24 * HOUR_MS; const EDGE_FORMAT = "MMM DD, HH:mm"; -export interface TimeWindow { - startMs: number; - endMs: number; -} +import type { TimeRange as TimeWindow } from "../api"; + +export type { TimeWindow }; export interface Bucket extends DotColumn { startMs: number; endMs: number; } -/** Run counts per equal-width time bucket across the window; runs outside it are dropped. */ -export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buckets = BUCKETS): Bucket[] { - const width = (range.endMs - range.startMs) / buckets; - const placed = runs.map((run) => ({ - index: Math.floor((moment(run.start_time).valueOf() - range.startMs) / width), - failed: run.error_count > 0, - agent: traceAgentNames(run)[0] ?? "", - })); - return Array.from({ length: buckets }, (_, i) => { - const hits = placed.filter((p) => p.index === i); - return { - startMs: range.startMs + i * width, - endMs: range.startMs + (i + 1) * width, - total: hits.length, - failed: hits.filter((p) => p.failed).length, - series: hits.filter((p) => !p.failed).map((p) => p.agent), - }; - }); -} - /** Compact window length, Logfire-style: "45m", "6h 12m", "7d", "152d 23h". */ export function formatSpan(ms: number): string { const totalMinutes = Math.max(0, Math.round(ms / MINUTE_MS)); @@ -72,7 +48,7 @@ const tickShift = (t: number): string => { }; interface TracesTimelineProps { - runs: readonly TraceSummary[]; + buckets: readonly Bucket[]; range: TimeWindow; selection: TimeWindow | null; onSelect: (selection: TimeWindow | null) => void; @@ -232,8 +208,7 @@ function TickAxis({ range }: { range: TimeWindow }) { } /** Histogram of runs over the window. Drag to select; drag the bracket or its edges to adjust; Esc clears. */ -export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimelineProps) { - const buckets = useMemo(() => bucketRuns(runs, range), [runs, range]); +export function TracesTimeline({ buckets, range, selection, onSelect }: TracesTimelineProps) { const [hover, setHover] = useState(null); const [drag, setDrag] = useState(null); const [draft, setDraft] = useState(null); diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts b/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts index d82f224ef58..40470ed93ae 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/useAgentTraces.ts @@ -1,12 +1,12 @@ import { useTracesApi } from "../api"; -import { useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { keepPreviousData, useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query"; import moment from "moment"; import { useMemo } from "react"; import { ApiError } from "@/lib/http/client"; import type { TracePage, TraceSummary } from "../types"; -import type { TraceWindow } from "../api"; +import type { TimeRange, TraceWindow } from "../api"; const LIVE_TAIL_INTERVAL_MS = 15000; @@ -41,6 +41,10 @@ interface UseAgentTracesOptions { isCustomDate: boolean; isLiveTail: boolean; enabled: boolean; + /** The search the server applies before paging. */ + q: string; + /** A window inside the range that replaces it for the list. */ + zoom: TimeRange | null; } export interface AgentTracesResult { @@ -60,8 +64,8 @@ export const traceWindowStartMs = (startTime: string, endTime: string, isCustomD isCustomDate ? moment(startTime).valueOf() : nowMs - (moment(endTime).valueOf() - moment(startTime).valueOf()); /** - * GET /v1/traces for the Logs page time range, cursor-paginated as the runs list scrolls. - * Preset ranges roll on refresh; subsequent pages keep the first page's window. + * GET /v1/traces for the Logs page time range (or the zoom inside it) and search, cursor-paginated as the runs + * list scrolls. Preset ranges roll on refresh; subsequent pages keep the first page's window. */ export function useAgentTraces({ accessToken, @@ -70,18 +74,22 @@ export function useAgentTraces({ isCustomDate, isLiveTail, enabled, + q, + zoom, }: UseAgentTracesOptions): AgentTracesResult { const traces = useTracesApi(accessToken); const fetchPage = async (pageParam: unknown): Promise => { const nowMs = Date.now(); - const window = (pageParam as TraceWindow | null) ?? { - startMs: traceWindowStartMs(startTime, endTime, isCustomDate, nowMs), - endMs: isCustomDate ? moment(endTime).valueOf() : nowMs, - }; - return { ...(await traces.list(window)), window }; + const window = (pageParam as TraceWindow | null) ?? + zoom ?? { + startMs: traceWindowStartMs(startTime, endTime, isCustomDate, nowMs), + endMs: isCustomDate ? moment(endTime).valueOf() : nowMs, + }; + return { ...(await traces.list({ ...window, q })), window }; }; const queryOptions: Parameters>[0] = { - queryKey: ["agentTraces", accessToken, startTime, endTime, isCustomDate], + queryKey: ["agentTraces", traces.scope, startTime, endTime, isCustomDate, q, zoom], + placeholderData: keepPreviousData, queryFn: ({ pageParam }) => fetchPage(pageParam), initialPageParam: null, getNextPageParam: (lastPage) => diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/useTraceHistogram.ts b/ui/litellm-dashboard/src/components/lens/traces/list/useTraceHistogram.ts new file mode 100644 index 00000000000..623445e00e6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/traces/list/useTraceHistogram.ts @@ -0,0 +1,40 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +import type { TimeRange } from "../api"; +import { useTracesApi } from "../api"; +import type { TraceHistogram } from "../types"; +import { BUCKETS, type Bucket } from "./TracesTimeline"; + +/** The dot field draws one series entry per successful run. */ +export const toBuckets = (histogram: TraceHistogram): Bucket[] => + histogram.buckets.map((bucket) => ({ + startMs: bucket.start_ms, + endMs: bucket.end_ms, + total: bucket.total, + failed: bucket.failed, + series: bucket.agents.flatMap(({ agent, runs }) => Array(runs).fill(agent)), + })); + +const emptyBuckets = (range: TimeRange): Bucket[] => { + const width = (range.endMs - range.startMs) / BUCKETS; + return Array.from({ length: BUCKETS }, (_, i) => ({ + startMs: range.startMs + i * width, + endMs: range.startMs + (i + 1) * width, + total: 0, + failed: 0, + series: [], + })); +}; + +/** Matching runs per bucket across the whole range, counted by the server so every run is plotted, not just loaded ones. */ +export function useTraceHistogram(accessToken: string, range: TimeRange, q: string, enabled: boolean): Bucket[] { + const traces = useTracesApi(accessToken); + const histogram = useQuery({ + queryKey: ["agentTraceHistogram", traces.scope, range.startMs, range.endMs, q], + queryFn: () => traces.histogram(range, q, BUCKETS), + enabled, + placeholderData: keepPreviousData, + select: toBuckets, + }); + return histogram.data ?? emptyBuckets(range); +} diff --git a/ui/litellm-dashboard/src/components/lens/traces/types.ts b/ui/litellm-dashboard/src/components/lens/traces/types.ts index a424e010670..4b0e738f537 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/types.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/types.ts @@ -2,6 +2,8 @@ import type { components, paths } from "@/lib/http/schema"; export type Trace = paths["/v1/traces/{trace_id}"]["get"]["responses"][200]["content"]["application/json"]; export type TracePage = paths["/v1/traces"]["get"]["responses"][200]["content"]["application/json"]; +export type TraceHistogram = paths["/v1/traces/histogram"]["get"]["responses"][200]["content"]["application/json"]; +export type RunField = paths["/v1/traces/values/{field}"]["get"]["parameters"]["path"]["field"]; export type SpanErrorPage = paths["/v1/traces/{trace_id}/spans/{span_id}/error"]["get"]["responses"][200]["content"]["application/json"]; type ApiSpanDetail = diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 98f3a120a49..92549b0a5c0 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1961,14 +1961,16 @@ export const agentTraceListCall = async ({ accessToken, startMs, endMs, + q, cursor, }: { accessToken: string; startMs: number; endMs: number; + q: string; cursor?: string | null; }): Promise => { - const query = { start_ms: startMs, end_ms: endMs, cursor: cursor ?? undefined }; + const query = { start_ms: startMs, end_ms: endMs, q: q || undefined, cursor: cursor ?? undefined }; return apiClient.get(`/v1/traces`, { accessToken, query }); }; diff --git a/ui/litellm-dashboard/src/lib/http/api.test.ts b/ui/litellm-dashboard/src/lib/http/api.test.ts index 2ebf959867e..1f665e53660 100644 --- a/ui/litellm-dashboard/src/lib/http/api.test.ts +++ b/ui/litellm-dashboard/src/lib/http/api.test.ts @@ -195,4 +195,12 @@ describe("typed api client middleware", () => { expect(onError).not.toHaveBeenCalled(); }); + + it("sends Content-Type: application/json on a bodyless GET", async () => { + const { fetch, requests } = capturingFetch(jsonResponse(200, { data: [] })); + + await fetchClient.GET("/model_group/info", { fetch }); + + expect(requests[0].headers.get("Content-Type")).toBe("application/json"); + }); }); diff --git a/ui/litellm-dashboard/src/lib/http/api.ts b/ui/litellm-dashboard/src/lib/http/api.ts index a4fcc45d86b..c1b3db1d754 100644 --- a/ui/litellm-dashboard/src/lib/http/api.ts +++ b/ui/litellm-dashboard/src/lib/http/api.ts @@ -19,6 +19,12 @@ const middleware: Middleware = { if (token && !request.headers.has(getAuthHeaderName())) { request.headers.set(getAuthHeaderName(), `Bearer ${token}`); } + // openapi-fetch omits Content-Type on a bodyless GET, but the local dev rewrite in + // next.config.mjs (LENS_DEV_PROXY_URL) uses that header to tell an API call apart from a + // same-path dashboard page (e.g. GET /lens collides with the Lens page route) and send it to + // the real proxy instead of letting Next.js serve its own page HTML. Setting it unconditionally + // keeps that signal regardless of method or body. + request.headers.set("Content-Type", "application/json"); }, async onResponse({ response }) { if (response.ok) return response; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 34cde5bbef6..d0029210126 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -8990,6 +8990,23 @@ export interface paths { patch?: never; trace?: never; }; + "/lens/values/{field}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Lens Values */ + get: operations["list_lens_values_lens_values__field__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/lens/watch-all": { parameters: { query?: never; @@ -22616,6 +22633,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/traces/histogram": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Agent Trace Histogram */ + get: operations["agent_trace_histogram_v1_traces_histogram_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/traces/query": { parameters: { query?: never; @@ -22650,6 +22684,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/traces/values/{field}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Agent Trace Values */ + get: operations["agent_trace_values_v1_traces_values__field__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/traces/{trace_id}": { parameters: { query?: never; @@ -26053,6 +26104,13 @@ export interface components { /** Updated By */ updated_by?: string | null; }; + /** AgentRuns */ + AgentRuns: { + /** Agent */ + agent: string; + /** Runs */ + runs: number; + }; /** * AgentSkill * @description Represents a distinct capability or function that an agent can perform. @@ -33140,6 +33198,19 @@ export interface components { */ vault_token?: string | null; }; + /** HistogramBucket */ + HistogramBucket: { + /** Agents */ + agents: components["schemas"]["AgentRuns"][]; + /** End Ms */ + end_ms: number; + /** Failed */ + failed: number; + /** Start Ms */ + start_ms: number; + /** Total */ + total: number; + }; /** Hyperparameters */ Hyperparameters: { /** Batch Size */ @@ -44911,6 +44982,11 @@ export interface components { /** Start */ start?: string | null; }; + /** RunValues */ + RunValues: { + /** Values */ + values: string[]; + }; /** SCIMEnterpriseUser */ SCIMEnterpriseUser: { /** Costcenter */ @@ -47760,6 +47836,11 @@ export interface components { spans: components["schemas"]["Span"][]; summary: components["schemas"]["TraceSummary"]; }; + /** TraceHistogram */ + TraceHistogram: { + /** Buckets */ + buckets: components["schemas"]["HistogramBucket"][]; + }; /** TracePage */ TracePage: { /** Data */ @@ -62954,7 +63035,10 @@ export interface operations { }; list_lenses_lens_get: { parameters: { - query?: never; + query?: { + /** @description Free text and key:value filters, e.g. `status:failed -schedule:paused`. Keys: name, agent, status, schedule. `*` globs and a leading `-` negates */ + q?: string; + }; header?: never; path?: never; cookie?: never; @@ -62970,6 +63054,15 @@ export interface operations { "application/json": components["schemas"]["LensList"]; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; create_lens_lens_post: { @@ -63078,6 +63171,40 @@ export interface operations { }; }; }; + list_lens_values_lens_values__field__get: { + parameters: { + query?: { + contains?: string; + limit?: number; + }; + header?: never; + path: { + field: "name" | "agent" | "status" | "schedule"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string[]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; watch_all_lens_watch_all_post: { parameters: { query?: never; @@ -81301,11 +81428,13 @@ export interface operations { list_agent_traces_v1_traces_get: { parameters: { query?: { + /** @description Free text and key:value filters, e.g. `agent:research* -status:ok "book a flight"`. Keys: name, agent, status, model, input, trace_id. `*` globs and a leading `-` negates */ + q?: string; + cursor?: string | null; /** @description Window start, unix ms. Default: 24h ago */ start_ms?: number | null; /** @description Window end, unix ms. Default: now */ end_ms?: number | null; - cursor?: string | null; }; header?: never; path?: never; @@ -81353,6 +81482,43 @@ export interface operations { }; }; }; + agent_trace_histogram_v1_traces_histogram_get: { + parameters: { + query?: { + /** @description Free text and key:value filters, e.g. `agent:research* -status:ok "book a flight"`. Keys: name, agent, status, model, input, trace_id. `*` globs and a leading `-` negates */ + q?: string; + buckets?: number; + /** @description Window start, unix ms. Default: 24h ago */ + start_ms?: number | null; + /** @description Window end, unix ms. Default: now */ + end_ms?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TraceHistogram"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; query_agent_traces_v1_traces_query_post: { parameters: { query?: never; @@ -81406,6 +81572,44 @@ export interface operations { }; }; }; + agent_trace_values_v1_traces_values__field__get: { + parameters: { + query?: { + contains?: string; + limit?: number; + /** @description Window start, unix ms. Default: 24h ago */ + start_ms?: number | null; + /** @description Window end, unix ms. Default: now */ + end_ms?: number | null; + }; + header?: never; + path: { + field: "name" | "agent" | "status" | "model" | "input" | "trace_id"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RunValues"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_agent_trace_v1_traces__trace_id__get: { parameters: { query?: {