diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx
new file mode 100644
index 0000000..b71924d
--- /dev/null
+++ b/apps/web/src/app/dashboard/layout.tsx
@@ -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 (
+
+ {/* Logo */}
+
+
+
+
+ AgentLens
+ Dashboard
+
+
+
+
+ {/* Navigation */}
+
+
+ {/* Footer */}
+
+
+ );
+}
+
+export default function DashboardLayout({ children }: { children: ReactNode }) {
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+
+ return (
+
+ {/* Desktop Sidebar */}
+
+
+ {/* Mobile Sidebar Overlay */}
+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ />
+ )}
+
+ {/* Mobile Sidebar */}
+
+
+ {/* Main Content */}
+
+ {/* Mobile Header */}
+
+
+
+
+
+
AgentLens
+
+
+
+
+
+ {/* Page Content */}
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx
new file mode 100644
index 0000000..300e3d1
--- /dev/null
+++ b/apps/web/src/app/dashboard/page.tsx
@@ -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
;
+ _count: {
+ decisionPoints: number;
+ spans: number;
+ events: number;
+ };
+ }>;
+ total: number;
+ page: number;
+ limit: number;
+ totalPages: number;
+}
+
+async function getTraces(
+ limit = 50,
+ page = 1
+): Promise {
+ 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 (
+
+ );
+}
diff --git a/apps/web/src/app/dashboard/traces/[id]/page.tsx b/apps/web/src/app/dashboard/traces/[id]/page.tsx
new file mode 100644
index 0000000..d26e8fc
--- /dev/null
+++ b/apps/web/src/app/dashboard/traces/[id]/page.tsx
@@ -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;
+ costUsd: number | null;
+ };
+ decisionPoints: Array<{
+ id: string;
+ type: string;
+ chosenAction: string;
+ alternatives: string[];
+ reasoning: string | null;
+ contextSnapshot: Record | 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;
+ }>;
+ events: Array<{
+ id: string;
+ type: string;
+ name: string;
+ timestamp: string;
+ metadata: Record;
+ }>;
+}
+
+async function getTrace(id: string): Promise {
+ 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 (
+
+ );
+}
diff --git a/apps/web/src/components/trace-detail.tsx b/apps/web/src/components/trace-detail.tsx
new file mode 100644
index 0000000..0124261
--- /dev/null
+++ b/apps/web/src/components/trace-detail.tsx
@@ -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 | 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;
+}
+
+interface Event {
+ id: string;
+ type: string;
+ name: string;
+ timestamp: string;
+ metadata: Record;
+}
+
+interface Trace {
+ id: string;
+ name: string;
+ status: TraceStatus;
+ startedAt: string;
+ endedAt: string | null;
+ durationMs: number | null;
+ tags: string[];
+ metadata: Record;
+ costUsd: number | null;
+}
+
+interface TraceDetailProps {
+ trace: Trace;
+ decisionPoints: DecisionPoint[];
+ spans: Span[];
+ events: Event[];
+}
+
+const statusConfig: Record; 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 = {
+ 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 = {
+ 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; 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("decisions");
+ const status = statusConfig[trace.status];
+ const StatusIcon = status.icon;
+
+ return (
+
+ {/* Back Button */}
+
+
+
Back to traces
+
+
+ {/* Header */}
+
+
+ {/* Title and Status */}
+
+
+
+ {trace.name}
+
+
+
+ {status.label}
+
+
+
+
+
+
+ Started {formatRelativeTime(trace.startedAt)}
+
+
+
+ Duration {formatDuration(trace.durationMs)}
+
+ {trace.costUsd !== null && (
+
+
+ Cost ${trace.costUsd.toFixed(4)}
+
+ )}
+
+
+ {trace.tags.length > 0 && (
+
+ {trace.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+ {/* Stats */}
+
+
+
+
+
+ {decisionPoints.length}
+
+
+
Decisions
+
+
+
+
+
+
+ {spans.length}
+
+
+
Spans
+
+
+
+
+
+
+ {events.length}
+
+
+
Events
+
+
+
+
+
+ {/* Tabs */}
+
+
+ setActiveTab("decisions")}
+ icon={GitBranch}
+ label={`Decisions (${decisionPoints.length})`}
+ />
+ setActiveTab("spans")}
+ icon={Layers}
+ label={`Spans (${spans.length})`}
+ />
+ setActiveTab("events")}
+ icon={Activity}
+ label={`Events (${events.length})`}
+ />
+
+
+
+ {/* Tab Content */}
+
+ {activeTab === "decisions" && (
+
+ )}
+ {activeTab === "spans" && }
+ {activeTab === "events" && }
+
+
+ );
+}
+
+function TabButton({
+ active,
+ onClick,
+ icon: Icon,
+ label,
+}: {
+ active: boolean;
+ onClick: () => void;
+ icon: React.ComponentType<{ className?: string }>;
+ label: string;
+}) {
+ return (
+
+ );
+}
+
+function DecisionsTab({ decisionPoints }: { decisionPoints: DecisionPoint[] }) {
+ if (decisionPoints.length === 0) {
+ return (
+
+
No decision points recorded
+
+ );
+ }
+
+ return (
+
+ {decisionPoints.map((decision) => (
+
+ ))}
+
+ );
+}
+
+function DecisionCard({ decision }: { decision: DecisionPoint }) {
+ const [expanded, setExpanded] = useState(false);
+ const colors = decisionTypeColors[decision.type] || decisionTypeColors.DEFAULT;
+
+ return (
+
+
+
+ {decision.type}
+
+
+
+ {decision.chosenAction}
+
+
+ {formatRelativeTime(decision.timestamp)}
+
+
+ {decision.confidence !== null && (
+
+
Confidence
+
+ {Math.round(decision.confidence * 100)}%
+
+
+ )}
+
+
+ {decision.reasoning && (
+
+
+ “{decision.reasoning}”
+
+
+ )}
+
+ {decision.alternatives.length > 0 && (
+
+
+
+ {expanded && (
+
+ {decision.alternatives.map((alt, idx) => (
+
+ #{idx + 1}
+ {alt}
+
+ ))}
+
+ )}
+
+ )}
+
+ {decision.contextSnapshot && Object.keys(decision.contextSnapshot).length > 0 && (
+
+
+
+
+ Context snapshot
+
+
+
+ {JSON.stringify(decision.contextSnapshot, null, 2)}
+
+
+
+ )}
+
+ );
+}
+
+function SpansTab({ spans }: { spans: Span[] }) {
+ if (spans.length === 0) {
+ return (
+
+ );
+ }
+
+ const maxDuration = Math.max(...spans.map((s) => s.durationMs || 0));
+
+ return (
+
+ {spans.map((span) => (
+
+ ))}
+
+ );
+}
+
+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 (
+
+
+
+ {expanded && (
+
+
+
+
Input
+
+ {JSON.stringify(span.input, null, 2)}
+
+
+
+
Output
+
+ {JSON.stringify(span.output, null, 2)}
+
+
+
+ {Object.keys(span.metadata).length > 0 && (
+
+
Metadata
+
+ {JSON.stringify(span.metadata, null, 2)}
+
+
+ )}
+
+ )}
+
+ );
+}
+
+function EventsTab({ events }: { events: Event[] }) {
+ if (events.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {events.map((event) => {
+ const { icon: Icon, color } = eventTypeColors[event.type] || eventTypeColors.DEFAULT;
+ return (
+
+
+
+
+
+
{event.name}
+
{event.type}
+
+
+ {formatRelativeTime(event.timestamp)}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/trace-list.tsx b/apps/web/src/components/trace-list.tsx
new file mode 100644
index 0000000..a6feda1
--- /dev/null
+++ b/apps/web/src/components/trace-list.tsx
@@ -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;
+ _count: {
+ decisionPoints: number;
+ spans: number;
+ events: number;
+ };
+}
+
+interface TraceListProps {
+ initialTraces: Trace[];
+ initialTotal: number;
+ initialTotalPages: number;
+ initialPage: number;
+}
+
+type FilterStatus = "ALL" | TraceStatus;
+
+const statusConfig: Record; 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("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 ;
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
Traces
+
+ {initialTotal} trace{initialTotal !== 1 ? "s" : ""} captured
+
+
+
+
+ {/* Search and Filter */}
+
+
+
+ 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"
+ />
+
+
+
+
+ {filterChips.map((chip) => (
+
+ ))}
+
+
+
+
+ {/* Trace List */}
+
+ {filteredTraces.map((trace) => (
+
+ ))}
+
+
+ {/* Empty Filtered State */}
+ {filteredTraces.length === 0 && initialTraces.length > 0 && (
+
+
+ No traces match your search criteria
+
+
+ )}
+
+ {/* Pagination */}
+ {initialTotalPages > 1 && (
+
+
+ Page {currentPage} of {initialTotalPages}
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+function TraceCard({ trace }: { trace: Trace }) {
+ const status = statusConfig[trace.status];
+ const StatusIcon = status.icon;
+
+ return (
+
+
+
+ {/* Left: Name and Status */}
+
+
+
+ {trace.name}
+
+
+
+ {status.label}
+
+
+
+
+
+ {formatRelativeTime(trace.startedAt)}
+
+
+
+ {formatDuration(trace.durationMs)}
+
+
+
+
+ {/* Middle: Stats */}
+
+
+
+
+
+
+ Decisions
+
+ {trace._count.decisionPoints}
+
+
+
+
+
+
+
+
+ Spans
+
+ {trace._count.spans}
+
+
+
+
+
+ {/* Right: Tags and Arrow */}
+
+ {trace.tags.length > 0 && (
+
+ {trace.tags.slice(0, 3).map((tag) => (
+
+ {tag}
+
+ ))}
+ {trace.tags.length > 3 && (
+
+ +{trace.tags.length - 3}
+
+ )}
+
+ )}
+
+
+
+
+
+ );
+}
+
+function EmptyState() {
+ return (
+
+
+
+ No traces yet
+
+
+ Install the SDK to start capturing traces from your AI agents
+
+
+
+
+
+
+
+ from{" "}
+ agentlens{" "}
+ import{" "}
+ init
+ ,{" "}
+ trace
+ {"\n"}
+ {"\n"}
+ init
+ (
+ {"\n"}
+ {" "}
+ api_key
+ =
+ "your-api-key"
+ {"\n"}
+ )
+ {"\n"}
+ {"\n"}
+ @trace
+ {"\n"}
+ def{" "}
+ my_agent
+ ():
+ {"\n"}
+ {" "}
+ return{" "}
+
+ "Hello, AgentLens!"
+
+
+
+
+
+
+
+ View Documentation
+
+
+
+ );
+}
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
new file mode 100644
index 0000000..54787b2
--- /dev/null
+++ b/apps/web/src/lib/utils.ts
@@ -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(" ");
+}
diff --git a/packages/sdk-python/agentlens/integrations/langchain.py b/packages/sdk-python/agentlens/integrations/langchain.py
index 7ccc6b5..decaf8c 100644
--- a/packages/sdk-python/agentlens/integrations/langchain.py
+++ b/packages/sdk-python/agentlens/integrations/langchain.py
@@ -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