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