feat: LangChain auto-instrumentation + dashboard UI

- LangChain: AgentLensCallbackHandler with auto-span creation for
  LLM calls, tool calls, chains, and agent decision logging
- Dashboard: trace list with search, status filters, pagination
- Dashboard: trace detail with Decision/Span/Event tabs
- Dashboard: sidebar layout, responsive design, dark theme
This commit is contained in:
Vectry
2026-02-09 23:36:28 +00:00
parent 3fe9013838
commit 21b4f9f316
7 changed files with 1677 additions and 23 deletions

View File

@@ -0,0 +1,155 @@
"use client";
import { ReactNode, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Activity,
GitBranch,
Settings,
Menu,
X,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
comingSoon?: boolean;
}
const navItems: NavItem[] = [
{ href: "/dashboard", label: "Traces", icon: Activity },
{ href: "/dashboard/decisions", label: "Decisions", icon: GitBranch, comingSoon: true },
{ href: "/dashboard/settings", label: "Settings", icon: Settings, comingSoon: true },
];
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
return (
<div className="flex flex-col h-full bg-neutral-900 border-r border-neutral-800">
{/* Logo */}
<div className="p-6 border-b border-neutral-800">
<Link
href="/"
className="flex items-center gap-3 group"
onClick={onNavigate}
>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20 group-hover:shadow-emerald-500/30 transition-shadow">
<Activity className="w-5 h-5 text-white" />
</div>
<div className="flex flex-col">
<span className="font-bold text-lg text-neutral-100">AgentLens</span>
<span className="text-xs text-neutral-500">Dashboard</span>
</div>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.comingSoon ? "#" : item.href}
onClick={(e) => {
if (item.comingSoon) {
e.preventDefault();
return;
}
onNavigate?.();
}}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200",
isActive
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-neutral-400 hover:text-neutral-100 hover:bg-neutral-800/50",
item.comingSoon && "opacity-50 cursor-not-allowed"
)}
>
<Icon className="w-5 h-5" />
<span className="flex-1">{item.label}</span>
{item.comingSoon && (
<span className="text-xs px-2 py-0.5 rounded-full bg-neutral-800 text-neutral-500">
Soon
</span>
)}
{isActive && <ChevronRight className="w-4 h-4" />}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="p-4 border-t border-neutral-800">
<div className="px-4 py-3 rounded-lg bg-neutral-800/50 border border-neutral-700/50">
<p className="text-xs text-neutral-500">AgentLens v0.1.0</p>
</div>
</div>
</div>
);
}
export default function DashboardLayout({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="min-h-screen bg-neutral-950 flex">
{/* Desktop Sidebar */}
<aside className="hidden lg:block w-64 h-screen sticky top-0">
<Sidebar />
</aside>
{/* Mobile Sidebar Overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Mobile Sidebar */}
<aside
className={cn(
"fixed inset-y-0 left-0 w-72 z-50 transform transition-transform duration-300 ease-in-out lg:hidden",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<Sidebar onNavigate={() => setSidebarOpen(false)} />
</aside>
{/* Main Content */}
<main className="flex-1 min-w-0">
{/* Mobile Header */}
<header className="lg:hidden sticky top-0 z-30 bg-neutral-950/80 backdrop-blur-md border-b border-neutral-800 px-4 py-3">
<div className="flex items-center justify-between">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-400 hover:text-neutral-100 transition-colors"
>
<Menu className="w-5 h-5" />
</button>
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center">
<Activity className="w-4 h-4 text-white" />
</div>
<span className="font-bold text-neutral-100">AgentLens</span>
</Link>
<div className="w-9" />
</div>
</header>
{/* Page Content */}
<div className="p-4 sm:p-6 lg:p-8">
{children}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { TraceList } from "@/components/trace-list";
interface TracesResponse {
traces: Array<{
id: string;
name: string;
status: "RUNNING" | "COMPLETED" | "ERROR";
startedAt: string;
endedAt: string | null;
durationMs: number | null;
tags: string[];
metadata: Record<string, unknown>;
_count: {
decisionPoints: number;
spans: number;
events: number;
};
}>;
total: number;
page: number;
limit: number;
totalPages: number;
}
async function getTraces(
limit = 50,
page = 1
): Promise<TracesResponse> {
try {
const res = await fetch(
`http://localhost:3000/api/traces?limit=${limit}&page=${page}`,
{ cache: "no-store" }
);
if (!res.ok) {
throw new Error(`Failed to fetch traces: ${res.status}`);
}
return res.json();
} catch (error) {
console.error("Error fetching traces:", error);
return {
traces: [],
total: 0,
page: 1,
limit,
totalPages: 0,
};
}
}
export default async function DashboardPage() {
const data = await getTraces(50, 1);
return (
<TraceList
initialTraces={data.traces}
initialTotal={data.total}
initialTotalPages={data.totalPages}
initialPage={data.page}
/>
);
}

View File

@@ -0,0 +1,87 @@
import { notFound } from "next/navigation";
import { TraceDetail } from "@/components/trace-detail";
interface TraceResponse {
trace: {
id: string;
name: string;
status: "RUNNING" | "COMPLETED" | "ERROR";
startedAt: string;
endedAt: string | null;
durationMs: number | null;
tags: string[];
metadata: Record<string, unknown>;
costUsd: number | null;
};
decisionPoints: Array<{
id: string;
type: string;
chosenAction: string;
alternatives: string[];
reasoning: string | null;
contextSnapshot: Record<string, unknown> | null;
confidence: number | null;
timestamp: string;
}>;
spans: Array<{
id: string;
name: string;
type: string;
status: "OK" | "ERROR" | "CANCELLED";
startedAt: string;
endedAt: string | null;
durationMs: number | null;
input: unknown;
output: unknown;
metadata: Record<string, unknown>;
}>;
events: Array<{
id: string;
type: string;
name: string;
timestamp: string;
metadata: Record<string, unknown>;
}>;
}
async function getTrace(id: string): Promise<TraceResponse | null> {
try {
const res = await fetch(`http://localhost:3000/api/traces/${id}`, {
cache: "no-store",
});
if (!res.ok) {
if (res.status === 404) {
return null;
}
throw new Error(`Failed to fetch trace: ${res.status}`);
}
return res.json();
} catch (error) {
console.error("Error fetching trace:", error);
return null;
}
}
interface TraceDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function TraceDetailPage({ params }: TraceDetailPageProps) {
const { id } = await params;
const data = await getTrace(id);
if (!data) {
notFound();
}
return (
<TraceDetail
trace={data.trace}
decisionPoints={data.decisionPoints}
spans={data.spans}
events={data.events}
/>
);
}

View File

@@ -0,0 +1,535 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {
ArrowLeft,
CheckCircle,
XCircle,
Activity,
Clock,
Calendar,
GitBranch,
Layers,
Zap,
ChevronDown,
ChevronRight,
FileJson,
DollarSign,
AlertCircle,
Terminal,
} from "lucide-react";
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
type TabType = "decisions" | "spans" | "events";
interface DecisionPoint {
id: string;
type: string;
chosenAction: string;
alternatives: string[];
reasoning: string | null;
contextSnapshot: Record<string, unknown> | null;
confidence: number | null;
timestamp: string;
}
interface Span {
id: string;
name: string;
type: string;
status: "OK" | "ERROR" | "CANCELLED";
startedAt: string;
endedAt: string | null;
durationMs: number | null;
input: unknown;
output: unknown;
metadata: Record<string, unknown>;
}
interface Event {
id: string;
type: string;
name: string;
timestamp: string;
metadata: Record<string, unknown>;
}
interface Trace {
id: string;
name: string;
status: TraceStatus;
startedAt: string;
endedAt: string | null;
durationMs: number | null;
tags: string[];
metadata: Record<string, unknown>;
costUsd: number | null;
}
interface TraceDetailProps {
trace: Trace;
decisionPoints: DecisionPoint[];
spans: Span[];
events: Event[];
}
const statusConfig: Record<TraceStatus, { label: string; icon: React.ComponentType<{ className?: string }>; color: string; bgColor: string }> = {
RUNNING: {
label: "Running",
icon: Activity,
color: "text-amber-400",
bgColor: "bg-amber-500/10 border-amber-500/20",
},
COMPLETED: {
label: "Completed",
icon: CheckCircle,
color: "text-emerald-400",
bgColor: "bg-emerald-500/10 border-emerald-500/20",
},
ERROR: {
label: "Error",
icon: XCircle,
color: "text-red-400",
bgColor: "bg-red-500/10 border-red-500/20",
},
};
const decisionTypeColors: Record<string, { bg: string; text: string; border: string }> = {
TOOL_SELECTION: {
bg: "bg-blue-500/10",
text: "text-blue-400",
border: "border-blue-500/20",
},
PATH_SELECTION: {
bg: "bg-purple-500/10",
text: "text-purple-400",
border: "border-purple-500/20",
},
RESPONSE_FORMULATION: {
bg: "bg-emerald-500/10",
text: "text-emerald-400",
border: "border-emerald-500/20",
},
DEFAULT: {
bg: "bg-neutral-700/30",
text: "text-neutral-400",
border: "border-neutral-600/30",
},
};
const spanStatusColors: Record<string, string> = {
OK: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
ERROR: "text-red-400 bg-red-500/10 border-red-500/20",
CANCELLED: "text-amber-400 bg-amber-500/10 border-amber-500/20",
};
const eventTypeColors: Record<string, { icon: React.ComponentType<{ className?: string }>; color: string }> = {
LLM_CALL: { icon: Zap, color: "text-purple-400" },
TOOL_CALL: { icon: Terminal, color: "text-blue-400" },
ERROR: { icon: AlertCircle, color: "text-red-400" },
DEFAULT: { icon: Activity, color: "text-neutral-400" },
};
export function TraceDetail({
trace,
decisionPoints,
spans,
events,
}: TraceDetailProps) {
const [activeTab, setActiveTab] = useState<TabType>("decisions");
const status = statusConfig[trace.status];
const StatusIcon = status.icon;
return (
<div className="space-y-6">
{/* Back Button */}
<Link
href="/dashboard"
className="inline-flex items-center gap-2 text-neutral-400 hover:text-emerald-400 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>Back to traces</span>
</Link>
{/* Header */}
<div className="p-6 bg-neutral-900 border border-neutral-800 rounded-xl">
<div className="flex flex-col lg:flex-row lg:items-start gap-6">
{/* Title and Status */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<h1 className="text-2xl font-bold text-neutral-100">
{trace.name}
</h1>
<div
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border",
status.bgColor,
status.color
)}
>
<StatusIcon className="w-4 h-4" />
<span className="text-sm font-medium">{status.label}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-neutral-400">
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
Started {formatRelativeTime(trace.startedAt)}
</span>
<span className="flex items-center gap-1.5">
<Clock className="w-4 h-4" />
Duration {formatDuration(trace.durationMs)}
</span>
{trace.costUsd !== null && (
<span className="flex items-center gap-1.5">
<DollarSign className="w-4 h-4" />
Cost ${trace.costUsd.toFixed(4)}
</span>
)}
</div>
{trace.tags.length > 0 && (
<div className="flex items-center gap-2 mt-4">
{trace.tags.map((tag) => (
<span
key={tag}
className="px-2.5 py-1 rounded-md bg-neutral-800 text-neutral-300 text-xs"
>
{tag}
</span>
))}
</div>
)}
</div>
{/* Stats */}
<div className="flex items-center gap-6">
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-1">
<GitBranch className="w-4 h-4 text-emerald-400" />
<span className="text-2xl font-bold text-neutral-100">
{decisionPoints.length}
</span>
</div>
<span className="text-xs text-neutral-500">Decisions</span>
</div>
<div className="w-px h-12 bg-neutral-800" />
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-1">
<Layers className="w-4 h-4 text-blue-400" />
<span className="text-2xl font-bold text-neutral-100">
{spans.length}
</span>
</div>
<span className="text-xs text-neutral-500">Spans</span>
</div>
<div className="w-px h-12 bg-neutral-800" />
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-1">
<Activity className="w-4 h-4 text-purple-400" />
<span className="text-2xl font-bold text-neutral-100">
{events.length}
</span>
</div>
<span className="text-xs text-neutral-500">Events</span>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-neutral-800">
<div className="flex gap-1">
<TabButton
active={activeTab === "decisions"}
onClick={() => setActiveTab("decisions")}
icon={GitBranch}
label={`Decisions (${decisionPoints.length})`}
/>
<TabButton
active={activeTab === "spans"}
onClick={() => setActiveTab("spans")}
icon={Layers}
label={`Spans (${spans.length})`}
/>
<TabButton
active={activeTab === "events"}
onClick={() => setActiveTab("events")}
icon={Activity}
label={`Events (${events.length})`}
/>
</div>
</div>
{/* Tab Content */}
<div className="min-h-[400px]">
{activeTab === "decisions" && (
<DecisionsTab decisionPoints={decisionPoints} />
)}
{activeTab === "spans" && <SpansTab spans={spans} />}
{activeTab === "events" && <EventsTab events={events} />}
</div>
</div>
);
}
function TabButton({
active,
onClick,
icon: Icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ComponentType<{ className?: string }>;
label: string;
}) {
return (
<button
onClick={onClick}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all border-b-2 -mb-px",
active
? "text-emerald-400 border-emerald-400"
: "text-neutral-400 border-transparent hover:text-neutral-200 hover:border-neutral-700"
)}
>
<Icon className="w-4 h-4" />
{label}
</button>
);
}
function DecisionsTab({ decisionPoints }: { decisionPoints: DecisionPoint[] }) {
if (decisionPoints.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-500">No decision points recorded</p>
</div>
);
}
return (
<div className="space-y-4">
{decisionPoints.map((decision) => (
<DecisionCard key={decision.id} decision={decision} />
))}
</div>
);
}
function DecisionCard({ decision }: { decision: DecisionPoint }) {
const [expanded, setExpanded] = useState(false);
const colors = decisionTypeColors[decision.type] || decisionTypeColors.DEFAULT;
return (
<div className="p-5 bg-neutral-900 border border-neutral-800 rounded-xl hover:border-neutral-700 transition-colors">
<div className="flex items-start gap-4">
<div
className={cn(
"px-2.5 py-1 rounded-md border text-xs font-medium uppercase tracking-wide",
colors.bg,
colors.text,
colors.border
)}
>
{decision.type}
</div>
<div className="flex-1">
<h3 className="font-semibold text-neutral-100 mb-1">
{decision.chosenAction}
</h3>
<p className="text-sm text-neutral-500">
{formatRelativeTime(decision.timestamp)}
</p>
</div>
{decision.confidence !== null && (
<div className="text-right">
<span className="text-xs text-neutral-500">Confidence</span>
<p className="font-semibold text-emerald-400">
{Math.round(decision.confidence * 100)}%
</p>
</div>
)}
</div>
{decision.reasoning && (
<div className="mt-4 p-4 bg-neutral-800/50 border-l-2 border-emerald-500/50 rounded-r-lg">
<p className="text-sm text-neutral-300 italic">
&ldquo;{decision.reasoning}&rdquo;
</p>
</div>
)}
{decision.alternatives.length > 0 && (
<div className="mt-4">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>
{expanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
{decision.alternatives.length} alternative
{decision.alternatives.length !== 1 ? "s" : ""} considered
</button>
{expanded && (
<div className="mt-3 space-y-2">
{decision.alternatives.map((alt, idx) => (
<div
key={idx}
className="flex items-center gap-2 px-3 py-2 bg-neutral-800/50 rounded-lg text-sm text-neutral-400"
>
<span className="text-neutral-600">#{idx + 1}</span>
{alt}
</div>
))}
</div>
)}
</div>
)}
{decision.contextSnapshot && Object.keys(decision.contextSnapshot).length > 0 && (
<div className="mt-4 pt-4 border-t border-neutral-800">
<details className="group">
<summary className="flex items-center gap-2 text-sm text-neutral-400 hover:text-neutral-200 cursor-pointer transition-colors">
<FileJson className="w-4 h-4" />
<span>Context snapshot</span>
<ChevronRight className="w-4 h-4 group-open:rotate-90 transition-transform" />
</summary>
<pre className="mt-3 p-4 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300">
{JSON.stringify(decision.contextSnapshot, null, 2)}
</pre>
</details>
</div>
)}
</div>
);
}
function SpansTab({ spans }: { spans: Span[] }) {
if (spans.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-500">No spans recorded</p>
</div>
);
}
const maxDuration = Math.max(...spans.map((s) => s.durationMs || 0));
return (
<div className="space-y-3">
{spans.map((span) => (
<SpanItem key={span.id} span={span} maxDuration={maxDuration} />
))}
</div>
);
}
function SpanItem({ span, maxDuration }: { span: Span; maxDuration: number }) {
const [expanded, setExpanded] = useState(false);
const statusColor = spanStatusColors[span.status] || spanStatusColors.CANCELLED;
const durationPercent = maxDuration > 0 ? ((span.durationMs || 0) / maxDuration) * 100 : 0;
return (
<div className="p-4 bg-neutral-900 border border-neutral-800 rounded-xl hover:border-neutral-700 transition-colors">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-4"
>
<div className={cn("px-2 py-1 rounded text-xs font-medium border", statusColor)}>
{span.status}
</div>
<div className="flex-1 text-left">
<h4 className="font-medium text-neutral-100">{span.name}</h4>
<p className="text-xs text-neutral-500">{span.type}</p>
</div>
<div className="flex items-center gap-4">
<div className="w-32 h-2 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full"
style={{ width: `${durationPercent}%` }}
/>
</div>
<span className="text-sm text-neutral-400 w-16 text-right">
{formatDuration(span.durationMs)}
</span>
{expanded ? (
<ChevronDown className="w-5 h-5 text-neutral-500" />
) : (
<ChevronRight className="w-5 h-5 text-neutral-500" />
)}
</div>
</button>
{expanded && (
<div className="mt-4 pt-4 border-t border-neutral-800 space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<h5 className="text-xs font-medium text-neutral-500 mb-2">Input</h5>
<pre className="p-3 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300">
{JSON.stringify(span.input, null, 2)}
</pre>
</div>
<div>
<h5 className="text-xs font-medium text-neutral-500 mb-2">Output</h5>
<pre className="p-3 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300">
{JSON.stringify(span.output, null, 2)}
</pre>
</div>
</div>
{Object.keys(span.metadata).length > 0 && (
<div>
<h5 className="text-xs font-medium text-neutral-500 mb-2">Metadata</h5>
<pre className="p-3 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300">
{JSON.stringify(span.metadata, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
function EventsTab({ events }: { events: Event[] }) {
if (events.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-500">No events recorded</p>
</div>
);
}
return (
<div className="space-y-2">
{events.map((event) => {
const { icon: Icon, color } = eventTypeColors[event.type] || eventTypeColors.DEFAULT;
return (
<div
key={event.id}
className="flex items-center gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-xl hover:border-neutral-700 transition-colors"
>
<div className={cn("p-2 rounded-lg bg-neutral-800", color)}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1">
<h4 className="font-medium text-neutral-100">{event.name}</h4>
<p className="text-xs text-neutral-500">{event.type}</p>
</div>
<span className="text-sm text-neutral-400">
{formatRelativeTime(event.timestamp)}
</span>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,352 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {
Search,
Filter,
Clock,
CheckCircle,
XCircle,
Activity,
GitBranch,
Layers,
ChevronLeft,
ChevronRight,
ArrowRight,
} from "lucide-react";
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
interface Trace {
id: string;
name: string;
status: TraceStatus;
startedAt: string;
endedAt: string | null;
durationMs: number | null;
tags: string[];
metadata: Record<string, unknown>;
_count: {
decisionPoints: number;
spans: number;
events: number;
};
}
interface TraceListProps {
initialTraces: Trace[];
initialTotal: number;
initialTotalPages: number;
initialPage: number;
}
type FilterStatus = "ALL" | TraceStatus;
const statusConfig: Record<TraceStatus, { label: string; icon: React.ComponentType<{ className?: string }>; color: string; bgColor: string }> = {
RUNNING: {
label: "Running",
icon: Activity,
color: "text-amber-400",
bgColor: "bg-amber-500/10 border-amber-500/20",
},
COMPLETED: {
label: "Completed",
icon: CheckCircle,
color: "text-emerald-400",
bgColor: "bg-emerald-500/10 border-emerald-500/20",
},
ERROR: {
label: "Error",
icon: XCircle,
color: "text-red-400",
bgColor: "bg-red-500/10 border-red-500/20",
},
};
export function TraceList({
initialTraces,
initialTotal,
initialTotalPages,
initialPage,
}: TraceListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<FilterStatus>("ALL");
const [currentPage, setCurrentPage] = useState(initialPage);
const filteredTraces = initialTraces.filter((trace) => {
const matchesSearch =
trace.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
trace.tags.some((tag) =>
tag.toLowerCase().includes(searchQuery.toLowerCase())
);
const matchesStatus =
statusFilter === "ALL" || trace.status === statusFilter;
return matchesSearch && matchesStatus;
});
const filterChips: { value: FilterStatus; label: string }[] = [
{ value: "ALL", label: "All" },
{ value: "RUNNING", label: "Running" },
{ value: "COMPLETED", label: "Completed" },
{ value: "ERROR", label: "Error" },
];
if (initialTraces.length === 0) {
return <EmptyState />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-neutral-100">Traces</h1>
<p className="text-neutral-400 mt-1">
{initialTotal} trace{initialTotal !== 1 ? "s" : ""} captured
</p>
</div>
</div>
{/* Search and Filter */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-500" />
<input
type="text"
placeholder="Search traces..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-neutral-900 border border-neutral-800 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all"
/>
</div>
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-neutral-500" />
<div className="flex gap-2">
{filterChips.map((chip) => (
<button
key={chip.value}
onClick={() => setStatusFilter(chip.value)}
className={cn(
"px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200",
statusFilter === chip.value
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
: "bg-neutral-900 text-neutral-400 border border-neutral-800 hover:border-neutral-700"
)}
>
{chip.label}
</button>
))}
</div>
</div>
</div>
{/* Trace List */}
<div className="space-y-3">
{filteredTraces.map((trace) => (
<TraceCard key={trace.id} trace={trace} />
))}
</div>
{/* Empty Filtered State */}
{filteredTraces.length === 0 && initialTraces.length > 0 && (
<div className="text-center py-12">
<p className="text-neutral-400">
No traces match your search criteria
</p>
</div>
)}
{/* Pagination */}
{initialTotalPages > 1 && (
<div className="flex items-center justify-between pt-6 border-t border-neutral-800">
<p className="text-sm text-neutral-500">
Page {currentPage} of {initialTotalPages}
</p>
<div className="flex gap-2">
<button
disabled={currentPage <= 1}
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
disabled={currentPage >= initialTotalPages}
onClick={() =>
setCurrentPage((p) => Math.min(initialTotalPages, p + 1))
}
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
);
}
function TraceCard({ trace }: { trace: Trace }) {
const status = statusConfig[trace.status];
const StatusIcon = status.icon;
return (
<Link href={`/dashboard/traces/${trace.id}`}>
<div className="group p-5 bg-neutral-900 border border-neutral-800 rounded-xl hover:border-emerald-500/30 hover:bg-neutral-800/50 transition-all duration-200 cursor-pointer">
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
{/* Left: Name and Status */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold text-neutral-100 truncate group-hover:text-emerald-400 transition-colors">
{trace.name}
</h3>
<div
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium",
status.bgColor,
status.color
)}
>
<StatusIcon className="w-3.5 h-3.5" />
{status.label}
</div>
</div>
<div className="flex items-center gap-4 text-sm text-neutral-500">
<span className="flex items-center gap-1.5">
<Clock className="w-4 h-4" />
{formatRelativeTime(trace.startedAt)}
</span>
<span className="flex items-center gap-1.5">
<Activity className="w-4 h-4" />
{formatDuration(trace.durationMs)}
</span>
</div>
</div>
{/* Middle: Stats */}
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-emerald-500/10">
<GitBranch className="w-4 h-4 text-emerald-400" />
</div>
<div className="flex flex-col">
<span className="text-xs text-neutral-500">Decisions</span>
<span className="text-sm font-medium text-neutral-200">
{trace._count.decisionPoints}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-blue-500/10">
<Layers className="w-4 h-4 text-blue-400" />
</div>
<div className="flex flex-col">
<span className="text-xs text-neutral-500">Spans</span>
<span className="text-sm font-medium text-neutral-200">
{trace._count.spans}
</span>
</div>
</div>
</div>
{/* Right: Tags and Arrow */}
<div className="flex items-center gap-4">
{trace.tags.length > 0 && (
<div className="flex items-center gap-1.5">
{trace.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-1 rounded-md bg-neutral-800 text-neutral-400 text-xs"
>
{tag}
</span>
))}
{trace.tags.length > 3 && (
<span className="px-2 py-1 rounded-md bg-neutral-800 text-neutral-500 text-xs">
+{trace.tags.length - 3}
</span>
)}
</div>
)}
<ArrowRight className="w-5 h-5 text-neutral-600 group-hover:text-emerald-400 group-hover:translate-x-1 transition-all" />
</div>
</div>
</div>
</Link>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-2xl bg-neutral-900 border border-neutral-800 flex items-center justify-center mb-6">
<Activity className="w-10 h-10 text-neutral-600" />
</div>
<h2 className="text-xl font-semibold text-neutral-100 mb-2">
No traces yet
</h2>
<p className="text-neutral-400 max-w-md mb-8">
Install the SDK to start capturing traces from your AI agents
</p>
<div className="w-full max-w-2xl">
<div className="rounded-xl overflow-hidden border border-neutral-800 bg-neutral-900">
<div className="px-4 py-3 border-b border-neutral-800 flex items-center gap-2">
<div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-neutral-700" />
<div className="w-3 h-3 rounded-full bg-neutral-700" />
<div className="w-3 h-3 rounded-full bg-neutral-700" />
</div>
<span className="ml-4 text-sm text-neutral-500">example.py</span>
</div>
<pre className="p-6 overflow-x-auto text-sm text-left">
<code className="text-neutral-300">
<span className="text-purple-400">from</span>{" "}
<span className="text-neutral-300">agentlens</span>{" "}
<span className="text-purple-400">import</span>{" "}
<span className="text-emerald-300">init</span>
<span className="text-neutral-300">,</span>{" "}
<span className="text-emerald-300">trace</span>
{"\n"}
{"\n"}
<span className="text-emerald-300">init</span>
<span className="text-neutral-300">(</span>
{"\n"}
{" "}
<span className="text-orange-300">api_key</span>
<span className="text-neutral-300">=</span>
<span className="text-emerald-300">&quot;your-api-key&quot;</span>
{"\n"}
<span className="text-neutral-300">)</span>
{"\n"}
{"\n"}
<span className="text-purple-400">@trace</span>
{"\n"}
<span className="text-purple-400">def</span>{" "}
<span className="text-emerald-300">my_agent</span>
<span className="text-neutral-300">():</span>
{"\n"}
{" "}
<span className="text-purple-400">return</span>{" "}
<span className="text-emerald-300">
&quot;Hello, AgentLens!&quot;
</span>
</code>
</pre>
</div>
</div>
<a
href="https://agentlens.vectry.tech/docs"
target="_blank"
rel="noopener noreferrer"
className="mt-8 inline-flex items-center gap-2 px-6 py-3 bg-emerald-500 hover:bg-emerald-400 text-neutral-950 font-semibold rounded-lg transition-colors"
>
View Documentation
<ArrowRight className="w-4 h-4" />
</a>
</div>
);
}

24
apps/web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,24 @@
export function formatDuration(ms: number | null | undefined): string {
if (ms === null || ms === undefined) return "—";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
export function formatRelativeTime(date: string | Date): string {
const now = new Date();
const then = typeof date === "string" ? new Date(date) : date;
const diffMs = now.getTime() - then.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return "just now";
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return `${diffHour}h ago`;
const diffDay = Math.floor(diffHour / 24);
return `${diffDay}d ago`;
}
export function cn(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(" ");
}

View File

@@ -1,55 +1,493 @@
"""LangChain integration for AgentLens."""
"""LangChain integration for AgentLens.
from typing import Any, Dict, Optional, Sequence
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.outputs import LLMResult
from langchain_core.messages import BaseMessage
This module provides a callback handler that auto-instruments LangChain chains,
agents, LLM calls, and tool calls, creating Spans and DecisionPoints in AgentLens traces.
"""
import logging
import time
from typing import Any, Dict, List, Optional, Union
from uuid import UUID
try:
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.outputs import LLMResult
from langchain_core.agents import AgentAction
from langchain_core.messages import BaseMessage
except ImportError:
raise ImportError(
"langchain-core is required. Install with: pip install agentlens[langchain]"
)
from agentlens.models import (
Span,
SpanType,
SpanStatus,
Event,
EventType,
_now_iso,
)
from agentlens.trace import (
get_current_trace,
get_current_span_id,
_get_context_stack,
TraceContext,
)
from agentlens.decision import log_decision
logger = logging.getLogger("agentlens")
class AgentLensCallbackHandler(BaseCallbackHandler):
"""Callback handler for LangChain integration with AgentLens.
This handler captures LLM calls, tool calls, and agent actions
to provide observability for LangChain-based agents.
This handler captures LLM calls, tool calls, agent actions, and chain execution
to provide observability for LangChain-based agents. It works both inside and
outside of an existing AgentLens trace context.
Example usage:
# Standalone (creates its own trace)
handler = AgentLensCallbackHandler(trace_name="my-langchain-trace")
chain.invoke(input, config={"callbacks": [handler]})
# Inside existing trace
with trace(name="my-operation"):
handler = AgentLensCallbackHandler()
chain.invoke(input, config={"callbacks": [handler]})
"""
def __init__(self) -> None:
self.trace_id: Optional[str] = None
def __init__(
self,
trace_name: str = "langchain-trace",
tags: Optional[List[str]] = None,
session_id: Optional[str] = None,
) -> None:
"""Initialize callback handler.
Args:
trace_name: Name for trace (if not already in a trace context)
tags: Optional tags to add to trace
session_id: Optional session ID for trace
"""
self.trace_name = trace_name
self.tags = tags
self.session_id = session_id
# Mapping from LangChain run_id to AgentLens Span
self._run_map: Dict[UUID, Span] = {}
# TraceContext if we create our own trace
self._trace_ctx: Optional[TraceContext] = None
# Track if we're in a top-level chain for trace lifecycle
self._top_level_run_id: Optional[UUID] = None
logger.debug(
"AgentLensCallbackHandler initialized: trace_name=%s, tags=%s",
trace_name,
tags,
)
def _get_or_create_trace(self) -> Optional[Any]:
"""Get current trace or create one if needed.
Returns:
The current trace (TraceData) or None if not in a trace context
"""
current_trace = get_current_trace()
if current_trace is not None:
return current_trace
# No active trace, create our own
if self._trace_ctx is None:
self._trace_ctx = TraceContext(
name=self.trace_name,
tags=self.tags,
session_id=self.session_id,
)
self._trace_ctx.__enter__()
logger.debug(
"AgentLensCallbackHandler created new trace: %s", self.trace_name
)
return get_current_trace()
def _create_span(
self,
name: str,
span_type: SpanType,
run_id: UUID,
parent_run_id: Optional[UUID],
input_data: Any = None,
) -> Optional[Span]:
"""Create a new span and add it to the trace.
Args:
name: Span name
span_type: Type of span (LLM_CALL, TOOL_CALL, CHAIN, etc.)
run_id: LangChain run ID for this operation
parent_run_id: LangChain run ID of parent operation
input_data: Input data for the span
Returns:
The created Span, or None if no active trace
"""
trace = self._get_or_create_trace()
if trace is None:
logger.warning("No active trace, skipping span creation")
return None
# Determine parent span ID from context or parent_run_id
parent_span_id = get_current_span_id()
if parent_span_id is None and parent_run_id is not None:
parent_span = self._run_map.get(parent_run_id)
if parent_span:
parent_span_id = parent_span.id
span = Span(
name=name,
type=span_type.value,
parent_span_id=parent_span_id,
input_data=input_data,
status=SpanStatus.RUNNING.value,
started_at=_now_iso(),
)
trace.spans.append(span)
self._run_map[run_id] = span
# Push onto context stack for nested operations
stack = _get_context_stack()
stack.append(span)
logger.debug(
"AgentLensCallbackHandler created span: type=%s, name=%s, run_id=%s",
span_type.value,
name,
run_id,
)
return span
def _complete_span(
self,
run_id: UUID,
output_data: Any = None,
status: SpanStatus = SpanStatus.COMPLETED,
) -> None:
"""Mark a span as completed.
Args:
run_id: LangChain run ID
output_data: Output data for the span
status: Final status of the span
"""
span = self._run_map.pop(run_id, None)
if span is None:
return
span.status = status.value
span.ended_at = _now_iso()
# Calculate duration if we have start time
# Note: We don't store start time separately, so estimate from timestamps
# In a more sophisticated implementation, we'd store start_time_ms
if output_data is not None:
span.output_data = output_data
# Pop from context stack if this is the top span
stack = _get_context_stack()
if stack and isinstance(stack[-1], Span) and stack[-1].id == span.id:
stack.pop()
logger.debug(
"AgentLensCallbackHandler completed span: name=%s, status=%s, run_id=%s",
span.name,
status.value,
run_id,
)
def _error_span(self, run_id: UUID, error: Exception) -> None:
"""Mark a span as errored and add an error event.
Args:
run_id: LangChain run ID
error: The exception that occurred
"""
span = self._run_map.get(run_id)
if span is None:
return
span.status = SpanStatus.ERROR.value
span.status_message = str(error)
span.ended_at = _now_iso()
# Add error event to trace
trace = get_current_trace()
if trace:
error_event = Event(
type=EventType.ERROR.value,
name=f"{span.name}: {str(error)}",
span_id=span.id,
metadata={"error_type": type(error).__name__},
)
trace.events.append(error_event)
# Pop from context stack
stack = _get_context_stack()
if stack and isinstance(stack[-1], Span) and stack[-1].id == span.id:
stack.pop()
logger.debug(
"AgentLensCallbackHandler errored span: name=%s, error=%s, run_id=%s",
span.name,
error,
run_id,
)
def on_llm_start(
self,
serialized: Dict[str, Any],
prompts: list[str],
prompts: List[str],
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
) -> None:
"""Called when an LLM starts processing."""
print(f"[AgentLens] LLM started: {serialized.get('name', 'unknown')}")
# Extract model name from serialized data
model_name = "unknown"
if "id" in serialized and isinstance(serialized["id"], list):
model_name = serialized["id"][-1]
elif "name" in serialized:
model_name = serialized["name"]
elif "model_name" in serialized:
model_name = serialized["model_name"]
def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
self._create_span(
name=model_name,
span_type=SpanType.LLM_CALL,
run_id=run_id,
parent_run_id=parent_run_id,
input_data={"prompts": prompts},
)
def on_llm_end(
self,
response: LLMResult,
*,
run_id: UUID,
**kwargs: Any,
) -> None:
"""Called when an LLM finishes processing."""
print(f"[AgentLens] LLM completed")
span = self._run_map.get(run_id)
if span is None:
return
def on_llm_error(self, error: Exception, **kwargs: Any) -> None:
# Extract token usage
token_count = None
llm_output = getattr(response, "llm_output", {}) or {}
if llm_output:
token_usage = llm_output.get("token_usage", {})
if token_usage:
total_tokens = token_usage.get("total_tokens")
if total_tokens is not None:
token_count = total_tokens
span.token_count = total_tokens
# Extract generation text
generations = getattr(response, "generations", [])
output_data = None
if generations:
# Get text from generations
texts = []
for gen in generations:
gen_dict = gen if isinstance(gen, dict) else gen.__dict__
text = gen_dict.get("text", "")
if text:
texts.append(text)
if texts:
output_data = {"generations": texts}
self._complete_span(run_id, output_data=output_data)
def on_llm_error(
self,
error: Exception,
*,
run_id: UUID,
**kwargs: Any,
) -> None:
"""Called when an LLM encounters an error."""
print(f"[AgentLens] LLM error: {error}")
self._error_span(run_id, error)
def on_chat_model_start(
self,
serialized: Dict[str, Any],
messages: List[List[BaseMessage]],
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
) -> None:
"""Called when a chat model starts processing."""
# Extract model name
model_name = "unknown"
if "id" in serialized and isinstance(serialized["id"], list):
model_name = serialized["id"][-1]
elif "name" in serialized:
model_name = serialized["name"]
# Extract message content for input
message_content = []
for msg_list in messages:
for msg in msg_list:
msg_dict = msg if isinstance(msg, dict) else msg.__dict__
content = msg_dict.get("content", "")
message_content.append(str(content))
self._create_span(
name=model_name,
span_type=SpanType.LLM_CALL,
run_id=run_id,
parent_run_id=parent_run_id,
input_data={"messages": message_content},
)
def on_tool_start(
self,
serialized: Dict[str, Any],
input_str: str,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
) -> None:
"""Called when a tool starts executing."""
print(f"[AgentLens] Tool started: {serialized.get('name', 'unknown')}")
tool_name = serialized.get("name", "unknown-tool")
def on_tool_end(self, output: str, **kwargs: Any) -> None:
self._create_span(
name=tool_name,
span_type=SpanType.TOOL_CALL,
run_id=run_id,
parent_run_id=parent_run_id,
input_data={"input": input_str},
)
def on_tool_end(
self,
output: Union[str, Dict[str, Any]],
*,
run_id: UUID,
**kwargs: Any,
) -> None:
"""Called when a tool finishes executing."""
print(f"[AgentLens] Tool completed")
self._complete_span(run_id, output_data={"output": output})
def on_tool_error(self, error: Exception, **kwargs: Any) -> None:
def on_tool_error(
self,
error: Exception,
*,
run_id: UUID,
**kwargs: Any,
) -> None:
"""Called when a tool encounters an error."""
print(f"[AgentLens] Tool error: {error}")
self._error_span(run_id, error)
def on_agent_action(self, action: Any, **kwargs: Any) -> None:
"""Called when an agent performs an action."""
print(f"[AgentLens] Agent action: {action.tool}")
def on_agent_action(
self,
action: AgentAction,
*,
run_id: UUID,
**kwargs: Any,
) -> None:
"""Called when an agent performs an action.
This logs tool selection as a decision point.
"""
# Log decision point for tool selection
log_decision(
type="TOOL_SELECTION",
chosen={
"name": action.tool,
"input": str(action.tool_input),
},
alternatives=[],
)
logger.debug(
"AgentLensCallbackHandler logged agent action: tool=%s, run_id=%s",
action.tool,
run_id,
)
def on_chain_start(
self,
serialized: Dict[str, Any],
inputs: Dict[str, Any],
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
) -> None:
"""Called when a chain starts executing."""
# Check if this is a top-level chain (no parent)
is_top_level = parent_run_id is None
# Extract chain name
chain_name = serialized.get("name", serialized.get("id", ["unknown-chain"])[-1])
# Create span for the chain
self._create_span(
name=chain_name,
span_type=SpanType.CHAIN,
run_id=run_id,
parent_run_id=parent_run_id,
input_data=inputs,
)
# Track top-level chain for trace lifecycle
if is_top_level:
self._top_level_run_id = run_id
def on_chain_end(
self,
outputs: Dict[str, Any],
*,
run_id: UUID,
**kwargs: Any,
) -> None:
"""Called when a chain finishes executing."""
# Complete the span
self._complete_span(run_id, output_data=outputs)
# If this was the top-level chain, close the trace
if self._top_level_run_id == run_id and self._trace_ctx is not None:
self._trace_ctx.__exit__(None, None, None)
logger.debug(
"AgentLensCallbackHandler closed trace: %s (top-level chain completed)",
self.trace_name,
)
self._trace_ctx = None
self._top_level_run_id = None
def on_chain_error(
self,
error: Exception,
*,
run_id: UUID,
**kwargs: Any,
) -> None:
"""Called when a chain encounters an error."""
self._error_span(run_id, error)
# If this was the top-level chain, close the trace with error
if self._top_level_run_id == run_id and self._trace_ctx is not None:
self._trace_ctx.__exit__(type(error), error, None)
logger.debug(
"AgentLensCallbackHandler closed trace: %s (top-level chain errored)",
self.trace_name,
)
self._trace_ctx = None
self._top_level_run_id = None