feat(lens): move traces and setup into Lens (#44068)

* feat(lens): move traces and setup into Lens

* fix(lens): refresh trace readiness and preserve loaded traces
This commit is contained in:
moe-berri 2026-10-01 13:50:55 -07:00 • committed by GitHub
parent a38fff6560
commit f88ac7424d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 641 additions and 247 deletions

View file

@ -1,8 +1,10 @@
import { screen, within } from "@testing-library/react";
import { act, screen, within } 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 { ApiError } from "@/lib/http/client";
import { apiClient } from "@/components/networking";
import { LIVE_TAIL_INTERVAL_MS } from "@/components/view_logs/log_filter_logic";
import { LensView } from "./LensView";
import { nextCheckStatus, type Lens, type Finding } from "./lensData";
@ -217,12 +219,16 @@ it("runs saved settings immediately without opening setup", async () => {
it("guides a first-time administrator into worker connection and lens setup", async () => {
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) =>
path === "/lens" ? { lenses: [], workers: [], tracing_enabled: true } : { data: [] },
path === "/lens" ? { lenses: [], workers: [], tracing_enabled: true } : { data: [{ trace_id: "first-trace" }] },
);
const user = userEvent.setup();
renderWithProviders(<LensView accessToken="test" />);
const guide = within(await screen.findByRole("region", { name: "Understand what your agents are doing" }));
expect(guide.getByRole("link", { name: "View logs" })).toHaveAttribute("href", "/ui/logs/");
expect(apiClient.get).toHaveBeenCalledWith("/v1/traces", { accessToken: "test", query: { start_ms: 0 } });
expect(guide.getByRole("link", { name: "View traces" })).toHaveAttribute(
"href",
expect.stringMatching(/^\/ui\/lens\/?\?tab=traces$/),
);
await user.click(guide.getByRole("button", { name: "Connect analyzer" }));
const connection = within(await screen.findByRole("dialog", { name: "Set up Lens analysis" }));
expect(connection.getByRole("button", { name: "Generate setup command" })).toBeVisible();
@ -298,3 +304,78 @@ it("reads request content from the beginning after its abbreviated preview", asy
await user.click(screen.getByRole("button", { name: "Previous section" }));
expect(await screen.findByText("Abbreviated preview")).toBeVisible();
});
it.each([false, true])(
"directs a new user to traces when tracing_enabled=%s and there are no traces",
async (enabled) => {
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) =>
path === "/lens" ? { lenses: [], workers: [], tracing_enabled: enabled } : { data: [] },
);
renderWithProviders(<LensView accessToken="test" />);
expect(await screen.findByRole("heading", { name: "Set up traces to start running investigations" })).toBeVisible();
expect(screen.getByRole("link", { name: "Set up traces" })).toHaveAttribute(
"href",
expect.stringMatching(/^\/ui\/lens\/?\?tab=traces$/),
);
expect(screen.queryByRole("button", { name: "Set up your first lens" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Set up analysis" })).not.toBeInTheDocument();
},
);
it("enables first-lens setup when a trace arrives without leaving Investigations", async () => {
testQueryClient.clear();
const traceCheck = vi.fn().mockResolvedValue({ data: [] });
vi.mocked(apiClient.get).mockImplementation(async (path) =>
path === "/lens" ? { lenses: [], workers: [], tracing_enabled: true } : traceCheck(),
);
vi.useFakeTimers();
try {
const view = renderWithProviders(<LensView accessToken="test" />);
await act(async () => vi.advanceTimersByTimeAsync(50));
expect(screen.getByRole("link", { name: "Set up traces" })).toBeVisible();
traceCheck.mockResolvedValue({ data: [{ trace_id: "first-trace" }] });
await act(async () => vi.advanceTimersByTimeAsync(LIVE_TAIL_INTERVAL_MS));
expect(screen.getByRole("button", { name: "Set up your first lens" })).toBeVisible();
expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument();
const completedChecks = traceCheck.mock.calls.length;
await act(async () => vi.advanceTimersByTimeAsync(LIVE_TAIL_INTERVAL_MS * 2));
expect(traceCheck).toHaveBeenCalledTimes(completedChecks);
view.unmount();
} finally {
vi.useRealTimers();
}
});
it("allows retrying a failed trace readiness check without treating it as an empty account", async () => {
testQueryClient.clear();
const traceCheck = vi
.fn()
.mockRejectedValueOnce(new ApiError("Trace storage unavailable", 503, {}))
.mockResolvedValue({ data: [] });
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [], workers: [], tracing_enabled: true };
if (path === "/v1/traces") return traceCheck();
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<LensView accessToken="test" />);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not check traces. Trace storage unavailable");
expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("link", { name: "Set up traces" })).toBeVisible();
});
it("keeps saved investigations accessible when tracing is disabled", async () => {
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], workers: [], tracing_enabled: false };
if (path === "/lens/lens/runs") return lens.jobs;
return { data: [] };
});
renderWithProviders(<LensView accessToken="test" readOnly />);
expect(await screen.findByText(issue.title)).toBeVisible();
expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument();
});

View file

