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 */} +
+
+

AgentLens v0.1.0

+
+
+
+ ); +} + +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 ( +
+

No spans recorded

+
+ ); + } + + 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 ( +
+

No events recorded

+
+ ); + } + + 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 +

+ +
+
+
+
+
+
+
+
+ example.py +
+
+            
+              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