langfuse
296 строк · 10.3 Кб
1import { type NestedObservation } from "@/src/utils/types";
2import { cn } from "@/src/utils/tailwind";
3import { type Trace, type Score, $Enums } from "@prisma/client";
4import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
5import { Fragment } from "react";
6import { type ObservationReturnType } from "@/src/server/api/routers/traces";
7import { LevelColors } from "@/src/components/level-colors";
8import { formatIntervalSeconds } from "@/src/utils/dates";
9import { MinusCircle, MinusIcon, PlusCircleIcon, PlusIcon } from "lucide-react";
10import { Toggle } from "@/src/components/ui/toggle";
11import { Button } from "@/src/components/ui/button";
12
13export const ObservationTree = (props: {
14observations: ObservationReturnType[];
15collapsedObservations: string[];
16toggleCollapsedObservation: (id: string) => void;
17collapseAll: () => void;
18expandAll: () => void;
19trace: Trace;
20scores: Score[];
21currentObservationId: string | undefined;
22setCurrentObservationId: (id: string | undefined) => void;
23showMetrics: boolean;
24showScores: boolean;
25className?: string;
26}) => {
27const nestedObservations = nestObservations(props.observations);
28return (
29<div className={props.className}>
30<ObservationTreeTraceNode
31expandAll={props.expandAll}
32collapseAll={props.collapseAll}
33trace={props.trace}
34scores={props.scores}
35currentObservationId={props.currentObservationId}
36setCurrentObservationId={props.setCurrentObservationId}
37showMetrics={props.showMetrics}
38showScores={props.showScores}
39/>
40<ObservationTreeNode
41observations={nestedObservations}
42collapsedObservations={props.collapsedObservations}
43toggleCollapsedObservation={props.toggleCollapsedObservation}
44scores={props.scores}
45indentationLevel={1}
46currentObservationId={props.currentObservationId}
47setCurrentObservationId={props.setCurrentObservationId}
48showMetrics={props.showMetrics}
49showScores={props.showScores}
50/>
51</div>
52);
53};
54
55const ObservationTreeTraceNode = (props: {
56trace: Trace & { latency?: number };
57expandAll: () => void;
58collapseAll: () => void;
59scores: Score[];
60currentObservationId: string | undefined;
61setCurrentObservationId: (id: string | undefined) => void;
62showMetrics?: boolean;
63showScores?: boolean;
64}) => (
65<div
66className={cn(
67"group mb-0.5 flex cursor-pointer flex-col gap-1 rounded-sm p-1",
68props.currentObservationId === undefined ||
69props.currentObservationId === ""
70? "bg-gray-100"
71: "hover:bg-gray-50",
72)}
73onClick={() => props.setCurrentObservationId(undefined)}
74>
75<div className="flex gap-2">
76<span className={cn("rounded-sm bg-gray-200 p-1 text-xs")}>TRACE</span>
77<span className="flex-1 break-all text-sm">{props.trace.name}</span>
78<Button
79onClick={(ev) => (ev.stopPropagation(), props.expandAll())}
80size="xs"
81variant="ghost"
82title="Expand all"
83>
84<PlusCircleIcon className="h-4 w-4" />
85</Button>
86<Button
87onClick={(ev) => (ev.stopPropagation(), props.collapseAll())}
88size="xs"
89variant="ghost"
90title="Collapse all"
91>
92<MinusCircle className="h-4 w-4" />
93</Button>
94</div>
95
96{props.showMetrics && props.trace.latency ? (
97<div className="flex gap-2">
98<span className="text-xs text-gray-500">
99{formatIntervalSeconds(props.trace.latency)}
100</span>
101</div>
102) : null}
103{props.showScores && props.scores.find((s) => s.observationId === null) ? (
104<div className="flex flex-wrap gap-1">
105<GroupedScoreBadges
106scores={props.scores.filter((s) => s.observationId === null)}
107/>
108</div>
109) : null}
110</div>
111);
112
113const ObservationTreeNode = (props: {
114observations: NestedObservation[];
115collapsedObservations: string[];
116toggleCollapsedObservation: (id: string) => void;
117scores: Score[];
118indentationLevel: number;
119currentObservationId: string | undefined;
120setCurrentObservationId: (id: string | undefined) => void;
121showMetrics?: boolean;
122showScores?: boolean;
123}) => (
124<>
125{props.observations
126.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
127.map((observation) => {
128const collapsed = props.collapsedObservations.includes(observation.id);
129
130return (
131<Fragment key={observation.id}>
132<div className="flex">
133{Array.from({ length: props.indentationLevel }, (_, i) => (
134<div className="mx-2 border-r" key={i} />
135))}
136<div
137className={cn(
138"group my-0.5 flex flex-1 cursor-pointer flex-col gap-1 rounded-sm p-1",
139props.currentObservationId === observation.id
140? "bg-gray-100"
141: "hover:bg-gray-50",
142)}
143onClick={() => props.setCurrentObservationId(observation.id)}
144>
145<div className="flex gap-2">
146<ColorCodedObservationType
147observationType={observation.type}
148/>
149<span className="flex-1 break-all text-sm">
150{observation.name}
151</span>
152{observation.children.length === 0 ? null : (
153<Toggle
154onClick={(ev) => (
155ev.stopPropagation(),
156props.toggleCollapsedObservation(observation.id)
157)}
158variant="default"
159pressed={collapsed}
160size="xs"
161className="w-7"
162title={
163collapsed ? "Expand children" : "Collapse children"
164}
165>
166{collapsed ? (
167<PlusIcon className="h-4 w-4" />
168) : (
169<MinusIcon className="h-4 w-4" />
170)}
171</Toggle>
172)}
173</div>
174{props.showMetrics &&
175(observation.promptTokens ||
176observation.completionTokens ||
177observation.totalTokens ||
178observation.endTime) && (
179<div className="flex gap-2">
180{observation.endTime ? (
181<span className="text-xs text-gray-500">
182{formatIntervalSeconds(
183(observation.endTime.getTime() -
184observation.startTime.getTime()) /
1851000,
186)}
187</span>
188) : null}
189{observation.promptTokens ||
190observation.completionTokens ||
191observation.totalTokens ? (
192<span className="text-xs text-gray-500">
193{observation.promptTokens} →{" "}
194{observation.completionTokens} (∑{" "}
195{observation.totalTokens})
196</span>
197) : null}
198</div>
199)}
200{observation.level !== "DEFAULT" ? (
201<div className="flex">
202<span
203className={cn(
204"rounded-sm p-0.5 text-xs",
205LevelColors[observation.level].bg,
206LevelColors[observation.level].text,
207)}
208>
209{observation.level}
210</span>
211</div>
212) : null}
213{props.showScores &&
214props.scores.find((s) => s.observationId === observation.id) ? (
215<div className="flex flex-wrap gap-1">
216<GroupedScoreBadges
217scores={props.scores.filter(
218(s) => s.observationId === observation.id,
219)}
220/>
221</div>
222) : null}
223</div>
224</div>
225{!collapsed && (
226<ObservationTreeNode
227observations={observation.children}
228collapsedObservations={props.collapsedObservations}
229toggleCollapsedObservation={props.toggleCollapsedObservation}
230scores={props.scores}
231indentationLevel={props.indentationLevel + 1}
232currentObservationId={props.currentObservationId}
233setCurrentObservationId={props.setCurrentObservationId}
234showMetrics={props.showMetrics}
235showScores={props.showScores}
236/>
237)}
238</Fragment>
239);
240})}
241</>
242);
243
244const ColorCodedObservationType = (props: {
245observationType: $Enums.ObservationType;
246}) => {
247const colors: Record<$Enums.ObservationType, string> = {
248[$Enums.ObservationType.SPAN]: "bg-blue-100",
249[$Enums.ObservationType.GENERATION]: "bg-orange-100",
250[$Enums.ObservationType.EVENT]: "bg-green-100",
251};
252
253return (
254<span
255className={cn(
256"self-start rounded-sm p-1 text-xs",
257colors[props.observationType],
258)}
259>
260{props.observationType}
261</span>
262);
263};
264
265export function nestObservations(
266list: ObservationReturnType[],
267): NestedObservation[] {
268if (list.length === 0) return [];
269
270// Step 1: Create a map where the keys are object IDs, and the values are
271// the corresponding objects with an added 'children' property.
272const map = new Map<string, NestedObservation>();
273for (const obj of list) {
274map.set(obj.id, { ...obj, children: [] });
275}
276
277// Step 2: Create another map for the roots of all trees.
278const roots = new Map<string, NestedObservation>();
279
280// Step 3: Populate the 'children' arrays and root map.
281for (const obj of map.values()) {
282if (obj.parentObservationId) {
283const parent = map.get(obj.parentObservationId);
284if (parent) {
285parent.children.push(obj);
286}
287} else {
288roots.set(obj.id, obj);
289}
290}
291
292// TODO sum token amounts per level
293
294// Step 4: Return the roots.
295return Array.from(roots.values());
296}
297