From 461327f3951947c567dbeb08eb32c90f3742c5e3 Mon Sep 17 00:00:00 2001 From: shoqqan Date: Mon, 1 Dec 2025 00:23:44 +0500 Subject: [PATCH 01/21] llm spans --- .../components/traces/trace-view/span-card.tsx | 2 +- .../traces/trace-view/timeline-element.tsx | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/components/traces/trace-view/span-card.tsx b/frontend/components/traces/trace-view/span-card.tsx index 6e502f709..a453dad50 100644 --- a/frontend/components/traces/trace-view/span-card.tsx +++ b/frontend/components/traces/trace-view/span-card.tsx @@ -84,7 +84,7 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth, span.pending && "text-muted-foreground" )} > - {span.name} + {span.spanType === "LLM" ? span.model : span.name} {span.pending ? ( isStringDateOld(span.startTime) ? ( diff --git a/frontend/components/traces/trace-view/timeline-element.tsx b/frontend/components/traces/trace-view/timeline-element.tsx index 7fb656acb..6fc98d139 100644 --- a/frontend/components/traces/trace-view/timeline-element.tsx +++ b/frontend/components/traces/trace-view/timeline-element.tsx @@ -59,7 +59,7 @@ const TimelineElement = ({ const SpanText = useMemo(() => { const textContent = ( <> - {span.span.name}{" "} + {span.span.spanType === "LLM" ? span.span.model : span.span.name}{" "} {getDurationString(span.span.startTime, span.span.endTime)} ); @@ -130,7 +130,7 @@ const TimelineElement = ({ maxWidth: "250px", }} > - {span.span.name}{" "} + {span.span.spanType === "LLM" ? span.span.model : span.span.name}{" "} {getDurationString(span.span.startTime, span.span.endTime)} )} @@ -157,6 +157,18 @@ const TimelineElement = ({ /> ))} {textPosition === "inside" && SpanText} + {span.span.spanType === "LLM" && + span.span.attributes["gen_ai.usage.cost"] > 0 && + span.span.attributes["gen_ai.usage.input_tokens"] + span.span.attributes["gen_ai.usage.output_tokens"] > + 0 && ( +
+ ${span.span.attributes["gen_ai.usage.cost"]} + + {span.span.attributes["gen_ai.usage.input_tokens"] + span.span.attributes["gen_ai.usage.output_tokens"]}{" "} + tokens + +
+ )} {textPosition === "outside" && SpanText} From cce4e6656dde2ccc71a91a23f419ff2ae71fa4be Mon Sep 17 00:00:00 2001 From: shoqqan Date: Mon, 1 Dec 2025 01:00:22 +0500 Subject: [PATCH 02/21] little refactor --- .../traces/trace-view/timeline-element.tsx | 45 +++++++++++-------- .../components/traces/trace-view/utils.ts | 5 +++ 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/frontend/components/traces/trace-view/timeline-element.tsx b/frontend/components/traces/trace-view/timeline-element.tsx index 6fc98d139..89ac40d86 100644 --- a/frontend/components/traces/trace-view/timeline-element.tsx +++ b/frontend/components/traces/trace-view/timeline-element.tsx @@ -3,6 +3,7 @@ import React, { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; import { TraceViewSpan } from "@/components/traces/trace-view/trace-view-store.tsx"; import { TimelineData } from "@/components/traces/trace-view/trace-view-store-utils.ts"; +import { getSpanDisplayName} from "@/components/traces/trace-view/utils.ts"; import { SPAN_TYPE_TO_COLOR } from "@/lib/traces/utils"; import { cn, getDurationString } from "@/lib/utils"; @@ -34,6 +35,18 @@ const TimelineElement = ({ } }; + const llmMetrics = useMemo(() => { + if (span.span.spanType !== "LLM") return null; + + const cost = span.span.attributes["gen_ai.usage.cost"]; + const totalTokens = + span.span.attributes["gen_ai.usage.input_tokens"] + span.span.attributes["gen_ai.usage.output_tokens"]; + + if (!cost || !totalTokens) return null; + + return { cost, totalTokens }; + }, [span]); + useLayoutEffect(() => { if (!blockRef.current || !textRef.current) return; @@ -56,10 +69,10 @@ const TimelineElement = ({ }; }, [span.span.name, span.events.length, span.width]); - const SpanText = useMemo(() => { + const spanTextElement = useMemo(() => { const textContent = ( <> - {span.span.spanType === "LLM" ? span.span.model : span.span.name}{" "} + {getSpanDisplayName(span.span)}{" "} {getDurationString(span.span.startTime, span.span.endTime)} ); @@ -123,14 +136,14 @@ const TimelineElement = ({ - {span.span.spanType === "LLM" ? span.span.model : span.span.name}{" "} + {getSpanDisplayName(span.span)}{" "} {getDurationString(span.span.startTime, span.span.endTime)} )} @@ -156,21 +169,17 @@ const TimelineElement = ({ }} /> ))} - {textPosition === "inside" && SpanText} - {span.span.spanType === "LLM" && - span.span.attributes["gen_ai.usage.cost"] > 0 && - span.span.attributes["gen_ai.usage.input_tokens"] + span.span.attributes["gen_ai.usage.output_tokens"] > - 0 && ( -
- ${span.span.attributes["gen_ai.usage.cost"]} - - {span.span.attributes["gen_ai.usage.input_tokens"] + span.span.attributes["gen_ai.usage.output_tokens"]}{" "} - tokens - -
- )} + {textPosition === "inside" && spanTextElement} + {llmMetrics && ( +
+ ${llmMetrics.cost} + + {llmMetrics.totalTokens} tokens + +
+ )} - {textPosition === "outside" && SpanText} + {textPosition === "outside" && spanTextElement} ); }; diff --git a/frontend/components/traces/trace-view/utils.ts b/frontend/components/traces/trace-view/utils.ts index 5272f5f82..d73d44526 100644 --- a/frontend/components/traces/trace-view/utils.ts +++ b/frontend/components/traces/trace-view/utils.ts @@ -236,3 +236,8 @@ export const findSpanToSelect = ( // Priority 3: First span as fallback return spans?.[0]; }; + + + +export const getSpanDisplayName = (span) => + span.spanType === "LLM" ? span.model : span.name; From 1927ffe0b4679ce3d4e1b3c446aa4400197c6123 Mon Sep 17 00:00:00 2001 From: shoqqan Date: Mon, 1 Dec 2025 01:34:41 +0500 Subject: [PATCH 03/21] changes for shared added --- .../components/shared/traces/trace-view.tsx | 1 - .../traces/trace-view/span-card.tsx | 16 ++++++++++-- .../traces/trace-view/timeline-element.tsx | 25 ++++++++----------- .../components/traces/trace-view/utils.ts | 19 ++++++++++++-- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/frontend/components/shared/traces/trace-view.tsx b/frontend/components/shared/traces/trace-view.tsx index 575172b5e..580bd1fc7 100644 --- a/frontend/components/shared/traces/trace-view.tsx +++ b/frontend/components/shared/traces/trace-view.tsx @@ -87,7 +87,6 @@ const PureTraceView = ({ trace, spans }: TraceViewProps) => { spanPath: state.spanPath, setSpanPath: state.setSpanPath, })); - const hasLangGraph = useMemo(() => getHasLangGraph(), [getHasLangGraph]); const llmSpanIds = useMemo( () => diff --git a/frontend/components/traces/trace-view/span-card.tsx b/frontend/components/traces/trace-view/span-card.tsx index a453dad50..01049cc15 100644 --- a/frontend/components/traces/trace-view/span-card.tsx +++ b/frontend/components/traces/trace-view/span-card.tsx @@ -2,6 +2,7 @@ import { ChevronDown, ChevronRight, X } from "lucide-react"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { TraceViewSpan, useTraceViewStoreContext } from "@/components/traces/trace-view/trace-view-store.tsx"; +import {getLLMMetrics, getSpanDisplayName} from "@/components/traces/trace-view/utils.ts"; import { isStringDateOld } from "@/lib/traces/utils"; import { cn, getDurationString } from "@/lib/utils"; @@ -24,6 +25,7 @@ interface SpanCardProps { export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth, depth }: SpanCardProps) { const [segmentHeight, setSegmentHeight] = useState(0); + const [isHovered, setIsHovered] = useState(false); const ref = useRef(null); const { selectedSpan, spans, toggleCollapse } = useTraceViewStoreContext((state) => ({ @@ -31,7 +33,7 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth, spans: state.spans, toggleCollapse: state.toggleCollapse, })); - + const llmMetrics = getLLMMetrics(span); // Get child spans from the store const childSpans = useMemo(() => spans.filter((s) => s.parentSpanId === span.spanId), [spans, span.spanId]); @@ -84,7 +86,7 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth, span.pending && "text-muted-foreground" )} > - {span.spanType === "LLM" ? span.model : span.name} + {isHovered && span.spanType === "LLM" ? span.name : getSpanDisplayName(span)} {span.pending ? ( isStringDateOld(span.startTime) ? ( @@ -113,7 +115,17 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth, onSpanSelect?.(span); } }} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} /> + {llmMetrics && ( +
+ ${llmMetrics.cost} + + {llmMetrics.totalTokens} tokens + +
+ )} {isSelected && (
(null); const blockRef = useRef(null); const [textPosition, setTextPosition] = useState<"inside" | "outside">("inside"); + const [isHovered, setIsHovered] = useState(false); const isSelected = useMemo(() => selectedSpan?.spanId === span.span.spanId, [span.span.spanId, selectedSpan?.spanId]); @@ -35,17 +36,7 @@ const TimelineElement = ({ } }; - const llmMetrics = useMemo(() => { - if (span.span.spanType !== "LLM") return null; - - const cost = span.span.attributes["gen_ai.usage.cost"]; - const totalTokens = - span.span.attributes["gen_ai.usage.input_tokens"] + span.span.attributes["gen_ai.usage.output_tokens"]; - - if (!cost || !totalTokens) return null; - - return { cost, totalTokens }; - }, [span]); + const llmMetrics = getLLMMetrics(span.span); useLayoutEffect(() => { if (!blockRef.current || !textRef.current) return; @@ -70,9 +61,11 @@ const TimelineElement = ({ }, [span.span.name, span.events.length, span.width]); const spanTextElement = useMemo(() => { + const displayName = isHovered && span.span.spanType === "LLM" ? span.span.name : getSpanDisplayName(span.span); + const textContent = ( <> - {getSpanDisplayName(span.span)}{" "} + {displayName}{" "} {getDurationString(span.span.startTime, span.span.endTime)} ); @@ -117,13 +110,15 @@ const TimelineElement = ({ {textContent} ); - }, [span.span.name, span.span.startTime, span.span.endTime, span.left, span.events.length, textPosition]); + }, [span.span.name, span.span.startTime, span.span.endTime, span.span.spanType, span.left, span.events.length, textPosition, isHovered]); return (
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} className={cn( "absolute top-0 left-0 w-full h-8 flex items-center px-4 hover:bg-muted cursor-pointer transition duration-200" )} @@ -143,7 +138,7 @@ const TimelineElement = ({ maxWidth: "250px", }} > - {getSpanDisplayName(span.span)}{" "} + {isHovered && span.span.spanType === "LLM" ? span.span.name : getSpanDisplayName(span.span)}{" "} {getDurationString(span.span.startTime, span.span.endTime)} )} diff --git a/frontend/components/traces/trace-view/utils.ts b/frontend/components/traces/trace-view/utils.ts index d73d44526..a2eeb602e 100644 --- a/frontend/components/traces/trace-view/utils.ts +++ b/frontend/components/traces/trace-view/utils.ts @@ -239,5 +239,20 @@ export const findSpanToSelect = ( -export const getSpanDisplayName = (span) => - span.spanType === "LLM" ? span.model : span.name; +export const getSpanDisplayName = (span:TraceViewSpan) => { + const modelName = span.model ?? span.attributes["gen_ai.request.model"]; + return span.spanType === "LLM" ? modelName : span.name; +}; + + +export const getLLMMetrics = (span:TraceViewSpan)=>{ + if (span.spanType !== "LLM") return null; + + const cost = span.attributes["gen_ai.usage.cost"].toFixed(3); + const totalTokens = + span.attributes["gen_ai.usage.input_tokens"] + span.attributes["gen_ai.usage.output_tokens"]; + + if (!cost || !totalTokens) return null; + + return { cost, totalTokens }; +}; From ba368673012ed081f6976e96bc1d6fed97722ef2 Mon Sep 17 00:00:00 2001 From: shoqqan Date: Mon, 1 Dec 2025 02:00:40 +0500 Subject: [PATCH 04/21] change alignment --- .../traces/trace-view/span-card.tsx | 29 +++++++++++-------- .../traces/trace-view/timeline-element.tsx | 20 ++++++++----- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/frontend/components/traces/trace-view/span-card.tsx b/frontend/components/traces/trace-view/span-card.tsx index 01049cc15..c437fdb79 100644 --- a/frontend/components/traces/trace-view/span-card.tsx +++ b/frontend/components/traces/trace-view/span-card.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronRight, X } from "lucide-react"; +import {ChevronDown, ChevronRight, CircleDollarSign, Coins, X} from "lucide-react"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { TraceViewSpan, useTraceViewStoreContext } from "@/components/traces/trace-view/trace-view-store.tsx"; @@ -99,9 +99,22 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth, ) ) : ( -
- {getDurationString(span.startTime, span.endTime)} -
+ <> +
+ {getDurationString(span.startTime, span.endTime)} +
+ {llmMetrics && ( + <> +
+ + {llmMetrics.totalTokens} +
+
+ + {llmMetrics.cost}
+ + )} + )}
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} /> - {llmMetrics && ( -
- ${llmMetrics.cost} - - {llmMetrics.totalTokens} tokens - -
- )} {isSelected && (
{displayName}{" "} {getDurationString(span.span.startTime, span.span.endTime)} + {llmMetrics && ( + <> +
+ + {llmMetrics.totalTokens} +
+
+ + {llmMetrics.cost}
+ + )} ); @@ -165,14 +177,6 @@ const TimelineElement = ({ /> ))} {textPosition === "inside" && spanTextElement} - {llmMetrics && ( -
- ${llmMetrics.cost} - - {llmMetrics.totalTokens} tokens - -
- )}
{textPosition === "outside" && spanTextElement}
From b201b15b55de0ac40b65b19ac17a27717af537b3 Mon Sep 17 00:00:00 2001 From: shoqqan Date: Mon, 1 Dec 2025 02:03:31 +0500 Subject: [PATCH 05/21] type checking in util function --- frontend/components/traces/trace-view/utils.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/components/traces/trace-view/utils.ts b/frontend/components/traces/trace-view/utils.ts index a2eeb602e..dd4c92b7f 100644 --- a/frontend/components/traces/trace-view/utils.ts +++ b/frontend/components/traces/trace-view/utils.ts @@ -248,11 +248,14 @@ export const getSpanDisplayName = (span:TraceViewSpan) => { export const getLLMMetrics = (span:TraceViewSpan)=>{ if (span.spanType !== "LLM") return null; - const cost = span.attributes["gen_ai.usage.cost"].toFixed(3); - const totalTokens = - span.attributes["gen_ai.usage.input_tokens"] + span.attributes["gen_ai.usage.output_tokens"]; + const costValue = span.attributes["gen_ai.usage.cost"]; + const inputTokens = span.attributes["gen_ai.usage.input_tokens"]; + const outputTokens = span.attributes["gen_ai.usage.output_tokens"]; - if (!cost || !totalTokens) return null; + if (costValue == null || inputTokens == null || outputTokens == null) return null; + + const cost = costValue.toFixed(3); + const totalTokens = inputTokens + outputTokens; return { cost, totalTokens }; }; From 53528472a03a1265652f8080f2cf5df8265f3449 Mon Sep 17 00:00:00 2001 From: shoqqan Date: Mon, 1 Dec 2025 13:42:46 +0500 Subject: [PATCH 06/21] span name fallback --- frontend/components/traces/trace-view/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/traces/trace-view/utils.ts b/frontend/components/traces/trace-view/utils.ts index dd4c92b7f..1dbcb3405 100644 --- a/frontend/components/traces/trace-view/utils.ts +++ b/frontend/components/traces/trace-view/utils.ts @@ -241,7 +241,7 @@ export const findSpanToSelect = ( export const getSpanDisplayName = (span:TraceViewSpan) => { const modelName = span.model ?? span.attributes["gen_ai.request.model"]; - return span.spanType === "LLM" ? modelName : span.name; + return span.spanType === "LLM" && modelName ? modelName : span.name; }; From fe7fa5467ba71541359ef8744fa2c762748a8af2 Mon Sep 17 00:00:00 2001 From: shoqqan Date: Mon, 1 Dec 2025 14:38:56 +0500 Subject: [PATCH 07/21] center text content in timeline --- .../traces/trace-view/timeline-element.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/components/traces/trace-view/timeline-element.tsx b/frontend/components/traces/trace-view/timeline-element.tsx index f1bd7d81e..15a1b84a3 100644 --- a/frontend/components/traces/trace-view/timeline-element.tsx +++ b/frontend/components/traces/trace-view/timeline-element.tsx @@ -65,21 +65,21 @@ const TimelineElement = ({ const displayName = isHovered && span.span.spanType === "LLM" ? span.span.name : getSpanDisplayName(span.span); const textContent = ( - <> +
{displayName}{" "} {getDurationString(span.span.startTime, span.span.endTime)} {llmMetrics && ( <> -
- + + {llmMetrics.totalTokens} -
-
- - {llmMetrics.cost}
+ + + + {llmMetrics.cost} )} - +
); const commonProps = { From 10df28d5b019987cd63993042a5cc6df7f57977e Mon Sep 17 00:00:00 2001 From: shoqqan Date: Mon, 1 Dec 2025 15:35:21 +0500 Subject: [PATCH 08/21] consistent gaps --- .../traces/trace-view/span-card.tsx | 19 +++++++++---- .../traces/trace-view/timeline-element.tsx | 27 ++++++++++++------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/frontend/components/traces/trace-view/span-card.tsx b/frontend/components/traces/trace-view/span-card.tsx index c437fdb79..ee74390dc 100644 --- a/frontend/components/traces/trace-view/span-card.tsx +++ b/frontend/components/traces/trace-view/span-card.tsx @@ -1,8 +1,8 @@ -import {ChevronDown, ChevronRight, CircleDollarSign, Coins, X} from "lucide-react"; +import { ChevronDown, ChevronRight, CircleDollarSign, Coins, X } from "lucide-react"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { TraceViewSpan, useTraceViewStoreContext } from "@/components/traces/trace-view/trace-view-store.tsx"; -import {getLLMMetrics, getSpanDisplayName} from "@/components/traces/trace-view/utils.ts"; +import { getLLMMetrics, getSpanDisplayName } from "@/components/traces/trace-view/utils.ts"; import { isStringDateOld } from "@/lib/traces/utils"; import { cn, getDurationString } from "@/lib/utils"; @@ -105,13 +105,22 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth,
{llmMetrics && ( <> -
+
{llmMetrics.totalTokens}
-
+
- {llmMetrics.cost}
+ {llmMetrics.cost} +
)} diff --git a/frontend/components/traces/trace-view/timeline-element.tsx b/frontend/components/traces/trace-view/timeline-element.tsx index 15a1b84a3..23724defc 100644 --- a/frontend/components/traces/trace-view/timeline-element.tsx +++ b/frontend/components/traces/trace-view/timeline-element.tsx @@ -1,10 +1,10 @@ import { VirtualItem } from "@tanstack/react-virtual"; -import {CircleDollarSign, Coins} from "lucide-react"; +import { CircleDollarSign, Coins } from "lucide-react"; import React, { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; import { TraceViewSpan } from "@/components/traces/trace-view/trace-view-store.tsx"; import { TimelineData } from "@/components/traces/trace-view/trace-view-store-utils.ts"; -import {getLLMMetrics, getSpanDisplayName} from "@/components/traces/trace-view/utils.ts"; +import { getLLMMetrics, getSpanDisplayName } from "@/components/traces/trace-view/utils.ts"; import { SPAN_TYPE_TO_COLOR } from "@/lib/traces/utils"; import { cn, getDurationString } from "@/lib/utils"; @@ -65,18 +65,18 @@ const TimelineElement = ({ const displayName = isHovered && span.span.spanType === "LLM" ? span.span.name : getSpanDisplayName(span.span); const textContent = ( -
- {displayName}{" "} - {getDurationString(span.span.startTime, span.span.endTime)} +
+ {displayName} {getDurationString(span.span.startTime, span.span.endTime)} {llmMetrics && ( <> - + {llmMetrics.totalTokens} - + - {llmMetrics.cost} + {llmMetrics.cost} + )}
@@ -122,7 +122,16 @@ const TimelineElement = ({ {textContent} ); - }, [span.span.name, span.span.startTime, span.span.endTime, span.span.spanType, span.left, span.events.length, textPosition, isHovered]); + }, [ + span.span.name, + span.span.startTime, + span.span.endTime, + span.span.spanType, + span.left, + span.events.length, + textPosition, + isHovered, + ]); return (
Date: Wed, 3 Dec 2025 11:47:48 +0500 Subject: [PATCH 09/21] aggregated span metrics in tree --- .../trace-view/trace-view-store-utils.ts | 85 ++++++++++++++++++- .../traces/trace-view/trace-view-store.tsx | 9 ++ .../components/traces/trace-view/utils.ts | 13 ++- 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/frontend/components/traces/trace-view/trace-view-store-utils.ts b/frontend/components/traces/trace-view/trace-view-store-utils.ts index 9d11c122a..648e14ffa 100644 --- a/frontend/components/traces/trace-view/trace-view-store-utils.ts +++ b/frontend/components/traces/trace-view/trace-view-store-utils.ts @@ -4,6 +4,16 @@ import { TraceViewSpan } from "@/components/traces/trace-view/trace-view-store.t import { SpanType } from "@/lib/traces/types.ts"; import { getDuration } from "@/lib/utils"; +interface AggregatedMetrics { + totalCost: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + inputCost: number; + outputCost: number; + hasLLMDescendants: boolean; +} + export interface TreeSpan { span: TraceViewSpan; depth: number; @@ -40,6 +50,69 @@ export interface MinimapSpan extends TreeSpan { spanId: string; } +const aggregateMetricsFromChildren = ( + span: TraceViewSpan, + childSpansMap: { [key: string]: TraceViewSpan[] } +): AggregatedMetrics | undefined => { + const children = childSpansMap[span.spanId]; + + if (!children || children.length === 0) { + if (span.spanType === "LLM") { + const inputTokens = span.attributes["gen_ai.usage.input_tokens"] ?? 0; + const outputTokens = span.attributes["gen_ai.usage.output_tokens"] ?? 0; + const inputCost = span.attributes["gen_ai.usage.input_cost"] ?? 0; + const outputCost = span.attributes["gen_ai.usage.output_cost"] ?? 0; + const cost = span.attributes["gen_ai.usage.cost"] ?? (inputCost + outputCost); + + return { + totalCost: cost, + totalTokens: inputTokens + outputTokens, + inputTokens, + outputTokens, + inputCost, + outputCost, + hasLLMDescendants: true, + }; + } + return undefined; + } + + let totalCost = 0; + let totalTokens = 0; + let inputTokens = 0; + let outputTokens = 0; + let inputCost = 0; + let outputCost = 0; + let hasLLMDescendants = false; + + for (const child of children) { + const childMetrics = aggregateMetricsFromChildren(child, childSpansMap); + if (childMetrics) { + totalCost += childMetrics.totalCost; + totalTokens += childMetrics.totalTokens; + inputTokens += childMetrics.inputTokens; + outputTokens += childMetrics.outputTokens; + inputCost += childMetrics.inputCost; + outputCost += childMetrics.outputCost; + hasLLMDescendants = true; + } + } + + if (hasLLMDescendants) { + return { + totalCost, + totalTokens, + inputTokens, + outputTokens, + inputCost, + outputCost, + hasLLMDescendants: true, + }; + } + + return undefined; +}; + export const getTopLevelSpans = (spans: T[]): T[] => spans .filter((span) => !span.parentSpanId) @@ -68,6 +141,14 @@ export const transformSpansToTree = (spans: TraceViewSpan[]): TreeSpan[] => { const topLevelSpans = getTopLevelSpans(spans); const childSpans = getChildSpansMap(spans); + const spansWithMetrics = spans.map(span => { + const aggregatedMetrics = aggregateMetricsFromChildren(span, childSpans); + return aggregatedMetrics ? { ...span, aggregatedMetrics } : span; + }); + + const childSpansWithMetrics = getChildSpansMap(spansWithMetrics); + const topLevelSpansWithMetrics = getTopLevelSpans(spansWithMetrics); + const spanItems: TreeSpan[] = []; const maxY = { current: 0 }; @@ -92,11 +173,11 @@ export const transformSpansToTree = (spans: TraceViewSpan[]): TreeSpan[] => { if (!span.collapsed) { const py = maxY.current; - childSpans[span.spanId]?.forEach((child) => buildTreeWithCollapse(items, child, depth + 1, maxY, py)); + childSpansWithMetrics[span.spanId]?.forEach((child) => buildTreeWithCollapse(items, child, depth + 1, maxY, py)); } }; - topLevelSpans.forEach((span) => buildTreeWithCollapse(spanItems, span, 0, maxY, 0)); + topLevelSpansWithMetrics.forEach((span) => buildTreeWithCollapse(spanItems, span, 0, maxY, 0)); return spanItems; }; diff --git a/frontend/components/traces/trace-view/trace-view-store.tsx b/frontend/components/traces/trace-view/trace-view-store.tsx index 8a17baa22..e75f83a8c 100644 --- a/frontend/components/traces/trace-view/trace-view-store.tsx +++ b/frontend/components/traces/trace-view/trace-view-store.tsx @@ -35,6 +35,15 @@ export type TraceViewSpan = { model?: string; pending?: boolean; collapsed: boolean; + aggregatedMetrics?: { + totalCost: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + inputCost: number; + outputCost: number; + hasLLMDescendants: boolean; + }; }; export type TraceViewTrace = { diff --git a/frontend/components/traces/trace-view/utils.ts b/frontend/components/traces/trace-view/utils.ts index 1dbcb3405..96003d8df 100644 --- a/frontend/components/traces/trace-view/utils.ts +++ b/frontend/components/traces/trace-view/utils.ts @@ -246,6 +246,17 @@ export const getSpanDisplayName = (span:TraceViewSpan) => { export const getLLMMetrics = (span:TraceViewSpan)=>{ + if (span.aggregatedMetrics?.hasLLMDescendants) { + const cost = span.aggregatedMetrics.totalCost.toFixed(3); + const totalTokens = span.aggregatedMetrics.totalTokens; + + return { + cost, + totalTokens, + isAggregated: true, + }; + } + if (span.spanType !== "LLM") return null; const costValue = span.attributes["gen_ai.usage.cost"]; @@ -257,5 +268,5 @@ export const getLLMMetrics = (span:TraceViewSpan)=>{ const cost = costValue.toFixed(3); const totalTokens = inputTokens + outputTokens; - return { cost, totalTokens }; + return { cost, totalTokens, isAggregated: false }; }; From f22031a134fca1e163f80195248e554c5d292d3d Mon Sep 17 00:00:00 2001 From: shoqqan Date: Wed, 3 Dec 2025 12:04:07 +0500 Subject: [PATCH 10/21] fix lack of metrics --- .../traces/trace-view/trace-view-store-utils.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/components/traces/trace-view/trace-view-store-utils.ts b/frontend/components/traces/trace-view/trace-view-store-utils.ts index 648e14ffa..29bbd32e2 100644 --- a/frontend/components/traces/trace-view/trace-view-store-utils.ts +++ b/frontend/components/traces/trace-view/trace-view-store-utils.ts @@ -210,6 +210,13 @@ export const transformSpansToTimeline = (spans: TraceViewSpan[]): TimelineData = const childSpans = getChildSpansMap(spans); + const spansWithMetrics = spans.map(span => { + const aggregatedMetrics = aggregateMetricsFromChildren(span, childSpans); + return aggregatedMetrics ? { ...span, aggregatedMetrics } : span; + }); + + const childSpansWithMetrics = getChildSpansMap(spansWithMetrics); + // Traverse function to get ordered spans respecting collapsed state const traverse = ( span: TraceViewSpan, @@ -229,12 +236,12 @@ export const transformSpansToTimeline = (spans: TraceViewSpan[]): TimelineData = }; const orderedSpans: TraceViewSpan[] = []; - const topLevelSpans = spans + const topLevelSpans = spansWithMetrics .filter((span) => !span.parentSpanId) .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); for (const span of topLevelSpans) { - traverse(span, childSpans, orderedSpans); + traverse(span, childSpansWithMetrics, orderedSpans); } orderedSpans.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); From de9a3f732cdde85c2a01a5d50905e4cb0c821b46 Mon Sep 17 00:00:00 2001 From: shoqqan Date: Fri, 5 Dec 2025 05:58:03 +0500 Subject: [PATCH 11/21] delete unnecessary span --- .../traces/trace-view/timeline-element.tsx | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/frontend/components/traces/trace-view/timeline-element.tsx b/frontend/components/traces/trace-view/timeline-element.tsx index 23724defc..781a98f00 100644 --- a/frontend/components/traces/trace-view/timeline-element.tsx +++ b/frontend/components/traces/trace-view/timeline-element.tsx @@ -73,7 +73,8 @@ const TimelineElement = ({ {llmMetrics.totalTokens} - + + {llmMetrics.cost} @@ -148,21 +149,6 @@ const TimelineElement = ({ }} > {isSelected &&
} - {span.left > 50 && textPosition === "outside" && ( - - {isHovered && span.span.spanType === "LLM" ? span.span.name : getSpanDisplayName(span.span)}{" "} - {getDurationString(span.span.startTime, span.span.endTime)} - - )}
Date: Fri, 5 Dec 2025 06:14:33 +0500 Subject: [PATCH 12/21] fix outside view of spans --- .../components/traces/trace-view/timeline-element.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/components/traces/trace-view/timeline-element.tsx b/frontend/components/traces/trace-view/timeline-element.tsx index 781a98f00..c33e77516 100644 --- a/frontend/components/traces/trace-view/timeline-element.tsx +++ b/frontend/components/traces/trace-view/timeline-element.tsx @@ -66,7 +66,10 @@ const TimelineElement = ({ const textContent = (
- {displayName} {getDurationString(span.span.startTime, span.span.endTime)} + + {displayName} + + {getDurationString(span.span.startTime, span.span.endTime)} {llmMetrics && ( <> @@ -86,7 +89,7 @@ const TimelineElement = ({ const commonProps = { title: span.span.name, ref: textRef, - className: "text-xs font-medium text-white/90 truncate", + className: "text-xs font-medium text-white/90", }; if (textPosition === "inside") { @@ -109,7 +112,7 @@ const TimelineElement = ({ {...commonProps} className={cn(commonProps.className, "absolute text-right")} style={{ - right: `calc(100% - ${span.left}% + 16px)`, + right: `calc(100% - ${span.left}% + 60px)`, maxWidth: "250px", }} > From 687d9dab5047a1f5eed9ea9e95ac829a09972e06 Mon Sep 17 00:00:00 2001 From: shoqqan Date: Mon, 8 Dec 2025 12:33:30 +0500 Subject: [PATCH 13/21] calculations in backend, tooltip llm name, min-width for span name --- .../traces/trace-view/span-card.tsx | 36 +++---- .../traces/trace-view/timeline-element.tsx | 83 ++++++++-------- .../trace-view/trace-view-store-utils.ts | 96 +------------------ .../traces/trace-view/trace-view-store.tsx | 4 - .../trace-view/ui/span-display-tooltip.tsx | 24 +++++ .../components/traces/trace-view/utils.ts | 9 +- frontend/lib/actions/spans/index.ts | 6 +- frontend/lib/actions/spans/utils.ts | 86 +++++++++++++++++ 8 files changed, 181 insertions(+), 163 deletions(-) create mode 100644 frontend/components/traces/trace-view/ui/span-display-tooltip.tsx diff --git a/frontend/components/traces/trace-view/span-card.tsx b/frontend/components/traces/trace-view/span-card.tsx index ee74390dc..341d9b7c3 100644 --- a/frontend/components/traces/trace-view/span-card.tsx +++ b/frontend/components/traces/trace-view/span-card.tsx @@ -2,6 +2,7 @@ import { ChevronDown, ChevronRight, CircleDollarSign, Coins, X } from "lucide-re import React, { useEffect, useMemo, useRef, useState } from "react"; import { TraceViewSpan, useTraceViewStoreContext } from "@/components/traces/trace-view/trace-view-store.tsx"; +import { SpanDisplayTooltip } from "@/components/traces/trace-view/ui/span-display-tooltip.tsx"; import { getLLMMetrics, getSpanDisplayName } from "@/components/traces/trace-view/utils.ts"; import { isStringDateOld } from "@/lib/traces/utils"; import { cn, getDurationString } from "@/lib/utils"; @@ -25,7 +26,6 @@ interface SpanCardProps { export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth, depth }: SpanCardProps) { const [segmentHeight, setSegmentHeight] = useState(0); - const [isHovered, setIsHovered] = useState(false); const ref = useRef(null); const { selectedSpan, spans, toggleCollapse } = useTraceViewStoreContext((state) => ({ @@ -48,16 +48,17 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth, const isSelected = useMemo(() => selectedSpan?.spanId === span.spanId, [selectedSpan?.spanId, span.spanId]); return ( -
-
+ +
-
- {isHovered && span.spanType === "LLM" ? span.name : getSpanDisplayName(span)} -
+ + {getSpanDisplayName(span)} + {span.pending ? ( isStringDateOld(span.startTime) ? ( @@ -137,8 +133,6 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth, onSpanSelect?.(span); } }} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} /> {isSelected && (
-
+
); } diff --git a/frontend/components/traces/trace-view/timeline-element.tsx b/frontend/components/traces/trace-view/timeline-element.tsx index c33e77516..26d456363 100644 --- a/frontend/components/traces/trace-view/timeline-element.tsx +++ b/frontend/components/traces/trace-view/timeline-element.tsx @@ -4,6 +4,7 @@ import React, { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; import { TraceViewSpan } from "@/components/traces/trace-view/trace-view-store.tsx"; import { TimelineData } from "@/components/traces/trace-view/trace-view-store-utils.ts"; +import { SpanDisplayTooltip } from "@/components/traces/trace-view/ui/span-display-tooltip.tsx"; import { getLLMMetrics, getSpanDisplayName } from "@/components/traces/trace-view/utils.ts"; import { SPAN_TYPE_TO_COLOR } from "@/lib/traces/utils"; import { cn, getDurationString } from "@/lib/utils"; @@ -27,7 +28,6 @@ const TimelineElement = ({ const textRef = useRef(null); const blockRef = useRef(null); const [textPosition, setTextPosition] = useState<"inside" | "outside">("inside"); - const [isHovered, setIsHovered] = useState(false); const isSelected = useMemo(() => selectedSpan?.spanId === span.span.spanId, [span.span.spanId, selectedSpan?.spanId]); @@ -62,13 +62,11 @@ const TimelineElement = ({ }, [span.span.name, span.events.length, span.width]); const spanTextElement = useMemo(() => { - const displayName = isHovered && span.span.spanType === "LLM" ? span.span.name : getSpanDisplayName(span.span); - const textContent = (
- - {displayName} - +
+ {getSpanDisplayName(span.span)} +
{getDurationString(span.span.startTime, span.span.endTime)} {llmMetrics && ( <> @@ -112,7 +110,7 @@ const TimelineElement = ({ {...commonProps} className={cn(commonProps.className, "absolute text-right")} style={{ - right: `calc(100% - ${span.left}% + 60px)`, + right: `calc(100% - ${span.left}% + 20px)`, maxWidth: "250px", }} > @@ -134,50 +132,49 @@ const TimelineElement = ({ span.left, span.events.length, textPosition, - isHovered, ]); return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - className={cn( - "absolute top-0 left-0 w-full h-8 flex items-center px-4 hover:bg-muted cursor-pointer transition duration-200" - )} - style={{ - transform: `translateY(${virtualRow.start}px)`, - }} - > - {isSelected &&
} +
- {span.events.map((event) => ( -
- ))} - {textPosition === "inside" && spanTextElement} + {isSelected &&
} +
+ {span.events.map((event) => ( +
+ ))} + {textPosition === "inside" && spanTextElement} +
+ {textPosition === "outside" && spanTextElement}
- {textPosition === "outside" && spanTextElement} -
+ ); }; diff --git a/frontend/components/traces/trace-view/trace-view-store-utils.ts b/frontend/components/traces/trace-view/trace-view-store-utils.ts index 29bbd32e2..9d11c122a 100644 --- a/frontend/components/traces/trace-view/trace-view-store-utils.ts +++ b/frontend/components/traces/trace-view/trace-view-store-utils.ts @@ -4,16 +4,6 @@ import { TraceViewSpan } from "@/components/traces/trace-view/trace-view-store.t import { SpanType } from "@/lib/traces/types.ts"; import { getDuration } from "@/lib/utils"; -interface AggregatedMetrics { - totalCost: number; - totalTokens: number; - inputTokens: number; - outputTokens: number; - inputCost: number; - outputCost: number; - hasLLMDescendants: boolean; -} - export interface TreeSpan { span: TraceViewSpan; depth: number; @@ -50,69 +40,6 @@ export interface MinimapSpan extends TreeSpan { spanId: string; } -const aggregateMetricsFromChildren = ( - span: TraceViewSpan, - childSpansMap: { [key: string]: TraceViewSpan[] } -): AggregatedMetrics | undefined => { - const children = childSpansMap[span.spanId]; - - if (!children || children.length === 0) { - if (span.spanType === "LLM") { - const inputTokens = span.attributes["gen_ai.usage.input_tokens"] ?? 0; - const outputTokens = span.attributes["gen_ai.usage.output_tokens"] ?? 0; - const inputCost = span.attributes["gen_ai.usage.input_cost"] ?? 0; - const outputCost = span.attributes["gen_ai.usage.output_cost"] ?? 0; - const cost = span.attributes["gen_ai.usage.cost"] ?? (inputCost + outputCost); - - return { - totalCost: cost, - totalTokens: inputTokens + outputTokens, - inputTokens, - outputTokens, - inputCost, - outputCost, - hasLLMDescendants: true, - }; - } - return undefined; - } - - let totalCost = 0; - let totalTokens = 0; - let inputTokens = 0; - let outputTokens = 0; - let inputCost = 0; - let outputCost = 0; - let hasLLMDescendants = false; - - for (const child of children) { - const childMetrics = aggregateMetricsFromChildren(child, childSpansMap); - if (childMetrics) { - totalCost += childMetrics.totalCost; - totalTokens += childMetrics.totalTokens; - inputTokens += childMetrics.inputTokens; - outputTokens += childMetrics.outputTokens; - inputCost += childMetrics.inputCost; - outputCost += childMetrics.outputCost; - hasLLMDescendants = true; - } - } - - if (hasLLMDescendants) { - return { - totalCost, - totalTokens, - inputTokens, - outputTokens, - inputCost, - outputCost, - hasLLMDescendants: true, - }; - } - - return undefined; -}; - export const getTopLevelSpans = (spans: T[]): T[] => spans .filter((span) => !span.parentSpanId) @@ -141,14 +68,6 @@ export const transformSpansToTree = (spans: TraceViewSpan[]): TreeSpan[] => { const topLevelSpans = getTopLevelSpans(spans); const childSpans = getChildSpansMap(spans); - const spansWithMetrics = spans.map(span => { - const aggregatedMetrics = aggregateMetricsFromChildren(span, childSpans); - return aggregatedMetrics ? { ...span, aggregatedMetrics } : span; - }); - - const childSpansWithMetrics = getChildSpansMap(spansWithMetrics); - const topLevelSpansWithMetrics = getTopLevelSpans(spansWithMetrics); - const spanItems: TreeSpan[] = []; const maxY = { current: 0 }; @@ -173,11 +92,11 @@ export const transformSpansToTree = (spans: TraceViewSpan[]): TreeSpan[] => { if (!span.collapsed) { const py = maxY.current; - childSpansWithMetrics[span.spanId]?.forEach((child) => buildTreeWithCollapse(items, child, depth + 1, maxY, py)); + childSpans[span.spanId]?.forEach((child) => buildTreeWithCollapse(items, child, depth + 1, maxY, py)); } }; - topLevelSpansWithMetrics.forEach((span) => buildTreeWithCollapse(spanItems, span, 0, maxY, 0)); + topLevelSpans.forEach((span) => buildTreeWithCollapse(spanItems, span, 0, maxY, 0)); return spanItems; }; @@ -210,13 +129,6 @@ export const transformSpansToTimeline = (spans: TraceViewSpan[]): TimelineData = const childSpans = getChildSpansMap(spans); - const spansWithMetrics = spans.map(span => { - const aggregatedMetrics = aggregateMetricsFromChildren(span, childSpans); - return aggregatedMetrics ? { ...span, aggregatedMetrics } : span; - }); - - const childSpansWithMetrics = getChildSpansMap(spansWithMetrics); - // Traverse function to get ordered spans respecting collapsed state const traverse = ( span: TraceViewSpan, @@ -236,12 +148,12 @@ export const transformSpansToTimeline = (spans: TraceViewSpan[]): TimelineData = }; const orderedSpans: TraceViewSpan[] = []; - const topLevelSpans = spansWithMetrics + const topLevelSpans = spans .filter((span) => !span.parentSpanId) .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); for (const span of topLevelSpans) { - traverse(span, childSpansWithMetrics, orderedSpans); + traverse(span, childSpans, orderedSpans); } orderedSpans.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); diff --git a/frontend/components/traces/trace-view/trace-view-store.tsx b/frontend/components/traces/trace-view/trace-view-store.tsx index e75f83a8c..f9fc0f1f0 100644 --- a/frontend/components/traces/trace-view/trace-view-store.tsx +++ b/frontend/components/traces/trace-view/trace-view-store.tsx @@ -38,10 +38,6 @@ export type TraceViewSpan = { aggregatedMetrics?: { totalCost: number; totalTokens: number; - inputTokens: number; - outputTokens: number; - inputCost: number; - outputCost: number; hasLLMDescendants: boolean; }; }; diff --git a/frontend/components/traces/trace-view/ui/span-display-tooltip.tsx b/frontend/components/traces/trace-view/ui/span-display-tooltip.tsx new file mode 100644 index 000000000..bf098dc26 --- /dev/null +++ b/frontend/components/traces/trace-view/ui/span-display-tooltip.tsx @@ -0,0 +1,24 @@ +import React, { ReactNode } from "react"; + +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip.tsx"; +import { cn } from "@/lib/utils.ts"; + +interface SpanDisplayTooltipProps { + isLLM: boolean; + name: string; + children: ReactNode; +} + +export const SpanDisplayTooltip = ({ name, isLLM, children }: SpanDisplayTooltipProps) => ( + + + {children} + + {name} + + + +); diff --git a/frontend/components/traces/trace-view/utils.ts b/frontend/components/traces/trace-view/utils.ts index 96003d8df..2fa6962f2 100644 --- a/frontend/components/traces/trace-view/utils.ts +++ b/frontend/components/traces/trace-view/utils.ts @@ -259,7 +259,14 @@ export const getLLMMetrics = (span:TraceViewSpan)=>{ if (span.spanType !== "LLM") return null; - const costValue = span.attributes["gen_ai.usage.cost"]; + let costValue = span.attributes["gen_ai.usage.cost"]; + + if (costValue == null) { + const inputCost = span.attributes["gen_ai.usage.input_cost"] ?? 0; + const outputCost = span.attributes["gen_ai.usage.output_cost"] ?? 0; + costValue = inputCost + outputCost; + } + const inputTokens = span.attributes["gen_ai.usage.input_tokens"]; const outputTokens = span.attributes["gen_ai.usage.output_tokens"]; diff --git a/frontend/lib/actions/spans/index.ts b/frontend/lib/actions/spans/index.ts index 928e76ef1..0281c4086 100644 --- a/frontend/lib/actions/spans/index.ts +++ b/frontend/lib/actions/spans/index.ts @@ -6,7 +6,7 @@ import { Filter } from "@/lib/actions/common/filters"; import { Operator } from "@/lib/actions/common/operators"; import { buildSelectQuery, SelectQueryOptions } from "@/lib/actions/common/query-builder"; import { FiltersSchema, PaginationFiltersSchema, TimeRangeSchema } from "@/lib/actions/common/types"; -import { buildSpansQueryWithParams, createParentRewiring, transformSpanWithEvents } from "@/lib/actions/spans/utils"; +import { aggregateSpanMetrics, buildSpansQueryWithParams, createParentRewiring, transformSpanWithEvents } from "@/lib/actions/spans/utils"; import { executeQuery } from "@/lib/actions/sql"; import { clickhouseClient } from "@/lib/clickhouse/client"; import { searchTypeToQueryFilter } from "@/lib/clickhouse/spans"; @@ -304,7 +304,9 @@ export async function getTraceSpans(input: z.infer): : new Map(); const spanEventsMap = groupBy(events, (event) => event.spanId); - return spans.map((span) => transformSpanWithEvents(span, spanEventsMap, parentRewiring, projectId)); + const transformedSpans = spans.map((span) => transformSpanWithEvents(span, spanEventsMap, parentRewiring, projectId)); + const spansWithMetrics = aggregateSpanMetrics(transformedSpans); + return spansWithMetrics; } export async function deleteSpans(input: z.infer) { diff --git a/frontend/lib/actions/spans/utils.ts b/frontend/lib/actions/spans/utils.ts index d4a2dc6fb..10a606205 100644 --- a/frontend/lib/actions/spans/utils.ts +++ b/frontend/lib/actions/spans/utils.ts @@ -266,3 +266,89 @@ export const transformSpanWithEvents = ( })), collapsed: false, }); + +interface AggregatedMetrics { + totalCost: number; + totalTokens: number; + hasLLMDescendants: boolean; +} + +export const aggregateSpanMetrics = (spans: TraceViewSpan[]): TraceViewSpan[] => { + const spanMap = new Map(); + const childrenMap = new Map(); + const metricsCache = new Map(); + + for (const span of spans) { + spanMap.set(span.spanId, span); + if (span.parentSpanId) { + const siblings = childrenMap.get(span.parentSpanId) || []; + siblings.push(span.spanId); + childrenMap.set(span.parentSpanId, siblings); + } + } + + const calculateMetrics = (spanId: string): AggregatedMetrics | null => { + if (metricsCache.has(spanId)) { + return metricsCache.get(spanId)!; + } + + const span = spanMap.get(spanId)!; + const children = childrenMap.get(spanId) || []; + + if (children.length === 0) { + if (span.spanType === 'LLM') { + let cost = span.attributes['gen_ai.usage.cost']; + + if (cost == null) { + const inputCost = span.attributes['gen_ai.usage.input_cost'] ?? 0; + const outputCost = span.attributes['gen_ai.usage.output_cost'] ?? 0; + cost = inputCost + outputCost; + } + + const inputTokens = span.attributes['gen_ai.usage.input_tokens'] ?? 0; + const outputTokens = span.attributes['gen_ai.usage.output_tokens'] ?? 0; + + const metrics = { + totalCost: cost, + totalTokens: inputTokens + outputTokens, + hasLLMDescendants: true, + }; + metricsCache.set(spanId, metrics); + return metrics; + } + metricsCache.set(spanId, null); + return null; + } + + let totalCost = 0; + let totalTokens = 0; + let hasLLMDescendants = false; + + for (const childId of children) { + const childMetrics = calculateMetrics(childId); + if (childMetrics) { + totalCost += childMetrics.totalCost; + totalTokens += childMetrics.totalTokens; + hasLLMDescendants = true; + } + } + + if (hasLLMDescendants) { + const metrics = { + totalCost, + totalTokens, + hasLLMDescendants: true, + }; + metricsCache.set(spanId, metrics); + return metrics; + } + + metricsCache.set(spanId, null); + return null; + }; + + return spans.map(span => { + const metrics = calculateMetrics(span.spanId); + return metrics ? { ...span, aggregatedMetrics: metrics } : span; + }); +}; From f2f7bcadbfdc76a018dce6ead6e55c5f33599f54 Mon Sep 17 00:00:00 2001 From: shoqqan Date: Mon, 8 Dec 2025 15:31:58 +0500 Subject: [PATCH 14/21] delete duplicate ref --- frontend/components/traces/trace-view/span-card.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/components/traces/trace-view/span-card.tsx b/frontend/components/traces/trace-view/span-card.tsx index 341d9b7c3..e288aafce 100644 --- a/frontend/components/traces/trace-view/span-card.tsx +++ b/frontend/components/traces/trace-view/span-card.tsx @@ -58,7 +58,6 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth, >
Date: Mon, 8 Dec 2025 22:45:21 +0000 Subject: [PATCH 15/21] feat: count current span if llm --- frontend/lib/actions/spans/utils.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/lib/actions/spans/utils.ts b/frontend/lib/actions/spans/utils.ts index ce244ff24..40c96cd90 100644 --- a/frontend/lib/actions/spans/utils.ts +++ b/frontend/lib/actions/spans/utils.ts @@ -296,7 +296,7 @@ export const aggregateSpanMetrics = (spans: TraceViewSpan[]): TraceViewSpan[] => const children = childrenMap.get(spanId) || []; if (children.length === 0) { - if (span.spanType === 'LLM') { + if (span.spanType === "LLM") { const cost = span.totalCost || (span.inputCost ?? 0) + (span.outputCost ?? 0); const tokens = span.totalTokens || (span.inputTokens ?? 0) + (span.outputTokens ?? 0); @@ -316,6 +316,12 @@ export const aggregateSpanMetrics = (spans: TraceViewSpan[]): TraceViewSpan[] => let totalTokens = 0; let hasLLMDescendants = false; + if (span.spanType === "LLM") { + totalCost = span.totalCost || (span.inputCost ?? 0) + (span.outputCost ?? 0); + totalTokens = span.totalTokens || (span.inputTokens ?? 0) + (span.outputTokens ?? 0); + hasLLMDescendants = true; + } + for (const childId of children) { const childMetrics = calculateMetrics(childId); if (childMetrics) { @@ -339,7 +345,7 @@ export const aggregateSpanMetrics = (spans: TraceViewSpan[]): TraceViewSpan[] => return null; }; - return spans.map(span => { + return spans.map((span) => { const metrics = calculateMetrics(span.spanId); return metrics ? { ...span, aggregatedMetrics: metrics } : span; }); From 29141b1ca66c96b7fe148fc62f4b2159fb2b9ae4 Mon Sep 17 00:00:00 2001 From: Olzhas Nurpeisov Date: Tue, 9 Dec 2025 10:20:40 +0000 Subject: [PATCH 16/21] feat: address comments, llm only leaf spans --- .../traces/trace-view/timeline-element.tsx | 2 +- .../components/traces/trace-view/utils.ts | 25 ++++++++++--------- frontend/lib/actions/spans/utils.ts | 6 ----- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/frontend/components/traces/trace-view/timeline-element.tsx b/frontend/components/traces/trace-view/timeline-element.tsx index 1a13500d5..4dc1aa951 100644 --- a/frontend/components/traces/trace-view/timeline-element.tsx +++ b/frontend/components/traces/trace-view/timeline-element.tsx @@ -62,7 +62,7 @@ const TimelineElement = ({ observer.disconnect(); cancelAnimationFrame(frameId); }; - }, [span.span.name, span.events.length, span.width]); + }, [span.span.name, span.span.model, span.span.spanType, span.events.length, span.width]); const spanTextElement = useMemo(() => { const textContent = ( diff --git a/frontend/components/traces/trace-view/utils.ts b/frontend/components/traces/trace-view/utils.ts index e4a08ad0b..92ae3cde9 100644 --- a/frontend/components/traces/trace-view/utils.ts +++ b/frontend/components/traces/trace-view/utils.ts @@ -3,6 +3,7 @@ import { capitalize, get } from "lodash"; import { createSpanTypeIcon } from "@/components/traces/span-type-icon"; import { TraceViewSpan, TraceViewTrace } from "@/components/traces/trace-view/trace-view-store.tsx"; import { ColumnFilter } from "@/components/ui/infinite-datatable/ui/datatable-filter/utils"; +import { aggregateSpanMetrics } from "@/lib/actions/spans/utils.ts"; import { RealtimeSpan, SpanType } from "@/lib/traces/types"; export const enrichSpansWithPending = (existingSpans: TraceViewSpan[]): TraceViewSpan[] => { @@ -63,12 +64,12 @@ export const enrichSpansWithPending = (existingSpans: TraceViewSpan[]): TraceVie endTime: new Date(span.endTime).toISOString(), attributes: {}, events: [], - inputCost: span.inputCost, - outputCost: span.outputCost, - totalCost: span.totalCost, - inputTokens: span.inputTokens, - outputTokens: span.outputTokens, - totalTokens: span.totalTokens, + inputCost: 0, + outputCost: 0, + totalCost: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, traceId: span.traceId, spanType: SpanType.DEFAULT, path: "", @@ -170,11 +171,11 @@ export const onRealtimeUpdateSpans = const newTrace = { ...trace }; newTrace.startTime = - new Date(newTrace.startTime).getTime() < new Date(newSpan.startTime).getTime() - ? newTrace.startTime - : newSpan.startTime; + new Date(newTrace.startTime).getTime() < new Date(newSpan.startTime).getTime() + ? newTrace.startTime + : newSpan.startTime; newTrace.endTime = - new Date(newTrace.endTime).getTime() > new Date(newSpan.endTime).getTime() ? newTrace.endTime : newSpan.endTime; + new Date(newTrace.endTime).getTime() > new Date(newSpan.endTime).getTime() ? newTrace.endTime : newSpan.endTime; newTrace.totalTokens += newSpan.totalTokens || (newSpan.inputTokens ?? 0) + (newSpan.outputTokens ?? 0); newTrace.inputTokens += newSpan.inputTokens ?? 0; newTrace.outputTokens += newSpan.outputTokens ?? 0; @@ -188,7 +189,7 @@ export const onRealtimeUpdateSpans = const newSpans = [...spans]; const index = newSpans.findIndex((span) => span.spanId === newSpan.spanId); if (index !== -1) { - // Always replace existing span, regardless of pending status + // Always replace existing span, regardless of pending status newSpans[index] = { ...newSpan, collapsed: newSpans[index].collapsed || false, @@ -204,7 +205,7 @@ export const onRealtimeUpdateSpans = }); } - return enrichSpansWithPending(newSpans); + return aggregateSpanMetrics(enrichSpansWithPending(newSpans)); }); }; diff --git a/frontend/lib/actions/spans/utils.ts b/frontend/lib/actions/spans/utils.ts index 40c96cd90..259d89a46 100644 --- a/frontend/lib/actions/spans/utils.ts +++ b/frontend/lib/actions/spans/utils.ts @@ -316,12 +316,6 @@ export const aggregateSpanMetrics = (spans: TraceViewSpan[]): TraceViewSpan[] => let totalTokens = 0; let hasLLMDescendants = false; - if (span.spanType === "LLM") { - totalCost = span.totalCost || (span.inputCost ?? 0) + (span.outputCost ?? 0); - totalTokens = span.totalTokens || (span.inputTokens ?? 0) + (span.outputTokens ?? 0); - hasLLMDescendants = true; - } - for (const childId of children) { const childMetrics = calculateMetrics(childId); if (childMetrics) { From 689c00a130c768911e78f66e6ae03a2f4abec5ad Mon Sep 17 00:00:00 2001 From: Olzhas Nurpeisov Date: Tue, 9 Dec 2025 17:13:36 +0000 Subject: [PATCH 17/21] feat: add aggregation to shared traces --- frontend/lib/actions/shared/spans/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/lib/actions/shared/spans/index.ts b/frontend/lib/actions/shared/spans/index.ts index e7a3c5ba8..c0ef0c3aa 100644 --- a/frontend/lib/actions/shared/spans/index.ts +++ b/frontend/lib/actions/shared/spans/index.ts @@ -4,6 +4,7 @@ import z from "zod/v4"; import { TraceViewSpan } from "@/components/traces/trace-view/trace-view-store.tsx"; import { GetSharedTraceSchema } from "@/lib/actions/shared/trace"; +import {aggregateSpanMetrics} from "@/lib/actions/spans/utils.ts"; import { executeQuery } from "@/lib/actions/sql"; import { db } from "@/lib/db/drizzle.ts"; import { sharedTraces } from "@/lib/db/migrations/schema.ts"; @@ -73,7 +74,7 @@ export const getSharedSpans = async (input: z.infer const spanEventsMap = groupBy(events, (event) => event.spanId); - return spans.map((span) => ({ + const transformedSpans = spans.map((span) => ({ ...span, collapsed: false, attributes: tryParseJson(span.attributes) || {}, @@ -83,4 +84,6 @@ export const getSharedSpans = async (input: z.infer attributes: tryParseJson(event.attributes), })), })); + + return aggregateSpanMetrics(transformedSpans); }; From 4306b3635fea3d6b1566063080b4577becceeb25 Mon Sep 17 00:00:00 2001 From: Olzhas Nurpeisov Date: Tue, 9 Dec 2025 17:32:38 +0000 Subject: [PATCH 18/21] feat: add short notation --- frontend/components/traces/trace-view/span-card.tsx | 2 +- frontend/components/traces/trace-view/timeline-element.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/traces/trace-view/span-card.tsx b/frontend/components/traces/trace-view/span-card.tsx index a3e338180..bcb0e01d7 100644 --- a/frontend/components/traces/trace-view/span-card.tsx +++ b/frontend/components/traces/trace-view/span-card.tsx @@ -33,7 +33,7 @@ interface SpanCardProps { } const numberFormatter = new Intl.NumberFormat("en-US", { - notation: "standard", + notation: "compact", }); export function SpanCard({ span, yOffset, parentY, onSpanSelect, depth }: SpanCardProps) { diff --git a/frontend/components/traces/trace-view/timeline-element.tsx b/frontend/components/traces/trace-view/timeline-element.tsx index 4dc1aa951..4868e1610 100644 --- a/frontend/components/traces/trace-view/timeline-element.tsx +++ b/frontend/components/traces/trace-view/timeline-element.tsx @@ -14,7 +14,7 @@ const TEXT_PADDING = { }; const numberFormatter = new Intl.NumberFormat("en-US", { - notation: "standard", + notation: "compact", }); const TimelineElement = ({ From 1afb6c870fd84789fc68bc6c4737be6b08faf8c1 Mon Sep 17 00:00:00 2001 From: Olzhas Nurpeisov Date: Tue, 9 Dec 2025 18:46:15 +0000 Subject: [PATCH 19/21] feat: fix realtime --- .../components/traces/trace-view/utils.ts | 36 +++++++++++++++---- frontend/lib/traces/types.ts | 6 ---- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/frontend/components/traces/trace-view/utils.ts b/frontend/components/traces/trace-view/utils.ts index 6d27ea583..776db5c19 100644 --- a/frontend/components/traces/trace-view/utils.ts +++ b/frontend/components/traces/trace-view/utils.ts @@ -1,4 +1,4 @@ -import { capitalize } from "lodash"; +import { capitalize, get } from "lodash"; import { createSpanTypeIcon } from "@/components/traces/span-type-icon"; import { TraceViewSpan, TraceViewTrace } from "@/components/traces/trace-view/trace-view-store.tsx"; @@ -165,6 +165,16 @@ export const onRealtimeUpdateSpans = setShowBrowserSession(true); } + const totalTokens = + get(newSpan.attributes, "gen_ai.usage.input_tokens", 0) + + get(newSpan.attributes, "gen_ai.usage.output_tokens", 0); + const inputTokens = get(newSpan.attributes, "gen_ai.usage.input_tokens", 0); + const outputTokens = get(newSpan.attributes, "gen_ai.usage.output_tokens", 0); + const inputCost = get(newSpan.attributes, "gen_ai.usage.input_cost", 0); + const outputCost = get(newSpan.attributes, "gen_ai.usage.output_cost", 0); + const totalCost = + get(newSpan.attributes, "gen_ai.usage.input_cost", 0) + get(newSpan.attributes, "gen_ai.usage.output_cost", 0); + setTrace((trace) => { if (!trace) return trace; @@ -176,12 +186,12 @@ export const onRealtimeUpdateSpans = : newSpan.startTime; newTrace.endTime = new Date(newTrace.endTime).getTime() > new Date(newSpan.endTime).getTime() ? newTrace.endTime : newSpan.endTime; - newTrace.totalTokens += newSpan.totalTokens || (newSpan.inputTokens ?? 0) + (newSpan.outputTokens ?? 0); - newTrace.inputTokens += newSpan.inputTokens ?? 0; - newTrace.outputTokens += newSpan.outputTokens ?? 0; - newTrace.inputCost += newSpan.inputCost ?? 0; - newTrace.outputCost += newSpan.outputCost ?? 0; - newTrace.totalCost += newSpan.totalCost || (newSpan.inputCost ?? 0) + (newSpan.outputCost ?? 0); + newTrace.totalTokens += totalTokens; + newTrace.inputTokens += inputTokens; + newTrace.outputTokens += outputTokens; + newTrace.inputCost += inputCost; + newTrace.outputCost += outputCost; + newTrace.totalCost += totalCost; return newTrace; }); @@ -192,6 +202,12 @@ export const onRealtimeUpdateSpans = // Always replace existing span, regardless of pending status newSpans[index] = { ...newSpan, + totalTokens, + inputTokens, + outputTokens, + inputCost, + outputCost, + totalCost, collapsed: newSpans[index].collapsed || false, events: [], path: "", @@ -199,6 +215,12 @@ export const onRealtimeUpdateSpans = } else { newSpans.push({ ...newSpan, + totalTokens, + inputTokens, + outputTokens, + inputCost, + outputCost, + totalCost, collapsed: false, events: [], path: "", diff --git a/frontend/lib/traces/types.ts b/frontend/lib/traces/types.ts index e1fa320fb..551fb6712 100644 --- a/frontend/lib/traces/types.ts +++ b/frontend/lib/traces/types.ts @@ -32,12 +32,6 @@ export type RealtimeSpan = { startTime: string; endTime: string; attributes: Record; - inputTokens: number; - outputTokens: number; - totalTokens: number; - inputCost: number; - outputCost: number; - totalCost: number; status?: string; projectId: string; createdAt: string; From ef0c432cb3641be7f9aa91eff1877df622587d32 Mon Sep 17 00:00:00 2001 From: Olzhas Nurpeisov Date: Tue, 9 Dec 2025 21:34:30 +0000 Subject: [PATCH 20/21] feat: add model --- frontend/components/traces/trace-view/utils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/components/traces/trace-view/utils.ts b/frontend/components/traces/trace-view/utils.ts index 776db5c19..4f6aa3e86 100644 --- a/frontend/components/traces/trace-view/utils.ts +++ b/frontend/components/traces/trace-view/utils.ts @@ -174,6 +174,7 @@ export const onRealtimeUpdateSpans = const outputCost = get(newSpan.attributes, "gen_ai.usage.output_cost", 0); const totalCost = get(newSpan.attributes, "gen_ai.usage.input_cost", 0) + get(newSpan.attributes, "gen_ai.usage.output_cost", 0); + const model = get(newSpan.attributes, "gen_ai.response.model") ?? get(newSpan.attributes, "gen_ai.request.model"); setTrace((trace) => { if (!trace) return trace; @@ -208,6 +209,7 @@ export const onRealtimeUpdateSpans = inputCost, outputCost, totalCost, + model, collapsed: newSpans[index].collapsed || false, events: [], path: "", @@ -221,6 +223,7 @@ export const onRealtimeUpdateSpans = inputCost, outputCost, totalCost, + model, collapsed: false, events: [], path: "", From 947f19bac7bef5a994a30910b90bfb55b8270f37 Mon Sep 17 00:00:00 2001 From: Olzhas Nurpeisov Date: Tue, 9 Dec 2025 21:53:34 +0000 Subject: [PATCH 21/21] small fix --- frontend/components/traces/trace-view/utils.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/components/traces/trace-view/utils.ts b/frontend/components/traces/trace-view/utils.ts index 4f6aa3e86..47ef14cc5 100644 --- a/frontend/components/traces/trace-view/utils.ts +++ b/frontend/components/traces/trace-view/utils.ts @@ -165,15 +165,12 @@ export const onRealtimeUpdateSpans = setShowBrowserSession(true); } - const totalTokens = - get(newSpan.attributes, "gen_ai.usage.input_tokens", 0) + - get(newSpan.attributes, "gen_ai.usage.output_tokens", 0); const inputTokens = get(newSpan.attributes, "gen_ai.usage.input_tokens", 0); const outputTokens = get(newSpan.attributes, "gen_ai.usage.output_tokens", 0); + const totalTokens = inputTokens + outputTokens; const inputCost = get(newSpan.attributes, "gen_ai.usage.input_cost", 0); const outputCost = get(newSpan.attributes, "gen_ai.usage.output_cost", 0); - const totalCost = - get(newSpan.attributes, "gen_ai.usage.input_cost", 0) + get(newSpan.attributes, "gen_ai.usage.output_cost", 0); + const totalCost = get(newSpan.attributes, "gen_ai.usage.cost", inputCost + outputCost); const model = get(newSpan.attributes, "gen_ai.response.model") ?? get(newSpan.attributes, "gen_ai.request.model"); setTrace((trace) => {