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
This commit is contained in:
Yujong Lee 2026-10-04 15:52:23 -07:00
parent 180b5ee482
commit b99c6d40d2
15 changed files with 505 additions and 156 deletions

View file

@ -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);

View file

@ -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)),

View file

@ -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<TracePage>;
list(request: RunListRequest): Promise<TracePage>;
histogram(range: TimeRange, q: string, buckets: number): Promise<TraceHistogram>;
values(field: RunField, contains: string, range: TimeRange): Promise<readonly string[]>;
anyRecorded(): Promise<boolean>;
trace(traceId: string, traceRef?: string, cursor?: string | null): Promise<Trace>;
span(traceId: string, spanId: string, traceRef?: string): Promise<SpanDetail>;
@ -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<TraceHistogram>("/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<TracePage>("/v1/traces", { accessToken, query: { start_ms: 0 } });
return page.data.length > 0;

View file

@ -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<string, unknown> }) => {
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<typeof vi.fn>) =>
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(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-30T00:00Z"
endTime="2026-10-01T00:00Z"
isCustomDate
isLiveTail={false}
/>,
{ 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));
});
});

View file

@ -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({
/>
)}
</RunsToolbar>
<TracesTimeline runs={filtered} range={range} selection={zoom} onSelect={setZoom} />
<TracesTimeline buckets={buckets} range={range} selection={zoom} onSelect={setZoom} />
<AgentTracesTable
traces={runs}
isLoading={traces.isLoading || (checkHistory && history.isLoading)}

View file

@ -205,8 +205,7 @@ export function AgentTracesTable({
}: AgentTracesTableProps) {
const settled = !isLoading && !error;
const isEmpty = settled && !hasMore && traces.length === 0;
const canContinue = settled && hasMore;
const autoContinue = canContinue && traces.length > 0;
const autoContinue = settled && hasMore && traces.length > 0;
const tableOptions: TableOptions<TraceSummary> = {
data: traces,
columns: RUN_COLUMNS,
@ -246,14 +245,6 @@ export function AgentTracesTable({
)}
</div>
)}
{canContinue && traces.length === 0 && (
<div className="flex items-center justify-center gap-3 py-16 text-xs text-muted-foreground">
<span>No loaded runs match these filters.</span>
<Button size="xs" variant="outline" disabled={isFetching} onClick={onLoadMore}>
Load older runs
</Button>
</div>
)}
{isEmpty && <EmptyRuns rangeEmpty={rangeEmpty} onSetUpTracing={onSetUpTracing} />}
</InspectorTable.Root>
);

View file

@ -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();
});

View file

@ -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<number | null>(null);
const [drag, setDrag] = useState<DragState | null>(null);
const [draft, setDraft] = useState<Band | null>(null);

View file

@ -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<LoadedTracePage> => {
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<typeof useInfiniteQuery<LoadedTracePage, Error>>[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) =>

View file

@ -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<string>(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);
}

View file

@ -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 =

View file

@ -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<TracePage> => {
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<TracePage>(`/v1/traces`, { accessToken, query });
};

View file

@ -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");
});
});

View file

@ -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;

View file

@ -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?: {