import { type ObservationLevelType, type TraceDomain } from "@langfuse/shared"; import { TraceTree } from "./TraceTree"; import { ObservationPreview } from "./ObservationPreview"; import { TracePreview } from "./TracePreview"; import { StringParam, type UrlUpdateType, useQueryParam, } from "use-query-params"; import { type ObservationReturnTypeWithMetadata } from "@/src/server/api/routers/traces"; import { api } from "@/src/utils/api"; import { castToNumberMap } from "@/src/utils/map-utils"; import useLocalStorage from "@/src/components/useLocalStorage"; import { Settings2, Download, FoldVertical, UnfoldVertical, } from "lucide-react"; import { useCallback, useState, useMemo, useRef } from "react"; import { usePanelState } from "./hooks/usePanelState"; import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture"; import { TraceTimelineView } from "@/src/components/trace/TraceTimelineView"; import { type APIScoreV2, ObservationLevel } from "@langfuse/shared"; import { useIsAuthenticatedAndProjectMember } from "@/src/features/auth/hooks"; import { TraceGraphView } from "@/src/features/trace-graph-view/components/TraceGraphView"; import { Command, CommandInput } from "@/src/components/ui/command"; import { TraceSearchList } from "@/src/components/trace/TraceSearchList"; import { Switch } from "@/src/components/ui/switch"; import { Button } from "@/src/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuLabel, } from "@/src/components/ui/dropdown-menu"; import { cn } from "@/src/utils/tailwind"; import useSessionStorage from "@/src/components/useSessionStorage"; import { JsonExpansionProvider } from "@/src/components/trace/JsonExpansionContext"; import { buildTraceUiData } from "@/src/components/trace/lib/helpers"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle, } from "@/src/components/ui/resizable"; const getNestedObservationKeys = ( observations: ObservationReturnTypeWithMetadata[], ): string[] => { const keys: string[] = []; const collectKeys = (obs: ObservationReturnTypeWithMetadata[]) => { obs.forEach((observation) => { keys.push(`observation-${observation.id}`); }); }; collectKeys(observations); return keys; }; export function Trace(props: { observations: Array; trace: Omit & { input: string | null; output: string | null; metadata: string | null; }; scores: APIScoreV2[]; projectId: string; viewType?: "detailed" | "focused"; isValidObservationId?: boolean; defaultMinObservationLevel?: ObservationLevelType; selectedTab?: string; setSelectedTab?: ( newValue?: string | null, updateType?: UrlUpdateType, ) => void; }) { const viewType = props.viewType ?? "detailed"; const isValidObservationId = props.isValidObservationId ?? true; const capture = usePostHogClientCapture(); const [currentObservationId, setCurrentObservationId] = useQueryParam( "observation", StringParam, ); const [metricsOnObservationTree, setMetricsOnObservationTree] = useLocalStorage("metricsOnObservationTree", true); const [scoresOnObservationTree, setScoresOnObservationTree] = useLocalStorage( "scoresOnObservationTree", true, ); const [ colorCodeMetricsOnObservationTree, setColorCodeMetricsOnObservationTree, ] = useLocalStorage("colorCodeMetricsOnObservationTree", true); const [showComments, setShowComments] = useLocalStorage("showComments", true); const [showGraph, setShowGraph] = useLocalStorage("showGraph", true); const [collapsedNodes, setCollapsedNodes] = useState([]); // initial panel sizes for graph resizing const [timelineGraphSizes, setTimelineGraphSizes] = useLocalStorage( "trace-detail-timeline-graph-vertical", [60, 40], ); const [treeGraphSizes, setTreeGraphSizes] = useLocalStorage( "trace-detail-tree-graph-vertical", [60, 40], ); const [minObservationLevel, setMinObservationLevel] = useState( props.defaultMinObservationLevel ?? ObservationLevel.DEFAULT, ); const containerRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); const panelState = usePanelState( containerRef, props.selectedTab?.includes("timeline") ? "timeline" : "tree", ); const isAuthenticatedAndProjectMember = useIsAuthenticatedAndProjectMember( props.projectId, ); const observationCommentCounts = api.comments.getCountByObjectType.useQuery( { projectId: props.trace.projectId, objectType: "OBSERVATION", }, { trpc: { context: { skipBatch: true, }, }, refetchOnMount: false, // prevents refetching loops enabled: isAuthenticatedAndProjectMember, }, ); const traceCommentCounts = api.comments.getCountByObjectId.useQuery( { projectId: props.trace.projectId, objectId: props.trace.id, objectType: "TRACE", }, { trpc: { context: { skipBatch: true, }, }, refetchOnMount: false, // prevents refetching loops enabled: isAuthenticatedAndProjectMember, }, ); const observationStartTimes = props.observations.map((o) => o.startTime.getTime(), ); const minStartTime = new Date( Math.min(...observationStartTimes, Date.now()), // the Date now is a guard for empty obs list ).toISOString(); const maxStartTime = new Date( Math.max(...observationStartTimes, 0), // the zero is a guard for empty obs list ).toISOString(); const agentGraphDataQuery = api.traces.getAgentGraphData.useQuery( { projectId: props.trace.projectId, traceId: props.trace.id, minStartTime, maxStartTime, }, { enabled: props.observations.length > 0, }, ); const agentGraphData = useMemo(() => { return agentGraphDataQuery.data ?? []; }, [agentGraphDataQuery.data]); const isGraphViewAvailable = useMemo(() => { if (agentGraphData.length === 0) { return false; } // don't show graph UI at all for extremely large traces const MAX_NODES_FOR_GRAPH_UI = 5000; if (agentGraphData.length >= MAX_NODES_FOR_GRAPH_UI) { return false; } // Check if there are observations that would be included in the graph (not SPAN, EVENT, or GENERATION) const hasGraphableObservations = agentGraphData.some((obs) => { return ( obs.observationType !== "SPAN" && obs.observationType !== "EVENT" && obs.observationType !== "GENERATION" ); }); const hasLangGraphData = agentGraphData.some( (obs) => obs.step != null && obs.step !== 0, ); return hasGraphableObservations || hasLangGraphData; }, [agentGraphData]); const toggleCollapsedNode = useCallback((id: string) => { setCollapsedNodes((prev) => prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id], ); }, []); const expandAll = useCallback(() => { capture("trace_detail:observation_tree_expand", { type: "all" }); setCollapsedNodes([]); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const downloadTraceAsJson = useCallback(() => { const exportData = { trace: props.trace, observations: props.observations, }; const jsonString = JSON.stringify(exportData, null, 2); const blob = new Blob([jsonString], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `trace-${props.trace.id}.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); capture("trace_detail:download_button_click"); }, [props.trace, props.observations, capture]); const [expandedItems, setExpandedItems] = useSessionStorage( `${props.trace.id}-expanded`, [ `trace-${props.trace.id}`, ...getNestedObservationKeys(props.observations), ], ); // Build UI data once const { tree: traceTree, hiddenObservationsCount, searchItems, } = useMemo( () => buildTraceUiData(props.trace, props.observations, minObservationLevel), [props.trace, props.observations, minObservationLevel], ); // Compute these outside the component to avoid recreation const hasQuery = (searchQuery ?? "").trim().length > 0; const commentsMap = new Map( [ ...(observationCommentCounts.data ? Array.from(observationCommentCounts.data.entries()) : []), ...(traceCommentCounts.data ? [ [ `trace-${props.trace.id}`, traceCommentCounts.data.get(props.trace.id), ], ] : []), ].filter(([, count]) => count !== undefined) as [string, number][], ); const treeOrSearchContent = hasQuery ? ( setSearchQuery("")} /> ) : ( ); return (
{props.selectedTab?.includes("timeline") ? ( Node display ) : ( )} {viewType === "detailed" && (
{props.selectedTab?.includes("timeline") ? ( ) : ( (() => { // Use the same root id format as the tree (see buildTraceUiData) const traceRootId = `trace-${props.trace.id}`; // Check if everything is collapsed by seeing if the trace root is collapsed const isEverythingCollapsed = collapsedNodes.includes(traceRootId); return ( ); })() )} View Options {isGraphViewAvailable && (
e.preventDefault()} >
Show Graph setShowGraph(e)} />
)}
e.preventDefault()} >
Show Comments { setShowComments(e); }} />
e.preventDefault()} >
Show Scores { capture( "trace_detail:observation_tree_toggle_scores", { show: e, }, ); setScoresOnObservationTree(e); }} />
e.preventDefault()} >
Show Metrics { capture( "trace_detail:observation_tree_toggle_metrics", { show: e, }, ); setMetricsOnObservationTree(e); }} />
e.preventDefault()} disabled={!metricsOnObservationTree} className={cn( !metricsOnObservationTree && "cursor-not-allowed", )} >
Color Code Metrics setColorCodeMetricsOnObservationTree(e) } disabled={!metricsOnObservationTree} className={cn( !metricsOnObservationTree && "cursor-not-allowed", )} />
Min Level: {minObservationLevel} Minimum Level {Object.values(ObservationLevel).map((level) => ( { e.preventDefault(); setMinObservationLevel(level); }} > {level} ))}
)}
{props.selectedTab?.includes("timeline") ? (
{isGraphViewAvailable && showGraph ? ( ) : (
)}
) : (
{isGraphViewAvailable && showGraph ? (
{treeOrSearchContent}
) : (
{treeOrSearchContent}
)}
)}
{currentObservationId === undefined || currentObservationId === "" || currentObservationId === null ? ( ) : isValidObservationId ? ( ) : null}
); }