@ -3,18 +3,7 @@
import type { components } from "@/lib/http/schema";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Aperture,
ArrowUpRight,
CheckCircle2,
Circle,
Info,
Layers3,
Pause,
Play,
Plus,
Settings2,
} from "lucide-react";
import { ArrowUpRight, CheckCircle2, Circle, Info, Layers3, Pause, Play, Plus, Settings2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
@ -186,18 +175,9 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
};
return (
<main className="w-full min-w-0 p-6 md:p-8 space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<Aperture aria-hidden="true" className="size-7" strokeWidth={1.75} />
<h1 className="text-2xl font-semibold tracking-tight">Lens</h1>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Understand your agent activity. Find patterns worth acting on.
</p>
</div>
{!readOnly && (
<section aria-label="Investigations" className="w-full min-w-0 space-y-6">
<header className="flex flex-wrap justify-end gap-4">
{!readOnly && !showEmpty && (
<div className="flex gap-2">
<Button variant="outline" onClick={() => setWorkerSetup(true)}>
<Circle
@ -229,6 +209,8 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
)}
{showEmpty && (
<LensWelcome
accessToken={accessToken}
tracingEnabled={query.data?.tracing_enabled ?? false}
connected={connected}
readOnly={!!readOnly}
onConnect={() => setWorkerSetup(true)}
@ -794,6 +776,6 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
</div>
</SheetContent>
</Sheet>
</main>
</section>
);
}

View file

@ -1,18 +1,58 @@
import Link from "next/link";
import { isTracingNotEnabled, useTraceAvailability } from "@/components/view_logs/TraceView/useAgentTraces";
import { Aperture, ArrowUpRight, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { uiHref } from "@/utils/uiHref";
export function LensWelcome({
accessToken,
tracingEnabled,
connected,
readOnly,
onConnect,
onCreate,
}: {
accessToken: string;
tracingEnabled: boolean;
connected: boolean;
readOnly: boolean;
onConnect: () => void;
onCreate: () => void;
}) {
const traces = useTraceAvailability(accessToken, tracingEnabled);
if (tracingEnabled && traces.isPending) {
return (
<p role="status" className="py-8 text-sm text-muted-foreground">
Checking for traces…
</p>
);
}
if (traces.error && !isTracingNotEnabled(traces.error)) {
return (
<div role="alert" className="py-8 text-sm">
<p>Could not check traces. {traces.error.message}</p>
<Button variant="outline" className="mt-4" onClick={() => void traces.refetch()}>
Retry
</Button>
</div>
);
}
if (!tracingEnabled || !traces.data || isTracingNotEnabled(traces.error)) {
return (
<section className="py-8">
<h2 className="text-lg font-semibold">Set up traces to start running investigations</h2>
{tracingEnabled && !traces.error && (
<p className="mt-2 text-sm text-muted-foreground">No agent traces received yet.</p>
)}
<Link
href={uiHref("lens/?tab=traces")}
className="mt-4 inline-flex items-center gap-1 text-sm font-medium underline underline-offset-4"
>
Set up traces <ArrowUpRight aria-hidden="true" className="size-4" />
</Link>
</section>
);
}
return (
<section aria-labelledby="lens-welcome" className="overflow-hidden rounded-xl border bg-card">
<div className="border-b bg-muted/20 px-6 py-10 sm:px-10">
@ -34,12 +74,12 @@ export function LensWelcome({
Use the agent traces or LLM requests already in LiteLLM. Lens needs their inputs and outputs to understand
what happened.
</p>
<a
href={uiHref("logs/")}
<Link
href={uiHref("lens/?tab=traces")}
className="mt-auto inline-flex items-center gap-1 pt-3 text-sm font-medium underline-offset-4 hover:underline"
>
View logs <ArrowUpRight aria-hidden="true" className="size-4" />
</a>
View traces <ArrowUpRight aria-hidden="true" className="size-4" />
</Link>
</li>
<li className="flex flex-col gap-3 p-6 sm:p-8">
<span className="flex size-7 items-center justify-center rounded-full border text-xs font-medium">

View file

@ -0,0 +1,63 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import LensPage from "./page";
const { auth } = vi.hoisted(() => ({ auth: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: auth }));
vi.mock("@/components/view_logs/TraceView/AgentTracesPage", () => ({
default: ({ isActive }: { isActive: boolean }) => <div>Trace polling {isActive ? "active" : "paused"}</div>,
}));
vi.mock("./_components/LensView", () => ({
LensView: ({ readOnly }: { readOnly: boolean }) => (
<div>{readOnly ? "Read-only investigations" : "Manage investigations"}</div>
),
}));
describe("Lens navigation", () => {
beforeEach(() => auth.mockReturnValue({ accessToken: "test-token", userRole: "Admin", isViewOnly: false }));
it("opens traces and pauses trace polling when navigating to investigations", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<LensPage />, { onUrlUpdate });
expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Trace polling active")).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(screen.getByText("Manage investigations")).toBeVisible();
expect(screen.getByText("Trace polling paused")).not.toBeVisible();
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("tab")).toBe("investigations");
await user.click(screen.getByRole("tab", { name: "Traces" }));
expect(screen.getByText("Trace polling active")).toBeVisible();
});
it("opens existing lens links directly in investigations", () => {
renderWithProviders(<LensPage />, { searchParams: "?lens=saved-lens" });
expect(screen.getByRole("tab", { name: "Investigations" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Manage investigations")).toBeVisible();
});
it.each(["Internal User", "Internal Viewer", "Org Admin"])(
"preserves trace access without granting investigations to %s",
async (userRole) => {
auth.mockReturnValue({ accessToken: "test-token", userRole, isViewOnly: false });
const user = userEvent.setup();
renderWithProviders(<LensPage />);
expect(screen.getByText("Trace polling active")).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(screen.getByText(/Investigations require proxy administrator access/)).toBeVisible();
expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument();
},
);
it.each([
{ userRole: "Admin Viewer", isViewOnly: false },
{ userRole: "Admin", isViewOnly: true },
])("preserves read-only investigation access for $userRole with isViewOnly=$isViewOnly", (session) => {
auth.mockReturnValue({ accessToken: "test-token", ...session });
renderWithProviders(<LensPage />, { searchParams: "?tab=investigations" });
expect(screen.getByText("Read-only investigations")).toBeVisible();
expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument();
});
});

View file

@ -1,14 +1,54 @@
"use client";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Aperture } from "lucide-react";
import { parseAsString, parseAsStringLiteral, useQueryState } from "nuqs";
import AgentTracesPage from "@/components/view_logs/TraceView/AgentTracesPage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
import { LensView } from "./_components/LensView";
export default function LensPage() {
const { accessToken, userRole } = useAuthorized();
const { accessToken, userRole, isViewOnly } = useAuthorized();
const [tab, setTab] = useQueryState(
"tab",
parseAsStringLiteral(["traces", "investigations"]).withOptions({ history: "push" }),
);
const [lensId] = useQueryState("lens", parseAsString);
const activeTab = tab ?? (lensId ? "investigations" : "traces");
if (!accessToken) return null;
if (!isProxyAdminTierRole(userRole ?? "")) {
return <p className="p-6 text-sm text-muted-foreground">Lens requires proxy administrator access.</p>;
}
return <LensView accessToken={accessToken} readOnly={!isProxyAdminRole(userRole ?? "")} />;
return (
<main className="flex w-full min-w-0 flex-1 flex-col gap-5 p-6 md:p-8">
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-7" strokeWidth={1.75} />
Lens
</h1>
<Tabs
value={activeTab}
onValueChange={(value) => void setTab(value as "traces" | "investigations")}
className="min-h-0 flex-1 gap-4"
>
<TabsList variant="line" aria-label="Lens" className="w-full justify-start gap-6 border-b px-0">
<TabsTrigger value="traces" className="flex-none px-0">
Traces
</TabsTrigger>
<TabsTrigger value="investigations" className="flex-none px-0">
Investigations
</TabsTrigger>
</TabsList>
<TabsContent value="traces" keepMounted className="min-h-0">
<AgentTracesPage accessToken={accessToken} isActive={activeTab === "traces"} />
</TabsContent>
<TabsContent value="investigations">
{isProxyAdminTierRole(userRole ?? "") ? (
<LensView accessToken={accessToken} readOnly={isViewOnly || !isProxyAdminRole(userRole ?? "")} />
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</TabsContent>
</Tabs>
</main>
);
}

View file

@ -496,6 +496,7 @@ describe("Sidebar (leftnav)", () => {
expect(screen.queryByText("Guardrails Monitor")).not.toBeInTheDocument();
expect(screen.getByText("Usage")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Lens Beta" })).toHaveAttribute("href", "/ui/lens");
expect(screen.getByText("Cost Optimization")).toBeInTheDocument();
});

View file

@ -71,7 +71,6 @@ import { cn } from "@/lib/cva.config";
import { rolesWithCapability } from "../utils/capabilities";
import {
all_admin_roles,
proxyAdminTierRoles,
internalUserRoles,
isAdminRole,
isUserTeamAdminForAnyTeam,
@ -252,7 +251,6 @@ const menuGroups: MenuGroup[] = [
</span>
),
icon: <Aperture {...ICON} />,
roles: proxyAdminTierRoles,
},
{
key: "guardrails-monitor",

View file

@ -8,8 +8,7 @@ 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 }) {
export default function AgentTracesPage({ accessToken, isActive = true }: { accessToken: string; isActive?: boolean }) {
const [rangeHours, setRangeHours] = useState(DEFAULT_RANGE_HOURS);
const [live, setLive] = useState(true);
const [anchor, setAnchor] = useState(() => moment());
@ -30,7 +29,7 @@ export default function AgentTracesPage({ accessToken }: { accessToken: string }
<div className="flex min-h-0 flex-1 flex-col pt-3">
<AgentTracesSection
accessToken={accessToken}
isActive
isActive={isActive}
startTime={startTime}
endTime={endTime}
isCustomDate={false}

View file

@ -1,4 +1,4 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/http/client";
@ -10,6 +10,7 @@ import { AgentTracesSection, filterRuns } from "./AgentTracesSection";
import type { TracePage, TraceSummary } from "./traceTypes";
vi.mock("../../networking", () => ({
apiClient: { get: vi.fn() },
agentTraceListCall: vi.fn(),
agentTraceCall: vi.fn(),
agentTraceSpanCall: vi.fn(),
@ -25,7 +26,7 @@ vi.mock("./TraceDrawer", () => ({
),
}));
import { agentTraceListCall } from "../../networking";
import { agentTraceListCall, apiClient } from "../../networking";
const runs = (traceList as TracePage).data as TraceSummary[];
@ -76,6 +77,7 @@ describe("AgentTracesSection", () => {
} as DOMRect);
testQueryClient.clear();
vi.mocked(agentTraceListCall).mockReset();
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
});
it("renders the setup snippet when the proxy answers 501", async () => {
@ -87,10 +89,10 @@ describe("AgentTracesSection", () => {
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(screen.getByRole("button", { name: "Check setup" })).toBeEnabled();
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");
expect(card).toHaveTextContent("ClickHouse and proxy setup");
expect(card).toHaveTextContent("Ask your proxy administrator");
});
it("shows the waiting guide when tracing is on but no runs have arrived", async () => {
@ -98,11 +100,97 @@ describe("AgentTracesSection", () => {
renderSection();
const card = await screen.findByTestId("tracing-setup-card");
expect(card).toHaveTextContent("Waiting for traces");
expect(card).toHaveTextContent("No traces detected yet");
expect(card).toHaveTextContent("Connect your agent");
expect(card).toHaveTextContent("Waiting for your first trace");
expect(card).not.toHaveTextContent("store: clickhouse");
});
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);
renderSection();
expect(await screen.findByText("No runs match these filters.")).toBeVisible();
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
expect(apiClient.get).toHaveBeenCalledWith("/v1/traces", { accessToken: "sk-test", query: { start_ms: 0 } });
});
it("checks proxy readiness, waits for an agent, and confirms receipt using actual query results", async () => {
vi.mocked(agentTraceListCall).mockRejectedValue(new ApiError("Tracing is not enabled", 501, {}));
renderSection();
const checkSetup = await screen.findByRole("button", { name: "Check setup" });
vi.mocked(agentTraceListCall).mockResolvedValue({ ...(traceList as TracePage), data: [] });
fireEvent.click(checkSetup);
expect(await screen.findByRole("heading", { name: "Connect your agent" })).toBeVisible();
expect(screen.getByText("Waiting for your first trace")).toBeVisible();
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
fireEvent.click(screen.getByRole("button", { name: "Check for traces" }));
expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(runs.length);
expect(screen.getByText("Traces received. Select a run to inspect it.")).toBeVisible();
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
});
it("keeps setup visible while checking and explains when tracing is still disabled", async () => {
const failure = new ApiError("Tracing is not enabled", 501, {});
vi.mocked(agentTraceListCall).mockRejectedValue(failure);
renderSection();
const checkSetup = await screen.findByRole("button", { name: "Check setup" });
let rejectCheck: (error: Error) => void = () => {};
vi.mocked(agentTraceListCall).mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectCheck = reject;
}),
);
fireEvent.click(checkSetup);
expect(await screen.findByRole("button", { name: "Checking…" })).toBeDisabled();
expect(screen.getByRole("heading", { name: "Enable tracing" })).toBeVisible();
await act(async () => rejectCheck(failure));
expect(await screen.findByText(/Tracing is still unavailable/)).toBeVisible();
expect(screen.getByRole("button", { name: "Check setup" })).toBeEnabled();
});
it("keeps received traces and the open drawer visible during subsequent fetches", async () => {
vi.mocked(agentTraceListCall).mockRejectedValue(new ApiError("Tracing is not enabled", 501, {}));
renderSection();
const checkSetup = await screen.findByRole("button", { name: "Check setup" });
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
fireEvent.click(checkSetup);
const rows = await screen.findAllByTestId("agent-trace-row");
fireEvent.click(rows[0]);
const drawer = screen.getByRole("complementary", { name: "Trace details" });
let finishRefresh: (page: TracePage) => void = () => {};
vi.mocked(agentTraceListCall).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishRefresh = resolve;
}),
);
await act(async () => {
void testQueryClient.invalidateQueries({ queryKey: ["agentTraces"] });
});
expect(await screen.findByText("Updating…")).toBeVisible();
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length);
expect(screen.getByRole("complementary", { name: "Trace details" })).toBe(drawer);
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
await act(async () => finishRefresh(traceList as TracePage));
});
it("separates a failed history check from the empty list and retries that check", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue({ ...(traceList as TracePage), data: [] });
vi.mocked(apiClient.get).mockRejectedValue(new ApiError("History unavailable", 503, {}));
renderSection();
expect(await screen.findByRole("alert")).toHaveTextContent("Could not check earlier traces. History unavailable");
expect(screen.getByText("No runs match these filters.")).toBeVisible();
expect(screen.queryByText(/Could not load runs/)).not.toBeInTheDocument();
vi.mocked(apiClient.get).mockResolvedValue(traceList);
fireEvent.click(screen.getByRole("button", { name: "Retry trace check" }));
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(screen.getByText("No runs match these filters.")).toBeVisible();
expect(agentTraceListCall).toHaveBeenCalledTimes(1);
});
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();
@ -261,6 +349,7 @@ describe("AgentTracesPage", () => {
beforeEach(() => {
testQueryClient.clear();
vi.mocked(agentTraceListCall).mockReset();
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
});
it("shows the actual range, switches presets from the popover, and toggles Live", async () => {

View file

@ -14,7 +14,7 @@ import { previewText } from "./traceUtils";
import { TimeRangeControls } from "./TimeRangeControls";
import { TracesTimeline, type TimeWindow } from "./TracesTimeline";
import { TracingSetupCard } from "./TracingSetupCard";
import { traceWindowStartMs, useAgentTraces } from "./useAgentTraces";
import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
/** Client-side search (input text or trace id) plus service / status filters over the loaded runs. */
export function filterRuns(
@ -61,6 +61,33 @@ interface AgentTracesSectionProps {
onRunOpenChange?: (open: boolean) => void;
}
function useTracingSetup(traces: AgentTracesResult, isActive: boolean, rangeChanged: boolean) {
const [setupResult, setSetupResult] = useState<{ detail: string | null } | null>(null);
const waitingForFirstTrace = traces.traces.length === 0 && !rangeChanged;
const settledResponse = isActive && !traces.isFetching && !traces.error;
const rememberSetup = waitingForFirstTrace || setupResult !== null;
if (settledResponse && rememberSetup && setupResult?.detail !== traces.notEnabledDetail) {
setSetupResult({ detail: traces.notEnabledDetail });
}
const disabledDetail = traces.notEnabledDetail ?? (traces.isFetching ? setupResult?.detail : null);
const loadingFirstPage = traces.isLoading && setupResult === null;
const isEmpty = !loadingFirstPage && !traces.error && traces.traces.length === 0;
return { disabledDetail, isEmpty, received: setupResult !== null && traces.traces.length > 0 };
}
function TraceHistoryError({ history }: { history: ReturnType<typeof useTraceAvailability> }) {
if (!history.error) return null;
return (
<div role="alert" className="flex items-center justify-between gap-4 border-b px-3 py-3 text-sm">
<p>Could not check earlier traces. {history.error.message}</p>
<Button variant="outline" size="sm" disabled={history.isFetching} onClick={() => void history.refetch()}>
Retry trace check
</Button>
</div>
);
}
/** The Runs view: filters, the runs table and footer — or one run, in place, once a row is clicked. */
export function AgentTracesSection({
accessToken,
@ -81,6 +108,14 @@ export function AgentTracesSection({
const [rangeChanged, setRangeChanged] = useState(false);
const traceQuery = { accessToken, startTime, endTime, isCustomDate, isLiveTail, enabled: isActive };
const traces = useAgentTraces(traceQuery);
const setup = useTracingSetup(traces, isActive, rangeChanged);
const checkHistory = setup.isEmpty && !rangeChanged;
const history = useTraceAvailability(accessToken, isActive && checkHistory && setup.disabledDetail == null);
const checkTraces = () => {
traces.refetch();
if (setup.disabledDetail == null) void history.refetch();
};
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.
@ -106,10 +141,11 @@ export function AgentTracesSection({
onRunOpenChange?.(trace !== null);
};
if (traces.notEnabledDetail !== null) return <TracingSetupCard detail={traces.notEnabledDetail} />;
if (setup.disabledDetail != null)
return <TracingSetupCard detail={setup.disabledDetail} onCheck={checkTraces} checking={traces.isFetching} />;
// 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 <TracingSetupCard detail={null} />;
if (checkHistory && !history.error && history.data === false)
return <TracingSetupCard detail={null} onCheck={checkTraces} checking={traces.isFetching} />;
if (showSetup) {
return (
<div>
@ -120,7 +156,7 @@ export function AgentTracesSection({
>
← Back to traces
</button>
<TracingSetupCard detail={null} connected />
<TracingSetupCard detail={null} connected onCheck={checkTraces} checking={traces.isFetching} />
</div>
);
}
@ -130,6 +166,12 @@ export function AgentTracesSection({
return (
<div className="flex min-h-[560px] flex-1 flex-col overflow-hidden border-y border-border bg-card">
{checkHistory && <TraceHistoryError history={history} />}
{setup.received && (
<p role="status" className="border-b px-3 py-3 text-sm text-emerald-700 dark:text-emerald-400">
Traces received. Select a run to inspect it.
</p>
)}
<RunDrawer trace={openTrace} runs={runs} accessToken={accessToken} onSelect={openRun} />
<RunsToolbar
query={query}
@ -159,7 +201,7 @@ export function AgentTracesSection({
<TracesTimeline runs={filtered} range={range} selection={zoom} onSelect={setZoom} />
<AgentTracesTable
traces={runs}
isLoading={traces.isLoading}
isLoading={traces.isLoading || (checkHistory && history.isLoading)}
error={traces.error}
hasMore={traces.hasMore}
onLoadMore={traces.loadMore}

View file

@ -1,57 +1,74 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { chooseSelectOption } from "@/../tests/test-utils";
import { copyToClipboard } from "@/utils/dataUtils";
import { codingAgentPrompt, tracingEnvSnippet, TracingSetupCard } from "./TracingSetupCard";
vi.mock("../../networking", () => ({ getProxyBaseUrl: () => "http://proxy.test/" }));
vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn().mockResolvedValue(true) }));
describe("TracingSetupCard", () => {
it("shows the waiting state and OTEL-only setup when tracing is on", () => {
render(<TracingSetupCard detail={null} />);
const card = screen.getByTestId("tracing-setup-card");
expect(card).toHaveTextContent("Waiting for traces");
expect(card).toHaveTextContent("No traces detected yet. Follow our guide to start tracing your application.");
expect(card).toHaveTextContent("OTEL_EXPORTER_OTLP_ENDPOINT=http://proxy.test");
expect(card).not.toHaveTextContent(/langsmith/i);
expect(card).not.toHaveTextContent("store: clickhouse");
});
it("swaps the install command and prompt when another framework is picked", async () => {
it("shows agent connection guidance once tracing is enabled without presenting example data as live", async () => {
const user = userEvent.setup();
render(<TracingSetupCard detail={null} />);
expect(screen.getByTestId("tracing-setup-card")).toHaveTextContent("openinference-instrumentation-langchain");
await user.click(screen.getByRole("radio", { name: "CrewAI" }));
const card = screen.getByTestId("tracing-setup-card");
expect(screen.getByRole("radio", { name: "CrewAI" })).toBeChecked();
expect(card).toHaveTextContent("pip install -U opentelemetry-distro");
expect(card).toHaveTextContent("crewai openinference-instrumentation-crewai");
expect(card).toHaveTextContent("Send this CrewAI project's OpenTelemetry traces to LiteLLM.");
expect(card).not.toHaveTextContent("openinference-instrumentation-langchain");
expect(screen.getByRole("heading", { name: "Connect your agent" })).toBeVisible();
expect(screen.getByText("Tracing enabled")).toBeVisible();
expect(screen.getByText("Waiting for your first trace")).toBeVisible();
expect(screen.getByRole("img", { hidden: true, name: /Example agent trace/ })).not.toBeVisible();
expect(screen.getByTestId("tracing-setup-card")).not.toHaveTextContent("store: clickhouse");
await user.click(screen.getByText("Set up manually"));
expect(screen.getByText(/OTEL_EXPORTER_OTLP_ENDPOINT=http:\/\/proxy.test/)).toBeVisible();
expect(screen.getByTestId("tracing-setup-card")).not.toHaveTextContent(/langsmith/i);
});
it("shows the proxy config step only when tracing is not enabled", () => {
render(<TracingSetupCard detail="Agent tracing is not enabled" />);
const card = screen.getByTestId("tracing-setup-card");
expect(card).toHaveTextContent("Tracing is not enabled");
expect(card).toHaveTextContent("store: clickhouse");
it("copies instructions for the selected framework and preserves both manual installers", async () => {
const user = userEvent.setup();
render(<TracingSetupCard detail={null} />);
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Your agent framework" }), "CrewAI");
await user.click(screen.getByRole("button", { name: "Copy setup prompt" }));
expect(copyToClipboard).toHaveBeenLastCalledWith(
codingAgentPrompt("http://proxy.test", {
label: "CrewAI",
packages: "crewai openinference-instrumentation-crewai",
}),
);
expect(screen.getByRole("button", { name: "Prompt copied" })).toBeVisible();
await user.click(screen.getByText("Set up manually"));
expect(screen.getByText(/pip install -U opentelemetry-distro/)).toHaveTextContent(
"crewai openinference-instrumentation-crewai",
);
await user.click(screen.getByRole("tab", { name: "uv" }));
expect(screen.getByText(/uv add opentelemetry-distro/)).toHaveTextContent(
"crewai openinference-instrumentation-crewai",
);
});
it("guides proxy setup before agent setup and allows checking readiness", async () => {
const user = userEvent.setup();
const onCheck = vi.fn();
render(<TracingSetupCard detail="Agent tracing is not enabled" onCheck={onCheck} />);
expect(screen.getByRole("heading", { name: "Enable tracing" })).toBeVisible();
expect(screen.getByTestId("tracing-setup-card")).toHaveTextContent("store: clickhouse");
expect(screen.queryByRole("combobox", { name: "Your agent framework" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Check setup" }));
expect(onCheck).toHaveBeenCalledOnce();
expect(screen.getByText(/Tracing is still unavailable/)).toBeVisible();
});
});
describe("setup snippets", () => {
it("points OTLP at the proxy base URL and reads the key from the environment", () => {
it("uses OTLP HTTP with the proxy base URL and reads the key from the environment", () => {
const env = tracingEnvSnippet("http://proxy.test");
expect(env).toContain("OTEL_EXPORTER_OTLP_ENDPOINT=http://proxy.test\n");
expect(env).toContain("OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf");
expect(env).not.toContain("/v1/traces");
expect(env).toContain("Bearer $LITELLM_API_KEY");
const prompt = codingAgentPrompt("http://proxy.test", { label: "LangChain", packages: "langchain" });
expect(prompt).toContain("base_url=http://proxy.test/v1");
expect(prompt).toContain("opentelemetry-distro opentelemetry-exporter-otlp-proto-http langchain");
expect(prompt).toContain("OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf");
expect(prompt).toContain("Lens > Traces");
expect(prompt).not.toMatch(/langsmith/i);
});
});

View file

@ -4,6 +4,8 @@ import { ArrowUpRight, Check, Copy, Loader2 } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/cva.config";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { copyToClipboard } from "@/utils/dataUtils";
import previewImg from "../../../../public/assets/agent-traces-preview.png";
@ -17,7 +19,7 @@ import pydanticAiLogo from "../../../../public/assets/logos/pydantic-ai-color.sv
import { getProxyBaseUrl } from "../../networking";
const COPIED_RESET_MS = 1500;
const DOCS_URL = "https://docs.litellm.ai";
const DOCS_URL = "https://docs.litellm.ai/docs/proxy/lens";
const OTEL_BASE_PACKAGES = "opentelemetry-distro opentelemetry-exporter-otlp-proto-http";
const RUN_SNIPPET = "opentelemetry-instrument python my_agent.py";
@ -117,13 +119,6 @@ with tracer.start_as_current_span("my_agent", attributes=attrs):
},
];
const HIGHLIGHTS = [
["Input and output", "What the agent was asked and what it answered, at the top of every run."],
["Every step, nested", "LLM calls, tool calls and subagents in one tree, with timing."],
["Failures pinpointed", "See whether the tool, the model or LiteLLM broke."],
["Hand off to Claude / Codex", "Copy one command and your coding agent debugs the run."],
] as const;
const installPackages = (guide: Pick<FrameworkGuide, "packages">): string =>
[OTEL_BASE_PACKAGES, guide.packages].filter(Boolean).join(" ");
@ -131,6 +126,7 @@ const installPackages = (guide: Pick<FrameworkGuide, "packages">): string =>
export const tracingEnvSnippet = (proxyUrl: string): string =>
[
`export OTEL_EXPORTER_OTLP_ENDPOINT=${proxyUrl}`,
"export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf",
'export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $LITELLM_API_KEY"',
"export OTEL_SERVICE_NAME=my-agent",
].join("\n");
@ -142,12 +138,13 @@ export const codingAgentPrompt = (proxyUrl: string, guide: Pick<FrameworkGuide,
`1. Add these dependencies: ${installPackages(guide)}`,
"2. Set these env vars wherever the project loads config (.env, settings, deployment manifests):",
` OTEL_EXPORTER_OTLP_ENDPOINT=${proxyUrl}`,
" OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf",
' OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $LITELLM_API_KEY"',
" OTEL_SERVICE_NAME=<a short name for this agent>",
"3. Start the app through OTEL auto-instrumentation: opentelemetry-instrument <existing start command>.",
`4. Point every LLM client at LiteLLM: base_url=${proxyUrl}/v1, api key from LITELLM_API_KEY.`,
"5. Give each agent and subagent a name so runs are easy to read.",
"6. Run the agent once and confirm the run shows up in the LiteLLM UI under Logs > Agent Traces.",
"6. Run the agent once and confirm the run shows up in the LiteLLM UI under Lens > Traces.",
"",
"Never hardcode the key. Read it from LITELLM_API_KEY.",
].join("\n");
@ -163,9 +160,10 @@ export const PROXY_CONFIG_SNIPPET = [
function CodeBlock({ code, tabs, wrap = false }: { code: string; tabs?: React.ReactNode; wrap?: boolean }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
await copyToClipboard(code);
setCopied(true);
window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
if (await copyToClipboard(code)) {
setCopied(true);
window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
}
};
return (
<div className="overflow-hidden rounded-md border border-border bg-muted/30">
@ -237,154 +235,198 @@ function Step({ title, children }: { title: string; children: React.ReactNode })
);
}
function SetupStatus({ detail, connected }: { detail: string | null; connected: boolean }) {
const pill = "inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-[12px] text-foreground";
const hint = "text-[13px] text-muted-foreground";
if (connected) {
return (
<>
<span className={pill}>
<Check className="size-3" /> Receiving traces
</span>
<span className={hint}>Add another agent: point its OpenTelemetry exporter at LiteLLM.</span>
</>
);
}
if (detail === null) {
return (
<>
<span className={pill}>
<Loader2 className="size-3 animate-spin" /> Waiting for traces…
</span>
<span className={hint}>No traces detected yet. Follow our guide to start tracing your application.</span>
</>
);
}
function TraceReceipt({
connected,
checked,
checking,
onCheck,
}: {
connected: boolean;
checked: boolean;
checking: boolean;
onCheck: () => void;
}) {
const missingAfterCheck = checked && !checking && !connected;
return (
<>
<span className={pill}>Tracing is not enabled</span>
<span className={hint}>Turn on tracing in the proxy config, then point your agent at LiteLLM.</span>
</>
);
}
function WhatYoullSee() {
return (
<section className="mt-6">
<h2 className="text-[15px] font-medium text-foreground">What you&apos;ll see</h2>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-background shadow-sm">
<img src={previewImg.src} alt="Preview of an agent trace in LiteLLM" className="block w-full" />
<Step title="Run your agent once">
<p className="text-sm leading-6 text-muted-foreground">
Keep this page open. Your runs will appear here when LiteLLM receives them.
</p>
<div className="mt-4 flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={onCheck} disabled={checking}>
{checking && <Loader2 aria-hidden="true" className="size-4 animate-spin" />}
{checking ? "Checking…" : "Check for traces"}
</Button>
{!connected && (
<p role="status" className="text-sm text-muted-foreground">
Waiting for your first trace
</p>
)}
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{HIGHLIGHTS.map(([title, body]) => (
<div key={title}>
<div className="text-[13px] font-medium text-foreground">{title}</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">{body}</div>
</div>
))}
</div>
</section>
{missingAfterCheck && (
<p className="mt-3 text-sm leading-6 text-muted-foreground">
No traces received yet. Check the exporter URL and LiteLLM key in your agent’s environment, then check its
logs for export errors.
</p>
)}
</Step>
);
}
function FrameworkPicker({ value, onChange }: { value: string; onChange: (id: string) => void }) {
return (
<div className="mt-4 flex flex-wrap gap-2" role="radiogroup" aria-label="Framework">
{FRAMEWORKS.map((f) => (
<button
key={f.id}
type="button"
role="radio"
aria-checked={value === f.id}
onClick={() => onChange(f.id)}
className={cn(
"inline-flex items-center gap-2 rounded-md border px-3 py-2 text-[13px]",
value === f.id
? "border-foreground bg-accent text-foreground"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
<img src={f.logo} alt="" className="size-4" />
{f.label}
</button>
))}
</div>
);
function setupTitle(enabled: boolean, connected: boolean) {
if (!enabled) return "Enable tracing";
return connected ? "Connect another agent" : "Connect your agent";
}
/**
* Agent Traces onboarding: shown until the first trace arrives (and when tracing isn't enabled on the proxy).
* `detail` is the proxy's 501 message when tracing is off; null means tracing is on and we're waiting.
*/
export function TracingSetupCard({ detail, connected = false }: { detail: string | null; connected?: boolean }) {
export function TracingSetupCard({
detail,
connected = false,
onCheck,
checking = false,
}: {
detail: string | null;
connected?: boolean;
onCheck?: () => void;
checking?: boolean;
}) {
const proxyUrl = getProxyBaseUrl().replace(/\/$/, "");
const [framework, setFramework] = useState(FRAMEWORKS[0].id);
const [installer, setInstaller] = useState<Installer>("pip");
const [copied, setCopied] = useState(false);
const [checked, setChecked] = useState(false);
const guide = FRAMEWORKS.find((f) => f.id === framework) ?? FRAMEWORKS[0];
const packages = installPackages(guide);
const install = installer === "pip" ? `pip install -U ${packages}` : `uv add ${packages}`;
const enabled = detail === null;
const copyPrompt = async () => {
if (await copyToClipboard(codingAgentPrompt(proxyUrl, guide))) {
setCopied(true);
window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
}
};
const check = () => {
setChecked(true);
onCheck?.();
};
return (
<div className="pb-12" data-testid="tracing-setup-card">
<div className="w-full rounded-lg border border-border bg-card p-6">
<div className="flex flex-wrap items-center gap-3">
<SetupStatus detail={detail} connected={connected} />
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1 text-[13px] text-foreground hover:underline"
>
View docs <ArrowUpRight className="size-3.5" />
</a>
</div>
<div className="mx-auto w-full max-w-2xl py-6 pb-12" data-testid="tracing-setup-card">
<h2 className="text-xl font-semibold tracking-tight">{setupTitle(enabled, connected)}</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{enabled
? "Send your agent’s runs to LiteLLM to see its inputs, outputs, and tool calls."
: "Tracing needs ClickHouse and a small update to your LiteLLM proxy configuration."}
</p>
<p role="status" className="mt-4 flex items-center gap-2 text-sm">
{enabled && <Check aria-hidden="true" className="size-4 text-emerald-600" />}
{enabled ? "Tracing enabled" : "Tracing is not enabled"}
</p>
<WhatYoullSee />
<div className="mt-10 max-w-[860px]">
<h2 className="text-[15px] font-medium text-foreground">
Send your agent&apos;s OpenTelemetry traces to LiteLLM
</h2>
<p className="mt-1 text-[13px] text-muted-foreground">
Standard OTLP. Pick your framework, set three env vars, and run your agent as usual.
</p>
<FrameworkPicker value={framework} onChange={setFramework} />
{detail !== null && (
<Step title="Enable tracing on the proxy">
<CodeBlock code={PROXY_CONFIG_SNIPPET} tabs={<FileLabel>config.yaml</FileLabel>} />
</Step>
)}
<Step title="Let Claude Code or Codex set it up">
<p className="mb-2 text-[13px] text-muted-foreground">
Paste this into your coding agent from the project root, or follow the steps below by hand.
{!enabled ? (
<>
<Step title="Enable tracing on the proxy">
<p className="mb-3 text-sm leading-6 text-muted-foreground">
Set your ClickHouse writer and read-only reader URLs, add this to config.yaml, then restart the proxy. Ask
your proxy administrator if you don’t manage this deployment.
</p>
<CodeBlock code={codingAgentPrompt(proxyUrl, guide)} wrap tabs={<FileLabel>Prompt</FileLabel>} />
<CodeBlock code={PROXY_CONFIG_SNIPPET} tabs={<FileLabel>config.yaml</FileLabel>} />
<a
className="mt-3 inline-flex items-center gap-1 text-sm underline underline-offset-4"
href={`${DOCS_URL}#configure-an-existing-proxy`}
target="_blank"
rel="noreferrer"
>
ClickHouse and proxy setup <ArrowUpRight aria-hidden="true" className="size-3.5" />
</a>
</Step>
<Step title="Install dependencies">
<CodeBlock
code={install}
tabs={<LineTabs value={installer} options={["pip", "uv"]} onChange={setInstaller} />}
{checked && !checking && (
<p className="mt-4 text-sm text-muted-foreground">
Tracing is still unavailable. Check that the configuration was applied to this proxy and it has restarted.
</p>
)}
<Button className="mt-6" onClick={check} disabled={checking}>
{checking && <Loader2 aria-hidden="true" className="size-4 animate-spin" />}
{checking ? "Checking…" : "Check setup"}
</Button>
</>
) : (
<>
<div className="mt-6 space-y-2">
<label id="tracing-framework" className="text-sm font-medium">
Your agent framework
</label>
<Select
value={framework}
onValueChange={(value) => {
if (value) {
setFramework(value);
setCopied(false);
}
}}
>
<SelectTrigger aria-labelledby="tracing-framework" className="w-full">
<SelectValue>{guide.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{FRAMEWORKS.map((f) => (
<SelectItem key={f.id} value={f.id}>
<img src={f.logo} alt="" className="size-4" />
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Step title="Let your coding agent connect it">
<p className="mb-3 text-sm leading-6 text-muted-foreground">
Paste the setup prompt into Claude Code or Codex in your agent’s project. It uses your existing LiteLLM
key from the environment.
</p>
<Button variant="outline" onClick={() => void copyPrompt()}>
{copied ? (
<Check aria-hidden="true" className="size-4" />
) : (
<Copy aria-hidden="true" className="size-4" />
)}
{copied ? "Prompt copied" : "Copy setup prompt"}
</Button>
</Step>
<details className="mt-6 border-y py-4">
<summary className="cursor-pointer text-sm font-medium">Set up manually</summary>
<Step title="Install dependencies">
<CodeBlock
code={install}
tabs={<LineTabs value={installer} options={["pip", "uv"]} onChange={setInstaller} />}
/>
</Step>
<Step title="Configure environment">
<CodeBlock code={tracingEnvSnippet(proxyUrl)} tabs={<FileLabel>Shell</FileLabel>} />
</Step>
<Step title="Run your agent">
<p className="mb-2 text-sm text-muted-foreground">
Replace the example model with a model configured on your proxy.
</p>
<CodeBlock
code={`import os\n\n${guide.quickstart.replace("{PROXY}", proxyUrl)}`}
tabs={<FileLabel>my_agent.py</FileLabel>}
/>
<div className="mt-3">
<CodeBlock code={RUN_SNIPPET} tabs={<FileLabel>Shell</FileLabel>} />
</div>
</Step>
</details>
<TraceReceipt connected={connected} checked={checked} checking={checking} onCheck={check} />
<details className="mt-6 text-sm">
<summary className="cursor-pointer text-muted-foreground">See an example trace</summary>
<p className="my-3 text-muted-foreground">Example only. These are not your agent’s runs.</p>
<img
src={previewImg.src}
alt="Example agent trace with tool calls, inputs, and outputs"
className="w-full rounded-md border"
/>
</Step>
<Step title="Configure environment">
<CodeBlock code={tracingEnvSnippet(proxyUrl)} tabs={<FileLabel>Shell</FileLabel>} />
</Step>
<Step title="Run your agent">
<CodeBlock
code={`import os\n\n${guide.quickstart.replace("{PROXY}", proxyUrl)}`}
tabs={<FileLabel>my_agent.py</FileLabel>}
/>
<div className="mt-3">
<CodeBlock code={RUN_SNIPPET} tabs={<FileLabel>Shell</FileLabel>} />
</div>
</Step>
</div>
</div>
</details>
</>
)}
</div>
);
}

View file

@ -1,10 +1,10 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query";
import moment from "moment";
import { useMemo } from "react";
import { ApiError } from "@/lib/http/client";
import { agentTraceListCall } from "../../networking";
import { agentTraceListCall, apiClient } from "../../networking";
import { LIVE_TAIL_INTERVAL_MS } from "../log_filter_logic";
import type { TracePage, TraceSummary } from "./traceTypes";
@ -89,3 +89,16 @@ export function useAgentTraces({
refetch: () => void query.refetch(),
};
}
export function useTraceAvailability(accessToken: string, enabled: boolean) {
const options: UseQueryOptions<TracePage, Error, boolean> = {
queryKey: ["trace-availability", accessToken],
queryFn: () => apiClient.get<TracePage>("/v1/traces", { accessToken, query: { start_ms: 0 } }),
select: (page: TracePage) => page.data.length > 0,
enabled,
retry: false,
refetchInterval: (query) => (query.state.data?.data.length ? false : LIVE_TAIL_INTERVAL_MS),
refetchIntervalInBackground: false,
};
return useQuery(options);
}

View file

@ -65,13 +65,13 @@ describe("SpendLogsTable", () => {
useOrganizationsMock.mockReturnValue({ data: [] });
});
it("renders the log tabs, with Agent Traces marked new and Request Logs selected", () => {
it("keeps request and audit logs here while traces live in Lens", () => {
renderAs("Admin");
for (const label of ["Request Logs", "Audit Logs", "Deleted Keys", "Deleted Teams"]) {
expect(screen.getByRole("tab", { name: label })).toBeInTheDocument();
}
expect(screen.getByRole("tab", { name: /Agent Traces/ })).toHaveTextContent("New");
expect(screen.queryByRole("tab", { name: /Agent Traces/ })).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Request Logs" })).toHaveAttribute("aria-selected", "true");
});
@ -117,7 +117,7 @@ describe("SpendLogsTable", () => {
it("does not hand an org admin the Audit Logs tab, which the backend still refuses them", () => {
renderAs("Internal User", ORG_ADMIN_MEMBERSHIPS);
expect(tabNames()).toEqual(["Request Logs", "Agent TracesNew", "Deleted Keys", "Deleted Teams"]);
expect(tabNames()).toEqual(["Request Logs", "Deleted Keys", "Deleted Teams"]);
expect(screen.queryByTestId("audit-logs-panel")).not.toBeInTheDocument();
});
@ -126,7 +126,7 @@ describe("SpendLogsTable", () => {
{ organization_id: "org-1", members: [{ user_id: "user-1", user_role: "internal_user" }] },
]);
expect(tabNames()).toEqual(["Request Logs", "Agent TracesNew", "Deleted Keys"]);
expect(tabNames()).toEqual(["Request Logs", "Deleted Keys"]);
});
it("activates the org admin's selected tab rather than the one at the four-tab index", async () => {

View file

@ -4,7 +4,6 @@ import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage";
import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage";
import AuditLogsPanel from "./AuditLogsPanel";
import RequestLogsPanel from "./RequestLogsPanel";
import AgentTracesPage from "./TraceView/AgentTracesPage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
@ -16,24 +15,20 @@ interface SpendLogsTableProps {
premiumUser: boolean;
}
type LogsTabId = "request logs" | "agent traces" | "audit logs" | "deleted keys" | "deleted teams";
type LogsTabId = "request logs" | "audit logs" | "deleted keys" | "deleted teams";
interface LogsTab {
id: LogsTabId;
label: string;
isNew?: boolean;
}
const REQUEST_LOGS_TAB: LogsTab = { id: "request logs", label: "Request Logs" };
const AGENT_TRACES_TAB: LogsTab = { id: "agent traces", label: "Agent Traces", isNew: true };
const AUDIT_LOGS_TAB: LogsTab = { id: "audit logs", label: "Audit Logs" };
const DELETED_KEYS_TAB: LogsTab = { id: "deleted keys", label: "Deleted Keys" };
const DELETED_TEAMS_TAB: LogsTab = { id: "deleted teams", label: "Deleted Teams" };
const tabContentClassName = (tabId: LogsTabId): string =>
tabId === REQUEST_LOGS_TAB.id || tabId === AGENT_TRACES_TAB.id
? "flex min-h-0 flex-1 flex-col"
: "min-h-0 flex-1 overflow-y-auto";
tabId === REQUEST_LOGS_TAB.id ? "flex min-h-0 flex-1 flex-col" : "min-h-0 flex-1 overflow-y-auto";
export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) {
const [activeTab, setActiveTab] = useState<LogsTabId>(REQUEST_LOGS_TAB.id);
@ -50,7 +45,6 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
const tabs: LogsTab[] = [
REQUEST_LOGS_TAB,
AGENT_TRACES_TAB,
...(canViewAuditLogs ? [AUDIT_LOGS_TAB] : []),
DELETED_KEYS_TAB,
...(canViewDeletedTeams ? [DELETED_TEAMS_TAB] : []),
@ -68,8 +62,6 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
isActive={activeTab === "request logs"}
/>
);
case "agent traces":
return activeTab === "agent traces" ? <AgentTracesPage accessToken={accessToken} /> : null;
case "audit logs":
return (
<AuditLogsPanel
@ -99,11 +91,6 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="flex-none gap-1.5 px-0 text-[13px] font-normal">
{tab.label}
{tab.isNew && (
<span className="rounded-sm border border-border px-1 text-[10px] leading-4 text-muted-foreground">
New
</span>
)}
</TabsTrigger>
))}
</TabsList>