import { type ObservationReturnTypeWithMetadata } from "@/src/server/api/routers/traces"; import { isPresent, type APIScoreV2, type TraceDomain, ObservationLevel, type ObservationLevelType, } from "@langfuse/shared"; import React, { useEffect, useMemo, useRef, useState, useLayoutEffect, } from "react"; import { SimpleTreeView, TreeItem } from "@mui/x-tree-view"; import type Decimal from "decimal.js"; import { InfoIcon } from "lucide-react"; import { heatMapTextColor, nestObservations, unnestObservation, } from "@/src/components/trace/lib/helpers"; import { type NestedObservation } from "@/src/utils/types"; import { cn } from "@/src/utils/tailwind"; import { calculateDisplayTotalCost } from "@/src/components/trace/lib/helpers"; import type { ObservationType } from "@langfuse/shared"; import { api } from "@/src/utils/api"; import { useIsAuthenticatedAndProjectMember } from "@/src/features/auth/hooks"; import { ItemBadge } from "@/src/components/ItemBadge"; import { CommentCountIcon } from "@/src/features/comments/CommentCountIcon"; import { GroupedScoreBadges } from "@/src/components/grouped-score-badge"; import { formatIntervalSeconds } from "@/src/utils/dates"; import { usdFormatter } from "@/src/utils/numbers"; import { getNumberFromMap, castToNumberMap } from "@/src/utils/map-utils"; // Fixed widths for styling for v1 const SCALE_WIDTH = 900; const STEP_SIZE = 100; const TREE_INDENTATION = 12; // default in MUI X TreeView const PREDEFINED_STEP_SIZES = [ 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 35, 40, 45, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, ]; const calculateStepSize = (latency: number, scaleWidth: number) => { const calculatedStepSize = latency / (scaleWidth / STEP_SIZE); return ( PREDEFINED_STEP_SIZES.find((step) => step >= calculatedStepSize) || PREDEFINED_STEP_SIZES[PREDEFINED_STEP_SIZES.length - 1] ); }; function TreeItemInner({ latency, totalScaleSpan, type, startOffset = 0, firstTokenTimeOffset, name, hasChildren, isSelected, showMetrics = true, showScores = true, showComments = true, colorCodeMetrics = false, scores, commentCount, parentTotalDuration, totalCost, parentTotalCost, }: { latency?: number; totalScaleSpan: number; type: ObservationType | "TRACE"; startOffset?: number; firstTokenTimeOffset?: number; name?: string | null; hasChildren: boolean; isSelected: boolean; showMetrics?: boolean; showScores?: boolean; showComments?: boolean; colorCodeMetrics?: boolean; scores?: APIScoreV2[]; commentCount?: number; parentTotalDuration?: number; totalCost?: Decimal; parentTotalCost?: Decimal; }) { const itemWidth = ((latency ?? 0) / totalScaleSpan) * SCALE_WIDTH; const duration = latency ? latency * 1000 : undefined; return (
{firstTokenTimeOffset ? (
First token {name} {showComments && commentCount ? ( ) : null} {showMetrics && isPresent(latency) && ( {formatIntervalSeconds(latency)} )} {showMetrics && totalCost && ( {usdFormatter(totalCost.toNumber())} )} {showScores && scores && scores.length > 0 && (
)}
) : (
{name} {showComments && commentCount ? ( ) : null} {showMetrics && isPresent(latency) && ( {formatIntervalSeconds(latency)} )} {showMetrics && totalCost && ( {usdFormatter(totalCost.toNumber())} )} {showScores && scores && scores.length > 0 && (
)}
)}
); } function TraceTreeItem({ observation, level = 0, traceStartTime, totalScaleSpan, projectId, scores, observations, cardWidth, commentCounts, currentObservationId, setCurrentObservationId, showMetrics, showScores, showComments, colorCodeMetrics, parentTotalDuration, parentTotalCost, }: { observation: NestedObservation; level: number; traceStartTime: Date; totalScaleSpan: number; projectId: string; scores: APIScoreV2[]; observations: Array; cardWidth: number; commentCounts?: Map; currentObservationId: string | null; setCurrentObservationId: (id: string | null) => void; showMetrics?: boolean; showScores?: boolean; showComments?: boolean; colorCodeMetrics?: boolean; parentTotalDuration?: number; parentTotalCost?: Decimal; }) { const { startTime, completionStartTime, endTime } = observation || {}; const latency = endTime ? (endTime.getTime() - startTime.getTime()) / 1000 : undefined; const startOffset = ((startTime.getTime() - traceStartTime.getTime()) / totalScaleSpan / 1000) * SCALE_WIDTH; const firstTokenTimeOffset = completionStartTime ? ((completionStartTime.getTime() - traceStartTime.getTime()) / totalScaleSpan / 1000) * SCALE_WIDTH : undefined; const observationScores = scores.filter( (s) => s.observationId === observation.id, ); // Calculate total cost for this observation and its children const unnestedObservations = unnestObservation(observation); const totalCost = calculateDisplayTotalCost({ allObservations: unnestedObservations, }); return ( { e.stopPropagation(); const isIconClick = (e.target as HTMLElement).closest( "svg.MuiSvgIcon-root", ); if (!isIconClick) { setCurrentObservationId(observation.id); } }} classes={{ content: `!rounded-none !min-w-fit !px-0 hover:!bg-background ${ observation.id === currentObservationId ? "!bg-background" : "" }`, selected: "!bg-background !important", label: "!min-w-fit", iconContainer: `absolute top-1/2 z-10 -translate-y-1/2`, }} sx={{ "& .MuiTreeItem-iconContainer": { left: startOffset > 0 ? `${startOffset + 4}px` : "4px", }, }} label={ } > {Array.isArray(observation.children) ? observation.children.map((child) => ( )) : null} ); } export function TraceTimelineView({ trace, observations, projectId, scores, currentObservationId, setCurrentObservationId, expandedItems, setExpandedItems, showMetrics = true, showScores = true, showComments = true, colorCodeMetrics = true, minLevel, setMinLevel, containerWidth, }: { trace: Omit & { latency?: number; input: string | null; output: string | null; metadata: string | null; }; observations: Array; projectId: string; scores: APIScoreV2[]; currentObservationId: string | null; setCurrentObservationId: (id: string | null) => void; expandedItems: string[]; setExpandedItems: (items: string[]) => void; showMetrics?: boolean; showScores?: boolean; showComments?: boolean; colorCodeMetrics?: boolean; minLevel?: ObservationLevelType; setMinLevel?: React.Dispatch>; containerWidth?: number; }) { const { latency, name, id } = trace; const { nestedObservations, hiddenObservationsCount } = useMemo( () => nestObservations(observations, minLevel), [observations, minLevel], ); // Use containerWidth from parent or fallback to ResizeObserver if not provided const [cardWidth, setCardWidth] = useState(0); const parentRef = useRef(null); // Calculate total cost for all observations const totalCost = useMemo( () => calculateDisplayTotalCost({ allObservations: observations, }), [observations], ); useEffect(() => { if (containerWidth) { // Use passed container width from parent setCardWidth(containerWidth); } else { // Fallback to ResizeObserver if containerWidth not provided const handleResize = () => { if (parentRef.current) { const availableWidth = parentRef.current.offsetWidth; setCardWidth(availableWidth); } }; handleResize(); if (parentRef.current) { const resizeObserver = new ResizeObserver(() => { handleResize(); }); resizeObserver.observe(parentRef.current); return () => { resizeObserver.disconnect(); }; } } }, [containerWidth]); const isAuthenticatedAndProjectMember = useIsAuthenticatedAndProjectMember(projectId); const observationCommentCounts = api.comments.getCountByObjectType.useQuery( { projectId: trace.projectId, objectType: "OBSERVATION", }, { trpc: { context: { skipBatch: true, }, }, refetchOnMount: false, // prevents refetching loops enabled: isAuthenticatedAndProjectMember && showComments, }, ); const traceCommentCounts = api.comments.getCountByObjectId.useQuery( { projectId: trace.projectId, objectId: trace.id, objectType: "TRACE", }, { trpc: { context: { skipBatch: true, }, }, refetchOnMount: false, // prevents refetching loops enabled: isAuthenticatedAndProjectMember && showComments, }, ); const timeIndexRef = useRef(null); const timelineContentRef = useRef(null); const outerContainerRef = useRef(null); const [contentWidth, setContentWidth] = useState(SCALE_WIDTH); // Use a useLayoutEffect to measure the actual content width after rendering useLayoutEffect(() => { if (!timelineContentRef.current) return; // Use scrollWidth which accounts for all content, including overflow const scrollWidth = timelineContentRef.current.scrollWidth; // Add 20px to account for scrollbar padding const newWidth = Math.max(SCALE_WIDTH, scrollWidth); if (newWidth !== contentWidth) { setContentWidth(newWidth); } }, [observations, expandedItems, contentWidth]); if (!latency) return null; const stepSize = calculateStepSize(latency, SCALE_WIDTH); const totalScaleSpan = stepSize * (SCALE_WIDTH / STEP_SIZE); const traceScores = scores.filter((s) => s.observationId === null); const totalDuration = latency * 1000; // Convert to milliseconds for consistency return (
{/* Sticky time index section - positioned absolutely at the top */}
{ if (outerContainerRef.current) { outerContainerRef.current.scrollLeft = e.currentTarget.scrollLeft; } }} >
{Array.from({ length: Math.ceil(SCALE_WIDTH / STEP_SIZE) + 1, }).map((_, index) => { const step = stepSize * index; return (
{step.toFixed(2)}s
); })} {/* Add end marker if content exceeds scale */} {contentWidth > SCALE_WIDTH && (
)}
{/* Main content with scrolling */}
{ if (timeIndexRef.current) { timeIndexRef.current.scrollLeft = e.currentTarget.scrollLeft; } }} >
{/* Main timeline content */}
setExpandedItems(itemIds) } itemChildrenIndentation={TREE_INDENTATION} expansionTrigger="iconContainer" > { e.stopPropagation(); const isIconClick = (e.target as HTMLElement).closest( "svg.MuiSvgIcon-root", ); if (!isIconClick) { setCurrentObservationId(null); } }} label={ } > {Boolean(nestedObservations.length) ? nestedObservations.map((observation) => ( )) : null} {minLevel && hiddenObservationsCount > 0 ? (

{hiddenObservationsCount} observations below {minLevel} level are hidden.

{setMinLevel && (

setMinLevel(ObservationLevel.DEBUG)} > Show all

)}
) : null}
); }