Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion frontend/components/shared/traces/trace-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ const PureTraceView = ({ trace, spans }: TraceViewProps) => {
spanPath: state.spanPath,
setSpanPath: state.setSpanPath,
}));

const hasLangGraph = useMemo(() => getHasLangGraph(), [getHasLangGraph]);
const llmSpanIds = useMemo(
() =>
Expand Down
29 changes: 23 additions & 6 deletions frontend/components/traces/trace-view/span-card.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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";
import {getLLMMetrics, getSpanDisplayName} from "@/components/traces/trace-view/utils.ts";
import { isStringDateOld } from "@/lib/traces/utils";
import { cn, getDurationString } from "@/lib/utils";

Expand All @@ -24,14 +25,15 @@ 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<HTMLDivElement>(null);

const { selectedSpan, spans, toggleCollapse } = useTraceViewStoreContext((state) => ({
selectedSpan: state.selectedSpan,
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]);

Expand Down Expand Up @@ -84,7 +86,7 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth,
span.pending && "text-muted-foreground"
)}
>
{span.name}
{isHovered && span.spanType === "LLM" ? span.name : getSpanDisplayName(span)}
</div>
{span.pending ? (
isStringDateOld(span.startTime) ? (
Expand All @@ -97,9 +99,22 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth,
<Skeleton className="w-10 h-4 text-secondary-foreground px-2 py-0.5 bg-secondary rounded-full text-xs" />
)
) : (
<div className="text-secondary-foreground px-2 py-0.5 bg-muted rounded-full text-xs">
{getDurationString(span.startTime, span.endTime)}
</div>
<>
<div className="text-secondary-foreground px-2 py-0.5 bg-muted rounded-full text-xs">
{getDurationString(span.startTime, span.endTime)}
</div>
{llmMetrics && (
<>
<div className={'text-secondary-foreground px-2 py-0.5 bg-muted rounded-full text-xs inline-flex items-center gap-1 ml-2'}>
<Coins className="min-w-3" size={12} />
{llmMetrics.totalTokens}
</div>
<div className={'text-secondary-foreground px-2 py-0.5 bg-muted rounded-full text-xs inline-flex items-center gap-1'} style={{marginLeft: 4}}>
<CircleDollarSign className="min-w-3" size={12} />
{llmMetrics.cost}</div>
</>
)}
</>
)}
<div
className="z-30 hover:bg-red-100/10 absolute transition-all"
Expand All @@ -113,6 +128,8 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth,
onSpanSelect?.(span);
}
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
/>
{isSelected && (
<div
Expand Down
34 changes: 27 additions & 7 deletions frontend/components/traces/trace-view/timeline-element.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { VirtualItem } from "@tanstack/react-virtual";
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 { SPAN_TYPE_TO_COLOR } from "@/lib/traces/utils";
import { cn, getDurationString } from "@/lib/utils";

Expand All @@ -25,6 +27,7 @@ const TimelineElement = ({
const textRef = useRef<HTMLSpanElement>(null);
const blockRef = useRef<HTMLDivElement>(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]);

Expand All @@ -34,6 +37,8 @@ const TimelineElement = ({
}
};

const llmMetrics = getLLMMetrics(span.span);

useLayoutEffect(() => {
if (!blockRef.current || !textRef.current) return;

Expand All @@ -56,11 +61,24 @@ const TimelineElement = ({
};
}, [span.span.name, span.events.length, span.width]);

const SpanText = useMemo(() => {
const spanTextElement = useMemo(() => {
const displayName = isHovered && span.span.spanType === "LLM" ? span.span.name : getSpanDisplayName(span.span);

const textContent = (
<>
{span.span.name}{" "}
{displayName}{" "}
<span className="text-white/70">{getDurationString(span.span.startTime, span.span.endTime)}</span>
{llmMetrics && (
<>
<div className={'text-white/70 inline-flex items-center gap-1 ml-2'}>
<Coins className="min-w-3" size={12} />
{llmMetrics.totalTokens}
</div>
<div className={'text-white/70 inline-flex items-center gap-1 ml-4'} style={{marginLeft: 4}}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second metrics block uses both a Tailwind class (ml-4) and an inline style (marginLeft: 4). This redundancy may lead to inconsistent spacing. Consider using one consistent approach.

<CircleDollarSign className="min-w-3" size={12} />
{llmMetrics.cost}</div>
</>
)}
</>
);

Expand Down Expand Up @@ -104,13 +122,15 @@ const TimelineElement = ({
{textContent}
</span>
);
}, [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 (
<div
key={virtualRow.index}
data-index={virtualRow.index}
onClick={handleSpanSelect}
onMouseEnter={() => 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"
)}
Expand All @@ -123,14 +143,14 @@ const TimelineElement = ({
<span
title={span.span.name}
ref={textRef}
className="text-xs font-medium text-black truncate absolute"
className={"text-xs font-medium text-black truncate absolute"}
style={{
right: `calc(100% - ${span.left}% + 16px)`,
textAlign: "right",
maxWidth: "250px",
}}
>
{span.span.name}{" "}
{isHovered && span.span.spanType === "LLM" ? span.span.name : getSpanDisplayName(span.span)}{" "}
<span className="text-secondary-foreground">{getDurationString(span.span.startTime, span.span.endTime)}</span>
</span>
)}
Expand All @@ -156,9 +176,9 @@ const TimelineElement = ({
}}
/>
))}
{textPosition === "inside" && SpanText}
{textPosition === "inside" && spanTextElement}
</div>
{textPosition === "outside" && SpanText}
{textPosition === "outside" && spanTextElement}
</div>
);
};
Expand Down
20 changes: 20 additions & 0 deletions frontend/components/traces/trace-view/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,23 @@ export const findSpanToSelect = (
// Priority 3: First span as fallback
return spans?.[0];
};



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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure gen_ai.usage.cost exists and is a number before calling toFixed(3) to avoid runtime errors.

const totalTokens =
span.attributes["gen_ai.usage.input_tokens"] + span.attributes["gen_ai.usage.output_tokens"];

if (!cost || !totalTokens) return null;

return { cost, totalTokens };
};