Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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 @@ -86,7 +86,6 @@ const PureTraceView = ({ trace, spans }: TraceViewProps) => {
spanPath: state.spanPath,
setSpanPath: state.setSpanPath,
}));

const hasLangGraph = useMemo(() => getHasLangGraph(), [getHasLangGraph]);
const llmSpanIds = useMemo(
() =>
Expand Down
38 changes: 32 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,31 @@ 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"
}
>
<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"
}
>
<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 +137,8 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth,
onSpanSelect?.(span);
}
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
/>
{isSelected && (
<div
Expand Down
66 changes: 42 additions & 24 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,18 +61,35 @@ 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}{" "}
<div className={"flex items-center gap-1.5"}>
<span className={'text-nowrap'}>
{displayName}
</span>
<span className="text-white/70">{getDurationString(span.span.startTime, span.span.endTime)}</span>
</>
{llmMetrics && (
<>
<span className={"text-white/70 inline-flex items-center gap-1"}>
<Coins className="min-w-1" size={12} />
{llmMetrics.totalTokens}
</span>

<span className={"text-white/70 flex w-fit items-center gap-1"}>
<CircleDollarSign className="min-w-1" size={12} />
{llmMetrics.cost}
</span>
</>
)}
</div>
);

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") {
Expand All @@ -90,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",
}}
>
Expand All @@ -104,13 +126,24 @@ 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 @@ -119,21 +152,6 @@ const TimelineElement = ({
}}
>
{isSelected && <div className="h-full w-full absolute left-0 bg-primary/25" />}
{span.left > 50 && textPosition === "outside" && (
<span
title={span.span.name}
ref={textRef}
className="text-xs font-medium text-black truncate absolute"
style={{
right: `calc(100% - ${span.left}% + 16px)`,
textAlign: "right",
maxWidth: "250px",
}}
>
{span.span.name}{" "}
<span className="text-secondary-foreground">{getDurationString(span.span.startTime, span.span.endTime)}</span>
</span>
)}
<div
ref={blockRef}
className="rounded relative z-20 flex items-center"
Expand All @@ -156,9 +174,9 @@ const TimelineElement = ({
}}
/>
))}
{textPosition === "inside" && SpanText}
{textPosition === "inside" && spanTextElement}
</div>
{textPosition === "outside" && SpanText}
{textPosition === "outside" && spanTextElement}
</div>
);
};
Expand Down
96 changes: 92 additions & 4 deletions frontend/components/traces/trace-view/trace-view-store-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = <T extends TraceViewSpan>(spans: T[]): T[] =>
spans
.filter((span) => !span.parentSpanId)
Expand Down Expand Up @@ -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 };

Expand All @@ -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;
};

Expand Down Expand Up @@ -129,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,
Expand All @@ -148,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());
Expand Down
9 changes: 9 additions & 0 deletions frontend/components/traces/trace-view/trace-view-store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading