mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
feat(ui): add test trace, tracing key and otel endpoints to tracing setup (#44090)
* feat(ui): add a call that posts an OTLP export to the proxy * feat(ui): build a sample agent run as an OTLP export * feat(ui): add a pulsing dot for active tracing * feat(ui): render a crisp sample run preview from real trace components * chore(ui): remove the pixelated agent traces preview image * feat(ui): add test trace, tracing key, otel endpoints and more frameworks to tracing setup * test(ui): cover tracing setup test trace, key masking, endpoints and frameworks * feat(ui): open the received test trace and mark tracing as active * test(ui): mock the new tracing setup network calls * fix(ui): let tracing setup use the full page width * fix(ui): send OTLP/JSON sample trace ids as hex per the spec * test(ui): cover the sample trace export shape and hex ids
This commit is contained in:
parent
0f6a06a6b9
commit
46196339d2
13 changed files with 1061 additions and 173 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 110 KiB |
|
|
@ -37,7 +37,12 @@ export default function LensPage() {
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="traces" keepMounted className="min-h-0">
|
||||
<AgentTracesPage accessToken={accessToken} isActive={activeTab === "traces"} />
|
||||
<AgentTracesPage
|
||||
accessToken={accessToken}
|
||||
isActive={activeTab === "traces"}
|
||||
readOnly={isViewOnly}
|
||||
canMintTracingKey={isProxyAdminRole(userRole ?? "")}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="investigations">
|
||||
{isProxyAdminTierRole(userRole ?? "") ? (
|
||||
|
|
|
|||
|
|
@ -2122,6 +2122,9 @@ export const agentTraceListCall = async ({
|
|||
return apiClient.get<TracePage>(`/v1/traces`, { accessToken, query });
|
||||
};
|
||||
|
||||
export const sendOtlpTraceCall = async (accessToken: string, exportRequest: object): Promise<void> =>
|
||||
apiClient.post(`/v1/traces`, { accessToken, body: exportRequest });
|
||||
|
||||
export const agentTraceCall = async (accessToken: string, traceId: string, traceRef?: string): Promise<Trace> =>
|
||||
apiClient.get<Trace>(`/v1/traces/${encodeURIComponent(traceId)}`, {
|
||||
accessToken,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
export function ActiveDot() {
|
||||
return (
|
||||
<span className="relative flex size-2" aria-hidden="true">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60 motion-reduce:hidden" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,17 @@ import { AgentTracesSection } from "./AgentTracesSection";
|
|||
const DEFAULT_RANGE_HOURS = 24;
|
||||
const TIME_FORMAT = "YYYY-MM-DDTHH:mm";
|
||||
|
||||
export default function AgentTracesPage({ accessToken, isActive = true }: { accessToken: string; isActive?: boolean }) {
|
||||
export default function AgentTracesPage({
|
||||
accessToken,
|
||||
isActive = true,
|
||||
readOnly = false,
|
||||
canMintTracingKey = false,
|
||||
}: {
|
||||
accessToken: string;
|
||||
isActive?: boolean;
|
||||
readOnly?: boolean;
|
||||
canMintTracingKey?: boolean;
|
||||
}) {
|
||||
const [rangeHours, setRangeHours] = useState(DEFAULT_RANGE_HOURS);
|
||||
const [live, setLive] = useState(true);
|
||||
const [anchor, setAnchor] = useState(() => moment());
|
||||
|
|
@ -34,6 +44,8 @@ export default function AgentTracesPage({ accessToken, isActive = true }: { acce
|
|||
endTime={endTime}
|
||||
isCustomDate={false}
|
||||
isLiveTail={live}
|
||||
readOnly={readOnly}
|
||||
canMintTracingKey={canMintTracingKey}
|
||||
timeControls={{ rangeHours, onRangeHoursChange: changeRange, onLiveChange: setLive }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ import { AgentTracesSection, filterRuns } from "./AgentTracesSection";
|
|||
import type { TracePage, TraceSummary } from "./traceTypes";
|
||||
|
||||
vi.mock("../../networking", () => ({
|
||||
apiClient: { get: vi.fn() },
|
||||
apiClient: { get: vi.fn(), post: vi.fn() },
|
||||
agentTraceListCall: vi.fn(),
|
||||
sendOtlpTraceCall: vi.fn(),
|
||||
agentTraceCall: vi.fn(),
|
||||
agentTraceSpanCall: vi.fn(),
|
||||
getProxyBaseUrl: () => "http://localhost:4000",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { Plug } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
|
|
@ -13,6 +12,7 @@ import type { TraceSummary } from "./traceTypes";
|
|||
import { previewText } from "./traceUtils";
|
||||
import { TimeRangeControls } from "./TimeRangeControls";
|
||||
import { TracesTimeline, type TimeWindow } from "./TracesTimeline";
|
||||
import { ActiveDot } from "./ActiveDot";
|
||||
import { TracingSetupCard } from "./TracingSetupCard";
|
||||
import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
|
||||
|
||||
|
|
@ -59,6 +59,8 @@ interface AgentTracesSectionProps {
|
|||
timeControls?: TimeControls;
|
||||
/** Called when a run opens / closes, so the page can hide its own header while a run fills the view. */
|
||||
onRunOpenChange?: (open: boolean) => void;
|
||||
readOnly?: boolean;
|
||||
canMintTracingKey?: boolean;
|
||||
}
|
||||
|
||||
function useTracingSetup(traces: AgentTracesResult, isActive: boolean, rangeChanged: boolean) {
|
||||
|
|
@ -98,6 +100,8 @@ export function AgentTracesSection({
|
|||
isLiveTail,
|
||||
timeControls,
|
||||
onRunOpenChange,
|
||||
readOnly = false,
|
||||
canMintTracingKey = false,
|
||||
}: AgentTracesSectionProps) {
|
||||
const [openTrace, setOpenTrace] = useState<TraceSummary | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
|
@ -141,11 +145,25 @@ export function AgentTracesSection({
|
|||
onRunOpenChange?.(trace !== null);
|
||||
};
|
||||
|
||||
if (setup.disabledDetail != null)
|
||||
return <TracingSetupCard detail={setup.disabledDetail} onCheck={checkTraces} checking={traces.isFetching} />;
|
||||
const openSentTrace = (trace: TraceSummary) => {
|
||||
setShowSetup(false);
|
||||
setRangeChanged(true);
|
||||
checkTraces();
|
||||
openRun(trace);
|
||||
};
|
||||
const setupProps = {
|
||||
accessToken,
|
||||
readOnly,
|
||||
canMintTracingKey,
|
||||
onOpenTrace: openSentTrace,
|
||||
onCheck: checkTraces,
|
||||
checking: traces.isFetching,
|
||||
};
|
||||
|
||||
if (setup.disabledDetail != null) return <TracingSetupCard detail={setup.disabledDetail} {...setupProps} />;
|
||||
// Onboarding only on the first, default view; an empty range the user picked keeps its controls.
|
||||
if (checkHistory && !history.error && history.data === false)
|
||||
return <TracingSetupCard detail={null} onCheck={checkTraces} checking={traces.isFetching} />;
|
||||
return <TracingSetupCard detail={null} {...setupProps} />;
|
||||
if (showSetup) {
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -156,7 +174,7 @@ export function AgentTracesSection({
|
|||
>
|
||||
← Back to traces
|
||||
</button>
|
||||
<TracingSetupCard detail={null} connected onCheck={checkTraces} checking={traces.isFetching} />
|
||||
<TracingSetupCard detail={null} connected {...setupProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -183,7 +201,7 @@ export function AgentTracesSection({
|
|||
onStatusChange={setStatus}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowSetup(true)} className="shrink-0 gap-1.5">
|
||||
<Plug className="size-3.5" />
|
||||
<ActiveDot />
|
||||
Set up tracing
|
||||
</Button>
|
||||
{timeControls && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import previewTrace from "./previewTrace.json";
|
||||
import { SpanIcon } from "./SpanIcon";
|
||||
import type { SpanType } from "./traceTypes";
|
||||
import { fmtMs } from "./traceUtils";
|
||||
|
||||
interface PreviewRow {
|
||||
id: string;
|
||||
name: string;
|
||||
type: SpanType;
|
||||
model: string | null;
|
||||
depth: number;
|
||||
start_offset_ms: number;
|
||||
duration_ms: number;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
const preview = previewTrace as {
|
||||
name: string;
|
||||
input_preview: string;
|
||||
span_count: number;
|
||||
duration_ms: number;
|
||||
rows: PreviewRow[];
|
||||
};
|
||||
|
||||
export function TracePreview() {
|
||||
const total = preview.duration_ms;
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="trace-preview"
|
||||
className="pointer-events-none overflow-hidden rounded-lg border border-border bg-background shadow-sm select-none"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-2.5">
|
||||
<SpanIcon type="agent" size="md" />
|
||||
<span className="text-[13px] font-medium text-foreground">{preview.name}</span>
|
||||
<span className="truncate text-[12px] text-muted-foreground">{preview.input_preview}</span>
|
||||
<span className="ml-auto shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||
{preview.span_count} steps · {fmtMs(total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
{preview.rows.map((row) => (
|
||||
<div key={row.id} className="flex h-8 items-center gap-2 border-b border-border/60 px-4 last:border-b-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2" style={{ paddingLeft: row.depth * 16 }}>
|
||||
<SpanIcon type={row.type} model={row.model} error={row.error} size="sm" />
|
||||
<span className="truncate text-[12.5px] text-foreground">{row.name}</span>
|
||||
</div>
|
||||
<div className="relative h-1.5 w-[40%] shrink-0 rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("absolute h-full rounded-full", row.error ? "bg-destructive" : "bg-trace-brand/70")}
|
||||
style={{
|
||||
left: `${(row.start_offset_ms / total) * 100}%`,
|
||||
width: `${Math.max((row.duration_ms / total) * 100, 0.6)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-14 shrink-0 text-right font-mono text-[11px] text-muted-foreground">
|
||||
{fmtMs(row.duration_ms)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-background to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,39 +1,98 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { 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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { chooseSelectOption, renderWithProviders } from "@/../tests/test-utils";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
import { codingAgentPrompt, tracingEnvSnippet, TracingSetupCard } from "./TracingSetupCard";
|
||||
import { agentTraceCall, apiClient, sendOtlpTraceCall } from "../../networking";
|
||||
import {
|
||||
codingAgentCommand,
|
||||
codingAgentPrompt,
|
||||
maskSecret,
|
||||
TRACING_KEY_REQUEST,
|
||||
tracingEnvSnippet,
|
||||
TracingSetupCard,
|
||||
} from "./TracingSetupCard";
|
||||
import type { Trace } from "./traceTypes";
|
||||
|
||||
vi.mock("../../networking", () => ({ getProxyBaseUrl: () => "http://proxy.test/" }));
|
||||
vi.mock("../../networking", () => ({
|
||||
getProxyBaseUrl: () => "http://proxy.test/",
|
||||
sendOtlpTraceCall: vi.fn(),
|
||||
agentTraceCall: vi.fn(),
|
||||
apiClient: { post: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn().mockResolvedValue(true) }));
|
||||
|
||||
const SECRET = "sk-abcdefghijklmnopWXYZ";
|
||||
|
||||
const renderCard = (
|
||||
props: {
|
||||
detail?: string | null;
|
||||
connected?: boolean;
|
||||
onCheck?: () => void;
|
||||
readOnly?: boolean;
|
||||
canMintTracingKey?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const onOpenTrace = vi.fn();
|
||||
renderWithProviders(
|
||||
<TracingSetupCard
|
||||
detail={props.detail ?? null}
|
||||
connected={props.connected}
|
||||
onCheck={props.onCheck}
|
||||
readOnly={props.readOnly}
|
||||
canMintTracingKey={props.canMintTracingKey ?? true}
|
||||
accessToken="sk-admin"
|
||||
onOpenTrace={onOpenTrace}
|
||||
/>,
|
||||
);
|
||||
return { onOpenTrace, card: screen.getByTestId("tracing-setup-card") };
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("TracingSetupCard", () => {
|
||||
it("shows agent connection guidance once tracing is enabled without presenting example data as live", async () => {
|
||||
it("shows agent connection guidance and labels the example run as sample data", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TracingSetupCard detail={null} />);
|
||||
const { card } = renderCard();
|
||||
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");
|
||||
expect(screen.getByTestId("trace-preview")).toBeVisible();
|
||||
expect(card).toHaveTextContent("sample data, not your runs");
|
||||
expect(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);
|
||||
expect(screen.getByText(/^export OTEL_EXPORTER_OTLP_ENDPOINT=http:\/\/proxy.test/)).toBeVisible();
|
||||
expect(card).not.toHaveTextContent(/langsmith/i);
|
||||
});
|
||||
|
||||
it("copies instructions for the selected framework and preserves both manual installers", async () => {
|
||||
it("lists the OTEL endpoints before the framework picker, each copyable", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TracingSetupCard detail={null} />);
|
||||
const { card } = renderCard();
|
||||
const text = card.textContent ?? "";
|
||||
expect(text.indexOf("OpenTelemetry (OTEL) endpoints")).toBeLessThan(text.indexOf("Your agent framework"));
|
||||
await user.click(screen.getByRole("button", { name: "Copy http://proxy.test/v1/traces" }));
|
||||
expect(copyToClipboard).toHaveBeenLastCalledWith("http://proxy.test/v1/traces");
|
||||
});
|
||||
|
||||
it("hides the sample preview once traces are arriving", () => {
|
||||
renderCard({ connected: true });
|
||||
expect(screen.getByRole("heading", { name: "Connect another agent" })).toBeVisible();
|
||||
expect(screen.queryByTestId("trace-preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("builds the coding agent command for the selected framework and keeps both manual installers", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
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();
|
||||
const prompt = codingAgentPrompt("http://proxy.test", {
|
||||
label: "CrewAI",
|
||||
packages: "crewai openinference-instrumentation-crewai",
|
||||
});
|
||||
const commandText = () => screen.getByText(/^claude |^codex /).textContent;
|
||||
expect(commandText()).toBe(codingAgentCommand("Claude Code", prompt));
|
||||
await user.click(screen.getByRole("tab", { name: "Codex" }));
|
||||
expect(commandText()).toBe(codingAgentCommand("Codex", prompt));
|
||||
|
||||
await user.click(screen.getByText("Set up manually"));
|
||||
expect(screen.getByText(/pip install -U opentelemetry-distro/)).toHaveTextContent(
|
||||
"crewai openinference-instrumentation-crewai",
|
||||
|
|
@ -44,13 +103,88 @@ describe("TracingSetupCard", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("uses npm and a CommonJS-safe TypeScript entrypoint for the Vercel AI SDK", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { card } = renderCard();
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Your agent framework" }), "Vercel AI SDK");
|
||||
await user.click(screen.getByText("Set up manually"));
|
||||
expect(card).toHaveTextContent("npm install ai @ai-sdk/openai-compatible @vercel/otel");
|
||||
expect(card).toHaveTextContent("my_agent.ts");
|
||||
expect(card).not.toHaveTextContent("opentelemetry-instrument python");
|
||||
const quickstart = screen.getByText(/registerOTel\(\{/).textContent ?? "";
|
||||
expect(quickstart).toContain("async function main()");
|
||||
expect(quickstart.split("\n").filter((line) => /^(const|let) .*= await /.test(line))).toEqual([]);
|
||||
});
|
||||
|
||||
it("hides the actions a read-only viewer cannot perform", () => {
|
||||
const { card } = renderCard({ readOnly: true });
|
||||
expect(screen.queryByRole("button", { name: "Send a test trace" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Generate tracing key" })).not.toBeInTheDocument();
|
||||
expect(card).toHaveTextContent("ask a proxy admin for one");
|
||||
expect(card).toHaveTextContent("OpenTelemetry (OTEL) endpoints");
|
||||
});
|
||||
|
||||
it("offers a scoped tracing key only to callers allowed to set key routes", () => {
|
||||
const { card } = renderCard({ canMintTracingKey: false });
|
||||
expect(screen.queryByRole("button", { name: "Generate tracing key" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Send a test trace" })).toBeVisible();
|
||||
expect(card).toHaveTextContent("Use any LiteLLM virtual key you already have");
|
||||
});
|
||||
|
||||
it("generates a tracing key that stays masked on screen but copies in full", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ key: SECRET });
|
||||
const { card } = renderCard();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Generate tracing key" }));
|
||||
|
||||
expect(await screen.findByText("Your tracing key")).toBeVisible();
|
||||
expect(apiClient.post).toHaveBeenCalledWith("/key/generate", {
|
||||
accessToken: "sk-admin",
|
||||
body: TRACING_KEY_REQUEST,
|
||||
});
|
||||
expect(TRACING_KEY_REQUEST.allowed_routes).toEqual(["/v1/traces"]);
|
||||
expect(card).not.toHaveTextContent(SECRET);
|
||||
expect(card).toHaveTextContent(maskSecret(SECRET));
|
||||
await user.click(screen.getAllByRole("button", { name: "Copy" })[0]);
|
||||
expect(copyToClipboard).toHaveBeenLastCalledWith(SECRET);
|
||||
});
|
||||
|
||||
it("sends a test trace, waits for it to land, then opens it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const summary = { trace_id: "abc", name: "weather_agent" } as Trace["summary"];
|
||||
vi.mocked(sendOtlpTraceCall).mockResolvedValue(undefined);
|
||||
vi.mocked(agentTraceCall).mockResolvedValue({ summary, agents: [], spans: [] } as unknown as Trace);
|
||||
const { onOpenTrace } = renderCard();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Send a test trace" }));
|
||||
await user.click(await screen.findByRole("button", { name: /View trace/ }));
|
||||
|
||||
expect(sendOtlpTraceCall).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(agentTraceCall).mock.calls[0][1]).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(onOpenTrace).toHaveBeenCalledWith(summary);
|
||||
});
|
||||
|
||||
it("reports a failed send instead of claiming success", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(sendOtlpTraceCall).mockRejectedValue(new Error("boom"));
|
||||
renderCard();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Send a test trace" }));
|
||||
|
||||
expect(await screen.findByText("Could not send the test trace.")).toBeVisible();
|
||||
expect(agentTraceCall).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: /View trace/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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} />);
|
||||
const { card } = renderCard({ detail: "Agent tracing is not enabled", onCheck });
|
||||
expect(screen.getByRole("heading", { name: "Enable tracing" })).toBeVisible();
|
||||
expect(screen.getByTestId("tracing-setup-card")).toHaveTextContent("store: clickhouse");
|
||||
expect(card).toHaveTextContent("store: clickhouse");
|
||||
expect(screen.queryByRole("combobox", { name: "Your agent framework" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Send a test trace" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Check setup" }));
|
||||
expect(onCheck).toHaveBeenCalledOnce();
|
||||
expect(screen.getByText(/Tracing is still unavailable/)).toBeVisible();
|
||||
|
|
@ -63,7 +197,13 @@ describe("setup snippets", () => {
|
|||
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).not.toContain("export LITELLM_API_KEY=");
|
||||
expect(env).toContain("Bearer $LITELLM_API_KEY");
|
||||
const withKey = tracingEnvSnippet("http://proxy.test", SECRET);
|
||||
expect(withKey).toContain(`export LITELLM_TRACING_KEY=${SECRET}\n`);
|
||||
expect(withKey).toContain('OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $LITELLM_TRACING_KEY"');
|
||||
expect(withKey).not.toContain("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");
|
||||
|
|
@ -71,4 +211,14 @@ describe("setup snippets", () => {
|
|||
expect(prompt).toContain("Lens > Traces");
|
||||
expect(prompt).not.toMatch(/langsmith/i);
|
||||
});
|
||||
|
||||
it("builds a shell-safe command for each coding agent", () => {
|
||||
expect(codingAgentCommand("Claude Code", "it's")).toBe("claude 'it'\\''s'");
|
||||
expect(codingAgentCommand("Codex", "go")).toBe("codex 'go'");
|
||||
});
|
||||
|
||||
it("masks secrets but keeps a recognisable prefix and suffix", () => {
|
||||
expect(maskSecret(SECRET)).toBe(`sk-ab${"•".repeat(16)}WXYZ`);
|
||||
expect(maskSecret("short")).toBe("•••••");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowUpRight, Check, Copy, Loader2 } from "lucide-react";
|
||||
import { ArrowRight, ArrowUpRight, Check, Copy, KeyRound, Loader2, Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
|
@ -8,22 +8,42 @@ 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";
|
||||
import anthropicLogo from "../../../../public/assets/logos/anthropic.svg";
|
||||
import crewaiLogo from "../../../../public/assets/logos/crewai-color.svg";
|
||||
import langchainLogo from "../../../../public/assets/logos/langchain.svg";
|
||||
import langgraphLogo from "../../../../public/assets/logos/langgraph-color.svg";
|
||||
import llamaindexLogo from "../../../../public/assets/logos/llamaindex-color.svg";
|
||||
import openaiAgentsLogo from "../../../../public/assets/logos/openai-agents.svg";
|
||||
import openaiLogo from "../../../../public/assets/logos/openai_small.svg";
|
||||
import otelLogo from "../../../../public/assets/logos/opentelemetry.svg";
|
||||
import pydanticAiLogo from "../../../../public/assets/logos/pydantic-ai-color.svg";
|
||||
import { getProxyBaseUrl } from "../../networking";
|
||||
import vercelLogo from "../../../../public/assets/logos/vercel.svg";
|
||||
import { agentTraceCall, apiClient, getProxyBaseUrl, sendOtlpTraceCall } from "../../networking";
|
||||
import { ActiveDot } from "./ActiveDot";
|
||||
import { sampleTraceExport } from "./sampleTrace";
|
||||
import { TracePreview } from "./TracePreview";
|
||||
import type { TraceSummary } from "./traceTypes";
|
||||
|
||||
const COPIED_RESET_MS = 1500;
|
||||
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";
|
||||
const PY_RUN_SNIPPET = "opentelemetry-instrument python my_agent.py";
|
||||
const TS_RUN_SNIPPET = "npx tsx my_agent.ts";
|
||||
const SAMPLE_TRACE_POLL_MS = 1000;
|
||||
export const TRACING_KEY_REQUEST = {
|
||||
key_alias: "Agent tracing",
|
||||
allowed_routes: ["/v1/traces"],
|
||||
metadata: { purpose: "agent_tracing" },
|
||||
} as const;
|
||||
const SAMPLE_TRACE_POLL_ATTEMPTS = 15;
|
||||
|
||||
type Installer = "pip" | "uv";
|
||||
type CodingAgent = "Claude Code" | "Codex";
|
||||
|
||||
const PY_INSTALL: Record<Installer, (packages: string) => string> = {
|
||||
pip: (packages) => `pip install -U ${packages}`,
|
||||
uv: (packages) => `uv add ${packages}`,
|
||||
};
|
||||
|
||||
interface FrameworkGuide {
|
||||
id: string;
|
||||
|
|
@ -31,12 +51,50 @@ interface FrameworkGuide {
|
|||
logo: string;
|
||||
packages: string;
|
||||
quickstart: string;
|
||||
typescript?: boolean;
|
||||
}
|
||||
|
||||
const FRAMEWORKS: readonly FrameworkGuide[] = [
|
||||
{
|
||||
id: "deep-agents",
|
||||
label: "Deep Agents",
|
||||
logo: langgraphLogo.src,
|
||||
packages: "deepagents langchain-openai openinference-instrumentation-langchain",
|
||||
quickstart: `from deepagents import create_deep_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
llm = ChatOpenAI(model="claude-sonnet-4-5", base_url="{PROXY}/v1", api_key=os.environ["LITELLM_API_KEY"])
|
||||
agent = create_deep_agent(model=llm, tools=[], system_prompt="You are a careful researcher.")
|
||||
agent.invoke({"messages": [{"role": "user", "content": "What is LiteLLM?"}]})`,
|
||||
},
|
||||
{
|
||||
id: "vercel-ai-sdk",
|
||||
label: "Vercel AI SDK",
|
||||
logo: vercelLogo.src,
|
||||
typescript: true,
|
||||
packages: "ai @ai-sdk/openai-compatible @vercel/otel @opentelemetry/api",
|
||||
quickstart: `import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import { registerOTel } from "@vercel/otel";
|
||||
import { generateText } from "ai";
|
||||
|
||||
registerOTel({ serviceName: process.env.OTEL_SERVICE_NAME ?? "my-agent" });
|
||||
|
||||
const litellm = createOpenAICompatible({ name: "litellm", baseURL: "{PROXY}/v1", apiKey: process.env.LITELLM_API_KEY });
|
||||
|
||||
async function main() {
|
||||
const { text } = await generateText({
|
||||
model: litellm("claude-sonnet-4-5"),
|
||||
prompt: "What is LiteLLM?",
|
||||
experimental_telemetry: { isEnabled: true, functionId: "my_agent" },
|
||||
});
|
||||
console.log(text);
|
||||
}
|
||||
|
||||
main();`,
|
||||
},
|
||||
{
|
||||
id: "langgraph",
|
||||
label: "LangGraph / Deep Agents",
|
||||
label: "LangGraph",
|
||||
logo: langgraphLogo.src,
|
||||
packages: "langgraph langchain-openai openinference-instrumentation-langchain",
|
||||
quickstart: `from langchain.agents import create_agent
|
||||
|
|
@ -119,19 +177,23 @@ with tracer.start_as_current_span("my_agent", attributes=attrs):
|
|||
},
|
||||
];
|
||||
|
||||
const installPackages = (guide: Pick<FrameworkGuide, "packages">): string =>
|
||||
[OTEL_BASE_PACKAGES, guide.packages].filter(Boolean).join(" ");
|
||||
const installPackages = (guide: Pick<FrameworkGuide, "packages" | "typescript">): string =>
|
||||
guide.typescript ? guide.packages : [OTEL_BASE_PACKAGES, guide.packages].filter(Boolean).join(" ");
|
||||
|
||||
/** The endpoint is the proxy base URL: OTLP exporters append /v1/traces themselves. */
|
||||
export const tracingEnvSnippet = (proxyUrl: string): string =>
|
||||
export const tracingEnvSnippet = (proxyUrl: string, tracingKey: string | null = null): string =>
|
||||
[
|
||||
...(tracingKey ? [`export LITELLM_TRACING_KEY=${tracingKey}`] : []),
|
||||
`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_EXPORTER_OTLP_HEADERS="Authorization=Bearer $${tracingKey ? "LITELLM_TRACING_KEY" : "LITELLM_API_KEY"}"`,
|
||||
"export OTEL_SERVICE_NAME=my-agent",
|
||||
].join("\n");
|
||||
|
||||
export const codingAgentPrompt = (proxyUrl: string, guide: Pick<FrameworkGuide, "label" | "packages">): string =>
|
||||
export const codingAgentPrompt = (
|
||||
proxyUrl: string,
|
||||
guide: Pick<FrameworkGuide, "label" | "packages" | "typescript">,
|
||||
): string =>
|
||||
[
|
||||
`Send this ${guide.label} project's OpenTelemetry traces to LiteLLM.`,
|
||||
"",
|
||||
|
|
@ -141,7 +203,9 @@ export const codingAgentPrompt = (proxyUrl: string, guide: Pick<FrameworkGuide,
|
|||
" 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>.",
|
||||
guide.typescript
|
||||
? "3. Call registerOTel() from @vercel/otel at startup and pass experimental_telemetry: { isEnabled: true } to every AI SDK call."
|
||||
: "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 Lens > Traces.",
|
||||
|
|
@ -149,6 +213,21 @@ export const codingAgentPrompt = (proxyUrl: string, guide: Pick<FrameworkGuide,
|
|||
"Never hardcode the key. Read it from LITELLM_API_KEY.",
|
||||
].join("\n");
|
||||
|
||||
const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
|
||||
export const codingAgentCommand = (agent: CodingAgent, prompt: string): string =>
|
||||
`${agent === "Claude Code" ? "claude" : "codex"} ${shellQuote(prompt)}`;
|
||||
|
||||
export const maskSecret = (secret: string): string =>
|
||||
secret.length > 10 ? `${secret.slice(0, 5)}${"•".repeat(16)}${secret.slice(-4)}` : "•".repeat(secret.length);
|
||||
|
||||
export const otlpEndpoints = (proxyUrl: string): readonly (readonly [string, string, boolean])[] => [
|
||||
["Traces endpoint (POST)", `${proxyUrl}/v1/traces`, true],
|
||||
["OTEL_EXPORTER_OTLP_ENDPOINT", proxyUrl, true],
|
||||
["Auth header", "Authorization: Bearer <LiteLLM virtual key>", true],
|
||||
["Protocol", "OTLP/HTTP, protobuf or JSON (gRPC not supported)", false],
|
||||
];
|
||||
|
||||
export const PROXY_CONFIG_SNIPPET = [
|
||||
"general_settings:",
|
||||
" tracing:",
|
||||
|
|
@ -157,7 +236,17 @@ export const PROXY_CONFIG_SNIPPET = [
|
|||
"# env: CLICKHOUSE_URL (writer) and CLICKHOUSE_READER_URL (read-only user)",
|
||||
].join("\n");
|
||||
|
||||
function CodeBlock({ code, tabs, wrap = false }: { code: string; tabs?: React.ReactNode; wrap?: boolean }) {
|
||||
function CodeBlock({
|
||||
code,
|
||||
display = code,
|
||||
tabs,
|
||||
wrap = false,
|
||||
}: {
|
||||
code: string;
|
||||
display?: string;
|
||||
tabs?: React.ReactNode;
|
||||
wrap?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
if (await copyToClipboard(code)) {
|
||||
|
|
@ -184,7 +273,7 @@ function CodeBlock({ code, tabs, wrap = false }: { code: string; tabs?: React.Re
|
|||
wrap ? "whitespace-pre-wrap" : "overflow-x-auto",
|
||||
)}
|
||||
>
|
||||
<code>{code}</code>
|
||||
<code>{display}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -198,10 +287,12 @@ function LineTabs<T extends string>({
|
|||
value,
|
||||
options,
|
||||
onChange,
|
||||
logos,
|
||||
}: {
|
||||
value: T;
|
||||
options: T[];
|
||||
onChange: (v: T) => void;
|
||||
logos?: Partial<Record<T, string>>;
|
||||
}) {
|
||||
return (
|
||||
<div role="tablist" className="flex h-9 items-end gap-3">
|
||||
|
|
@ -213,12 +304,13 @@ function LineTabs<T extends string>({
|
|||
aria-selected={value === option}
|
||||
onClick={() => onChange(option)}
|
||||
className={cn(
|
||||
"-mb-px h-9 border-b-2 text-[12.5px]",
|
||||
"-mb-px inline-flex h-9 items-center gap-1.5 border-b-2 text-[12.5px]",
|
||||
value === option
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{logos?.[option] && <img src={logos[option]} alt="" className="size-3.5" />}
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -226,7 +318,7 @@ function LineTabs<T extends string>({
|
|||
);
|
||||
}
|
||||
|
||||
function Step({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
function Step({ title, children }: { title: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mt-6">
|
||||
<h3 className="mb-2 text-[13px] font-medium text-foreground">{title}</h3>
|
||||
|
|
@ -235,6 +327,86 @@ function Step({ title, children }: { title: string; children: React.ReactNode })
|
|||
);
|
||||
}
|
||||
|
||||
type SendState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "sending" }
|
||||
| { kind: "waiting" }
|
||||
| { kind: "ready"; trace: TraceSummary }
|
||||
| { kind: "failed"; message: string };
|
||||
|
||||
const SEND_LABEL: Record<Exclude<SendState["kind"], "ready">, string> = {
|
||||
idle: "Send a test trace",
|
||||
sending: "Sending…",
|
||||
waiting: "Waiting for it to arrive…",
|
||||
failed: "Send a test trace",
|
||||
};
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
async function waitForTrace(accessToken: string, traceId: string): Promise<TraceSummary | null> {
|
||||
for (let attempt = 0; attempt < SAMPLE_TRACE_POLL_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return (await agentTraceCall(accessToken, traceId)).summary;
|
||||
} catch {
|
||||
await delay(SAMPLE_TRACE_POLL_MS);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function SendTestTrace({
|
||||
accessToken,
|
||||
onOpenTrace,
|
||||
}: {
|
||||
accessToken: string;
|
||||
onOpenTrace: (trace: TraceSummary) => void;
|
||||
}) {
|
||||
const [state, setState] = useState<SendState>({ kind: "idle" });
|
||||
const send = async () => {
|
||||
setState({ kind: "sending" });
|
||||
const sample = sampleTraceExport(Date.now());
|
||||
try {
|
||||
await sendOtlpTraceCall(accessToken, sample.body);
|
||||
} catch {
|
||||
setState({ kind: "failed", message: "Could not send the test trace." });
|
||||
return;
|
||||
}
|
||||
setState({ kind: "waiting" });
|
||||
const trace = await waitForTrace(accessToken, sample.traceId);
|
||||
setState(
|
||||
trace
|
||||
? { kind: "ready", trace }
|
||||
: { kind: "failed", message: "Sent, but it has not shown up yet. Check again in a moment." },
|
||||
);
|
||||
};
|
||||
if (state.kind === "ready") {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span role="status" className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<Check aria-hidden="true" className="size-4" /> Test trace received
|
||||
</span>
|
||||
<Button onClick={() => onOpenTrace(state.trace)}>
|
||||
View trace <ArrowRight aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const busy = state.kind === "sending" || state.kind === "waiting";
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => void send()} disabled={busy}>
|
||||
{busy ? (
|
||||
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send aria-hidden="true" className="size-4" />
|
||||
)}
|
||||
{SEND_LABEL[state.kind]}
|
||||
</Button>
|
||||
{state.kind === "failed" && <p className="text-sm text-destructive">{state.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceReceipt({
|
||||
connected,
|
||||
checked,
|
||||
|
|
@ -273,159 +445,367 @@ function TraceReceipt({
|
|||
);
|
||||
}
|
||||
|
||||
function TracingKey({
|
||||
accessToken,
|
||||
tracingKey,
|
||||
onCreated,
|
||||
}: {
|
||||
accessToken: string;
|
||||
tracingKey: string | null;
|
||||
onCreated: (key: string) => void;
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const create = async () => {
|
||||
setCreating(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await apiClient.post<{ key?: string }>("/key/generate", {
|
||||
accessToken,
|
||||
body: TRACING_KEY_REQUEST,
|
||||
});
|
||||
if (!result.key) throw new Error("The proxy did not return the new key");
|
||||
onCreated(result.key);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Could not create a key");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
if (tracingKey) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<CodeBlock code={tracingKey} display={maskSecret(tracingKey)} tabs={<FileLabel>Your tracing key</FileLabel>} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hidden for safety. Copy copies the full key, and the environment step below includes it. This key can only
|
||||
send traces, so your agent still needs its own key for model calls. Manage it under Virtual Keys as
|
||||
"Agent tracing".
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => void create()} disabled={creating}>
|
||||
{creating ? (
|
||||
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
|
||||
) : (
|
||||
<KeyRound aria-hidden="true" className="size-4" />
|
||||
)}
|
||||
Generate tracing key
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Or use any existing LiteLLM virtual key.</span>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointValue({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
if (await copyToClipboard(value)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy()}
|
||||
aria-label={`Copy ${value}`}
|
||||
className="group inline-flex min-w-0 items-center gap-2 text-left font-mono text-[12.5px] text-foreground"
|
||||
>
|
||||
<span className="break-all">{value}</span>
|
||||
{copied ? (
|
||||
<Check aria-hidden="true" className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<Copy aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground group-hover:text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Endpoints({ proxyUrl }: { proxyUrl: string }) {
|
||||
return (
|
||||
<section className="mt-6" aria-labelledby="otel-endpoints">
|
||||
<h3 id="otel-endpoints" className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<img src={otelLogo.src} alt="" className="size-5" />
|
||||
OpenTelemetry (OTEL) endpoints
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Point any OpenTelemetry exporter here. The framework guides below set these for you.
|
||||
</p>
|
||||
<dl className="mt-3 grid gap-x-8 gap-y-3 rounded-md border border-l-2 border-border border-l-trace-brand bg-muted/30 px-4 py-3 md:grid-cols-2">
|
||||
{otlpEndpoints(proxyUrl).map(([label, value, copyable]) => (
|
||||
<div key={label} className="min-w-0">
|
||||
<dt className="text-[12px] text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-0.5">
|
||||
{copyable ? <EndpointValue value={value} /> : <span className="text-[12.5px]">{value}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const CODING_AGENT_LOGOS: Record<CodingAgent, string> = {
|
||||
"Claude Code": anthropicLogo.src,
|
||||
Codex: openaiLogo.src,
|
||||
};
|
||||
|
||||
function setupTitle(enabled: boolean, connected: boolean) {
|
||||
if (!enabled) return "Enable tracing";
|
||||
return connected ? "Connect another agent" : "Connect your agent";
|
||||
}
|
||||
|
||||
export function TracingSetupCard({
|
||||
detail,
|
||||
connected = false,
|
||||
interface ConnectAgentProps {
|
||||
accessToken: string;
|
||||
onOpenTrace: (trace: TraceSummary) => void;
|
||||
connected: boolean;
|
||||
checked: boolean;
|
||||
checking: boolean;
|
||||
onCheck: () => void;
|
||||
readOnly: boolean;
|
||||
canMintTracingKey: boolean;
|
||||
}
|
||||
|
||||
function EnableTracing({ checked, checking, onCheck }: { checked: boolean; checking: boolean; onCheck: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<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={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>
|
||||
{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={onCheck} disabled={checking}>
|
||||
{checking && <Loader2 aria-hidden="true" className="size-4 animate-spin" />}
|
||||
{checking ? "Checking…" : "Check setup"}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectAgent({
|
||||
accessToken,
|
||||
onOpenTrace,
|
||||
connected,
|
||||
checked,
|
||||
checking,
|
||||
onCheck,
|
||||
checking = false,
|
||||
}: {
|
||||
detail: string | null;
|
||||
connected?: boolean;
|
||||
onCheck?: () => void;
|
||||
checking?: boolean;
|
||||
}) {
|
||||
readOnly,
|
||||
canMintTracingKey,
|
||||
}: ConnectAgentProps) {
|
||||
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 [codingAgent, setCodingAgent] = useState<CodingAgent>("Claude Code");
|
||||
const [tracingKey, setTracingKey] = useState<string | null>(null);
|
||||
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 install = guide.typescript ? `npm install ${packages}` : PY_INSTALL[installer](packages);
|
||||
const quickstart = guide.quickstart.replace("{PROXY}", proxyUrl);
|
||||
return (
|
||||
<>
|
||||
{!readOnly && (
|
||||
<Step title="See it work in one click">
|
||||
<p className="mb-3 text-sm leading-6 text-muted-foreground">
|
||||
Send a small sample run (an agent, an LLM call and a tool call) to confirm tracing works end to end.
|
||||
</p>
|
||||
<SendTestTrace accessToken={accessToken} onOpenTrace={onOpenTrace} />
|
||||
</Step>
|
||||
)}
|
||||
|
||||
<Endpoints proxyUrl={proxyUrl} />
|
||||
|
||||
<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);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-labelledby="tracing-framework" className="w-full">
|
||||
<SelectValue>
|
||||
<img src={guide.logo} alt="" className="size-4" />
|
||||
{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="Get a LiteLLM key">
|
||||
{canMintTracingKey && !readOnly ? (
|
||||
<TracingKey accessToken={accessToken} tracingKey={tracingKey} onCreated={setTracingKey} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use any LiteLLM virtual key you already have, or ask a proxy admin for one.
|
||||
</p>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
title={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
Let <img src={anthropicLogo.src} alt="" className="size-3.5" /> Claude Code or
|
||||
<img src={openaiLogo.src} alt="" className="size-3.5" /> Codex connect it
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<p className="mb-3 text-sm leading-6 text-muted-foreground">
|
||||
Run this in your agent’s project. It starts your coding agent with the setup task and reads the key from
|
||||
LITELLM_API_KEY.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={codingAgentCommand(codingAgent, codingAgentPrompt(proxyUrl, guide))}
|
||||
wrap
|
||||
tabs={
|
||||
<LineTabs
|
||||
value={codingAgent}
|
||||
options={["Claude Code", "Codex"]}
|
||||
onChange={setCodingAgent}
|
||||
logos={CODING_AGENT_LOGOS}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</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={
|
||||
guide.typescript ? (
|
||||
<FileLabel>npm</FileLabel>
|
||||
) : (
|
||||
<LineTabs value={installer} options={["pip", "uv"]} onChange={setInstaller} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Step>
|
||||
<Step title="Configure environment">
|
||||
<CodeBlock
|
||||
code={tracingEnvSnippet(proxyUrl, tracingKey)}
|
||||
display={tracingEnvSnippet(proxyUrl, tracingKey && maskSecret(tracingKey))}
|
||||
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={guide.typescript ? quickstart : `import os\n\n${quickstart}`}
|
||||
tabs={<FileLabel>{guide.typescript ? "my_agent.ts" : "my_agent.py"}</FileLabel>}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<CodeBlock code={guide.typescript ? TS_RUN_SNIPPET : PY_RUN_SNIPPET} tabs={<FileLabel>Shell</FileLabel>} />
|
||||
</div>
|
||||
</Step>
|
||||
</details>
|
||||
|
||||
<TraceReceipt connected={connected} checked={checked} checking={checking} onCheck={onCheck} />
|
||||
|
||||
{!connected && (
|
||||
<section className="mt-6">
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
Example run <span className="font-normal text-muted-foreground">(sample data, not your runs)</span>
|
||||
</h3>
|
||||
<TracePreview />
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TracingSetupCard({
|
||||
detail,
|
||||
accessToken,
|
||||
onOpenTrace,
|
||||
connected = false,
|
||||
onCheck,
|
||||
checking = false,
|
||||
readOnly = false,
|
||||
canMintTracingKey = false,
|
||||
}: {
|
||||
detail: string | null;
|
||||
accessToken: string;
|
||||
onOpenTrace: (trace: TraceSummary) => void;
|
||||
connected?: boolean;
|
||||
onCheck?: () => void;
|
||||
checking?: boolean;
|
||||
readOnly?: boolean;
|
||||
canMintTracingKey?: boolean;
|
||||
}) {
|
||||
const [checked, setChecked] = useState(false);
|
||||
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="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>
|
||||
<div className="w-full py-6 pb-12" data-testid="tracing-setup-card">
|
||||
<div className="flex items-start gap-4">
|
||||
<h2 className="text-xl font-semibold tracking-tight">{setupTitle(enabled, connected)}</h2>
|
||||
<a
|
||||
className="ml-auto inline-flex shrink-0 items-center gap-1 text-sm underline underline-offset-4"
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Docs <ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<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 && <ActiveDot />}
|
||||
{enabled ? "Tracing enabled" : "Tracing is not enabled"}
|
||||
</p>
|
||||
|
||||
{!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={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>
|
||||
{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>
|
||||
</>
|
||||
{enabled ? (
|
||||
<ConnectAgent
|
||||
accessToken={accessToken}
|
||||
onOpenTrace={onOpenTrace}
|
||||
connected={connected}
|
||||
checked={checked}
|
||||
checking={checking}
|
||||
onCheck={check}
|
||||
readOnly={readOnly}
|
||||
canMintTracingKey={canMintTracingKey}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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"
|
||||
/>
|
||||
</details>
|
||||
</>
|
||||
<EnableTracing checked={checked} checking={checking} onCheck={check} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
{
|
||||
"name": "deep_research_agent",
|
||||
"input_preview": "Should we store OTEL agent spans in ClickHouse or Postgres at 50k spans/sec?",
|
||||
"span_count": 126,
|
||||
"duration_ms": 51385,
|
||||
"rows": [
|
||||
{
|
||||
"id": "5e79f3b5b504985e",
|
||||
"name": "deep_research_agent",
|
||||
"type": "agent",
|
||||
"model": null,
|
||||
"depth": 0,
|
||||
"start_offset_ms": 0,
|
||||
"duration_ms": 51385.4,
|
||||
"error": false
|
||||
},
|
||||
{
|
||||
"id": "83451f3235847f6c",
|
||||
"name": "model",
|
||||
"type": "chain",
|
||||
"model": null,
|
||||
"depth": 1,
|
||||
"start_offset_ms": 1,
|
||||
"duration_ms": 9518.2,
|
||||
"error": false
|
||||
},
|
||||
{
|
||||
"id": "8a6a1c31940d07af",
|
||||
"name": "ChatOpenAI",
|
||||
"type": "llm",
|
||||
"model": "claude-sonnet-4-5",
|
||||
"depth": 2,
|
||||
"start_offset_ms": 6,
|
||||
"duration_ms": 9510.8,
|
||||
"error": false
|
||||
},
|
||||
{
|
||||
"id": "f6fdd164d528fee5",
|
||||
"name": "tools",
|
||||
"type": "chain",
|
||||
"model": null,
|
||||
"depth": 1,
|
||||
"start_offset_ms": 9520,
|
||||
"duration_ms": 2.7,
|
||||
"error": false
|
||||
},
|
||||
{
|
||||
"id": "1526d46d48d29a09",
|
||||
"name": "write_file",
|
||||
"type": "tool",
|
||||
"model": null,
|
||||
"depth": 2,
|
||||
"start_offset_ms": 9521,
|
||||
"duration_ms": 0.6,
|
||||
"error": false
|
||||
},
|
||||
{
|
||||
"id": "2697122e295d91b6",
|
||||
"name": "tools",
|
||||
"type": "chain",
|
||||
"model": null,
|
||||
"depth": 1,
|
||||
"start_offset_ms": 9523,
|
||||
"duration_ms": 35177.7,
|
||||
"error": false
|
||||
},
|
||||
{
|
||||
"id": "b2fb3a8f5a2fce01",
|
||||
"name": "task",
|
||||
"type": "tool",
|
||||
"model": null,
|
||||
"depth": 2,
|
||||
"start_offset_ms": 9524,
|
||||
"duration_ms": 35176.1,
|
||||
"error": false
|
||||
},
|
||||
{
|
||||
"id": "81499b492fd93f85",
|
||||
"name": "researcher",
|
||||
"type": "agent",
|
||||
"model": null,
|
||||
"depth": 3,
|
||||
"start_offset_ms": 9524,
|
||||
"duration_ms": 35175.3,
|
||||
"error": false
|
||||
},
|
||||
{
|
||||
"id": "64a2c760897f3310",
|
||||
"name": "model",
|
||||
"type": "chain",
|
||||
"model": null,
|
||||
"depth": 4,
|
||||
"start_offset_ms": 9526,
|
||||
"duration_ms": 6071.3,
|
||||
"error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SAMPLE_TRACE_SERVICE, sampleTraceExport } from "./sampleTrace";
|
||||
|
||||
interface ExportedSpan {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId?: string;
|
||||
startTimeUnixNano: string;
|
||||
endTimeUnixNano: string;
|
||||
attributes: { key: string; value: { stringValue: string } }[];
|
||||
}
|
||||
|
||||
const spansOf = (body: object): ExportedSpan[] =>
|
||||
(body as { resourceSpans: { scopeSpans: { spans: ExportedSpan[] }[] }[] }).resourceSpans[0].scopeSpans[0].spans;
|
||||
|
||||
const operation = (span: ExportedSpan) =>
|
||||
span.attributes.find((a) => a.key === "gen_ai.operation.name")?.value.stringValue;
|
||||
|
||||
describe("sampleTraceExport", () => {
|
||||
it("uses OTLP/JSON hex ids and returns the trace id the spans carry", () => {
|
||||
const { traceId, body } = sampleTraceExport(Date.now());
|
||||
const spans = spansOf(body);
|
||||
expect(traceId).toMatch(/^[0-9a-f]{32}$/);
|
||||
for (const span of spans) {
|
||||
expect(span.traceId).toBe(traceId);
|
||||
expect(span.spanId).toMatch(/^[0-9a-f]{16}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("nests an LLM call and a tool call under one root agent", () => {
|
||||
const spans = spansOf(sampleTraceExport(Date.now()).body);
|
||||
const roots = spans.filter((s) => !s.parentSpanId);
|
||||
expect(roots).toHaveLength(1);
|
||||
expect(operation(roots[0])).toBe("invoke_agent");
|
||||
const children = spans.filter((s) => s.parentSpanId === roots[0].spanId);
|
||||
expect(children.map(operation).sort()).toEqual(["chat", "execute_tool"]);
|
||||
});
|
||||
|
||||
it("ends at the given time and tags the sample service", () => {
|
||||
const now = 1_790_000_000_000;
|
||||
const { body } = sampleTraceExport(now);
|
||||
const root = spansOf(body).find((s) => !s.parentSpanId)!;
|
||||
expect(BigInt(root.endTimeUnixNano)).toBe(BigInt(now) * BigInt(1_000_000));
|
||||
expect(BigInt(root.startTimeUnixNano)).toBeLessThan(BigInt(root.endTimeUnixNano));
|
||||
expect(JSON.stringify(body)).toContain(SAMPLE_TRACE_SERVICE);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
const NS_PER_MS = BigInt(1_000_000);
|
||||
|
||||
export const SAMPLE_TRACE_SERVICE = "litellm-sample-agent";
|
||||
|
||||
const randomHexId = (bytes: number): string =>
|
||||
Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
const str = (key: string, value: string) => ({ key, value: { stringValue: value } });
|
||||
|
||||
const messages = (role: string, content: string): string => JSON.stringify([{ role, content }]);
|
||||
|
||||
interface SampleSpan {
|
||||
name: string;
|
||||
parent: string | null;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
attributes: ReturnType<typeof str>[];
|
||||
}
|
||||
|
||||
export function sampleTraceExport(nowMs: number): { traceId: string; body: object } {
|
||||
const traceId = randomHexId(16);
|
||||
const agentId = randomHexId(8);
|
||||
const question = "What is the weather in San Francisco?";
|
||||
const answer = "It is 18°C and sunny in San Francisco.";
|
||||
const spans: readonly (SampleSpan & { id: string })[] = [
|
||||
{
|
||||
id: agentId,
|
||||
name: "weather_agent",
|
||||
parent: null,
|
||||
startMs: 0,
|
||||
endMs: 2400,
|
||||
attributes: [
|
||||
str("gen_ai.operation.name", "invoke_agent"),
|
||||
str("gen_ai.agent.name", "weather_agent"),
|
||||
str("gen_ai.input.messages", messages("user", question)),
|
||||
str("gen_ai.output.messages", messages("assistant", answer)),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: randomHexId(8),
|
||||
name: "chat sample-model",
|
||||
parent: agentId,
|
||||
startMs: 100,
|
||||
endMs: 1500,
|
||||
attributes: [
|
||||
str("gen_ai.operation.name", "chat"),
|
||||
str("gen_ai.agent.name", "weather_agent"),
|
||||
str("gen_ai.request.model", "sample-model"),
|
||||
str("gen_ai.input.messages", messages("user", question)),
|
||||
str("gen_ai.output.messages", messages("assistant", "Calling get_weather(city=San Francisco)")),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: randomHexId(8),
|
||||
name: "get_weather",
|
||||
parent: agentId,
|
||||
startMs: 1600,
|
||||
endMs: 2300,
|
||||
attributes: [
|
||||
str("gen_ai.operation.name", "execute_tool"),
|
||||
str("gen_ai.agent.name", "weather_agent"),
|
||||
str("gen_ai.tool.call.arguments", JSON.stringify({ city: "San Francisco" })),
|
||||
str("gen_ai.tool.call.result", JSON.stringify({ temp_c: 18, sky: "sunny" })),
|
||||
],
|
||||
},
|
||||
];
|
||||
const startNs = BigInt(nowMs - 2400) * NS_PER_MS;
|
||||
const toNs = (ms: number): string => String(startNs + BigInt(ms) * NS_PER_MS);
|
||||
return {
|
||||
traceId,
|
||||
body: {
|
||||
resourceSpans: [
|
||||
{
|
||||
resource: { attributes: [str("service.name", SAMPLE_TRACE_SERVICE)] },
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: { name: "litellm-ui-sample" },
|
||||
spans: spans.map((s) => ({
|
||||
traceId,
|
||||
spanId: s.id,
|
||||
...(s.parent ? { parentSpanId: s.parent } : {}),
|
||||
name: s.name,
|
||||
kind: 1,
|
||||
startTimeUnixNano: toNs(s.startMs),
|
||||
endTimeUnixNano: toNs(s.endMs),
|
||||
attributes: s.attributes,
|
||||
status: { code: 1 },
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue