Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
66 changes: 43 additions & 23 deletions frontend/components/traces/trace-view/span-card.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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 { 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";

Expand Down Expand Up @@ -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]);

Expand All @@ -46,16 +48,17 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth,
const isSelected = useMemo(() => selectedSpan?.spanId === span.spanId, [selectedSpan?.spanId, span.spanId]);

return (
<div
className="text-md flex w-full flex-col"
ref={ref}
style={{
paddingLeft: depth * 24,
}}
>
<div className="flex flex-col">
<SpanDisplayTooltip isLLM={span.spanType === "LLM"} name={span.name}>
<div
className="text-md flex w-full flex-col"
ref={ref}
style={{
paddingLeft: depth * 24,
}}
>
<div
className="flex w-full items-center space-x-2 cursor-pointer group relative pl-2"
className="flex gap-x-1 w-full text-md items-center cursor-pointer group relative pl-2"
ref={ref}
style={{
height: ROW_HEIGHT,
}}
Expand All @@ -78,14 +81,9 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth,
status={span.status}
className={cn("min-w-[22px]", { "text-muted-foreground bg-muted ": span.pending })}
/>
<div
className={cn(
"text-ellipsis overflow-hidden whitespace-nowrap text-base truncate",
span.pending && "text-muted-foreground"
)}
>
{span.name}
</div>

{getSpanDisplayName(span)}

{span.pending ? (
isStringDateOld(span.startTime) ? (
<NoSpanTooltip>
Expand All @@ -97,9 +95,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 Down Expand Up @@ -138,6 +158,6 @@ export function SpanCard({ span, yOffset, parentY, onSpanSelect, containerWidth,
<div className="grow" />
</div>
</div>
</div>
</SpanDisplayTooltip>
);
}
125 changes: 70 additions & 55 deletions frontend/components/traces/trace-view/timeline-element.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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 { 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";

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

const llmMetrics = getLLMMetrics(span.span);

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

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

const SpanText = useMemo(() => {
const spanTextElement = useMemo(() => {
const textContent = (
<>
{span.span.name}{" "}
<div className={"flex items-center gap-1.5"}>
<div className={"overflow-hidden text-ellipsis whitespace-nowrap max-w-20 text-nowrap"}>
{getSpanDisplayName(span.span)}
</div>
<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 +110,7 @@ const TimelineElement = ({
{...commonProps}
className={cn(commonProps.className, "absolute text-right")}
style={{
right: `calc(100% - ${span.left}% + 16px)`,
right: `calc(100% - ${span.left}% + 20px)`,
maxWidth: "250px",
}}
>
Expand All @@ -104,62 +124,57 @@ 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,
]);

return (
<div
key={virtualRow.index}
data-index={virtualRow.index}
onClick={handleSpanSelect}
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 && <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>
)}
<SpanDisplayTooltip isLLM={span.span.spanType === "LLM"} name={span.span.name}>
<div
ref={blockRef}
className="rounded relative z-20 flex items-center"
key={virtualRow.index}
data-index={virtualRow.index}
onClick={handleSpanSelect}
className={cn(
"absolute w-full h-8 flex items-center px-4 hover:bg-muted cursor-pointer transition duration-200"
)}
style={{
backgroundColor:
span.span.status === "error" ? "rgba(204, 51, 51, 1)" : SPAN_TYPE_TO_COLOR[span.span.spanType],
marginLeft: span.left + "%",
width: `max(${span.width}%, 2px)`,
height: 24,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{span.events.map((event) => (
<div
key={event.id}
className="absolute bg-orange-400 w-1 rounded"
style={{
left: event.left + "%",
top: 0,
height: 24,
}}
/>
))}
{textPosition === "inside" && SpanText}
{isSelected && <div className="h-full w-full absolute left-0 bg-primary/25" />}
<div
ref={blockRef}
className="rounded relative z-20 flex items-center"
style={{
backgroundColor:
span.span.status === "error" ? "rgba(204, 51, 51, 1)" : SPAN_TYPE_TO_COLOR[span.span.spanType],
marginLeft: span.left + "%",
width: `max(${span.width}%, 2px)`,
height: 24,
}}
>
{span.events.map((event) => (
<div
key={event.id}
className="absolute bg-orange-400 w-1 rounded"
style={{
left: event.left + "%",
top: 0,
height: 24,
}}
/>
))}
{textPosition === "inside" && spanTextElement}
</div>
{textPosition === "outside" && spanTextElement}
</div>
{textPosition === "outside" && SpanText}
</div>
</SpanDisplayTooltip>
);
};

Expand Down
5 changes: 5 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,11 @@ export type TraceViewSpan = {
model?: string;
pending?: boolean;
collapsed: boolean;
aggregatedMetrics?: {
totalCost: number;
totalTokens: number;
hasLLMDescendants: boolean;
};
};

export type TraceViewTrace = {
Expand Down
24 changes: 24 additions & 0 deletions frontend/components/traces/trace-view/ui/span-display-tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<TooltipProvider disableHoverableContent={!isLLM}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="top"
className={cn("text-sm p-2 text-center border whitespace-pre-wrap text-secondary-foreground")}
>
{name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
41 changes: 41 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,44 @@ 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 ? modelName : span.name;
};


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;

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"];

if (costValue == null || inputTokens == null || outputTokens == null) return null;

const cost = costValue.toFixed(3);
const totalTokens = inputTokens + outputTokens;

return { cost, totalTokens, isAggregated: false };
};
6 changes: 4 additions & 2 deletions frontend/lib/actions/spans/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -304,7 +304,9 @@ export async function getTraceSpans(input: z.infer<typeof GetTraceSpansSchema>):
: new Map<string, string | undefined>();

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<typeof DeleteSpansSchema>) {
Expand Down
Loading