mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
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:
parent
a38fff6560
commit
f88ac7424d
15 changed files with 641 additions and 247 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
63
ui/litellm-dashboard/src/app/(dashboard)/lens/page.test.tsx
Normal file
63
ui/litellm-dashboard/src/app/(dashboard)/lens/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue