Skip to content

Commit f626dd9

Browse files
show top span name for pending traces (#945)
* show top span name for pending traces * small fixes * fix bugbot comment * infer top span name in the backend * small fixes * Update frontend/components/traces/traces-table/index.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
1 parent e8d435d commit f626dd9

File tree

6 files changed

+93
-78
lines changed

6 files changed

+93
-78
lines changed

app-server/src/ch/traces.rs

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
use std::collections::HashSet;
2+
13
use chrono::{DateTime, Utc};
24
use clickhouse::Row;
35
use serde::{Deserialize, Serialize};
46
use uuid::Uuid;
57

6-
use super::spans::CHSpan;
7-
use super::utils::{chrono_to_nanoseconds, nanoseconds_to_chrono};
8-
use crate::db::spans::SpanType;
8+
use super::utils::chrono_to_nanoseconds;
9+
use crate::db::spans::{Span, SpanType};
910
use crate::db::trace::Trace;
11+
use crate::traces::spans::SpanUsage;
1012

1113
#[derive(Debug, Clone, Serialize, Deserialize, Row)]
1214
pub struct CHTrace {
@@ -92,7 +94,7 @@ pub struct TraceAggregation {
9294
pub user_id: Option<String>,
9395
pub status: Option<String>,
9496
pub metadata: Option<serde_json::Value>,
95-
pub tags: Vec<String>,
97+
pub tags: HashSet<String>,
9698
pub num_spans: i32,
9799
pub top_span_id: Option<Uuid>,
98100
pub top_span_name: Option<String>,
@@ -101,13 +103,13 @@ pub struct TraceAggregation {
101103
}
102104

103105
impl TraceAggregation {
104-
/// Aggregate statistics from a batch of CHSpans grouped by trace_id
105-
pub fn from_ch_spans(spans: &[CHSpan]) -> Vec<Self> {
106+
/// Aggregate statistics from a batch of Spans and SpanUsage grouped by trace_id
107+
pub fn from_spans(spans: &[Span], span_usage_vec: &[SpanUsage]) -> Vec<Self> {
106108
use std::collections::HashMap;
107109

108110
let mut trace_aggregations: HashMap<Uuid, TraceAggregation> = HashMap::new();
109111

110-
for span in spans {
112+
for (span, span_usage) in spans.iter().zip(span_usage_vec.iter()) {
111113
let entry =
112114
trace_aggregations
113115
.entry(span.trace_id)
@@ -126,70 +128,86 @@ impl TraceAggregation {
126128
user_id: None,
127129
status: None,
128130
metadata: None,
129-
tags: Vec::new(),
131+
tags: HashSet::new(),
130132
num_spans: 0,
131133
top_span_id: None,
132134
top_span_name: None,
133135
top_span_type: 0,
134136
trace_type: 0,
135137
});
136138

137-
// Convert nanoseconds to DateTime<Utc>
138-
let start_dt = nanoseconds_to_chrono(span.start_time);
139+
// Aggregate min start_time
139140
entry.start_time = Some(match entry.start_time {
140-
Some(existing) => existing.min(start_dt),
141-
None => start_dt,
141+
Some(existing) => existing.min(span.start_time),
142+
None => span.start_time,
142143
});
143144

144145
// Aggregate max end_time
145-
let end_dt = nanoseconds_to_chrono(span.end_time);
146146
entry.end_time = Some(match entry.end_time {
147-
Some(existing) => existing.max(end_dt),
148-
None => end_dt,
147+
Some(existing) => existing.max(span.end_time),
148+
None => span.end_time,
149149
});
150150

151-
// Sum tokens and costs
152-
entry.input_tokens += span.input_tokens;
153-
entry.output_tokens += span.output_tokens;
154-
entry.total_tokens += span.total_tokens;
155-
entry.input_cost += span.input_cost;
156-
entry.output_cost += span.output_cost;
157-
entry.total_cost += span.total_cost;
151+
// Sum tokens and costs from SpanUsage
152+
entry.input_tokens += span_usage.input_tokens;
153+
entry.output_tokens += span_usage.output_tokens;
154+
entry.total_tokens += span_usage.total_tokens;
155+
entry.input_cost += span_usage.input_cost;
156+
entry.output_cost += span_usage.output_cost;
157+
entry.total_cost += span_usage.total_cost;
158158

159159
// Use "any" strategy for these fields (take first non-empty value)
160-
if entry.session_id.is_none() && !span.session_id.is_empty() {
161-
entry.session_id = Some(span.session_id.clone());
160+
if entry.session_id.is_none() {
161+
if let Some(session_id) = span.attributes.session_id() {
162+
if !session_id.is_empty() {
163+
entry.session_id = Some(session_id);
164+
}
165+
}
162166
}
163-
if entry.user_id.is_none() && !span.user_id.is_empty() {
164-
entry.user_id = Some(span.user_id.clone());
167+
if entry.user_id.is_none() {
168+
if let Some(user_id) = span.attributes.user_id() {
169+
if !user_id.is_empty() {
170+
entry.user_id = Some(user_id);
171+
}
172+
}
165173
}
166-
if entry.status.is_none() && !span.status.is_empty() {
167-
entry.status = Some(span.status.clone());
174+
if entry.status.is_none() {
175+
if let Some(status) = &span.status {
176+
if !status.is_empty() {
177+
entry.status = Some(status.clone());
178+
}
179+
}
168180
}
169-
if entry.metadata.is_none() && !span.trace_metadata.is_empty() {
170-
if let Ok(parsed) = serde_json::from_str(&span.trace_metadata) {
171-
entry.metadata = Some(parsed);
181+
if entry.metadata.is_none() {
182+
if let Some(metadata) = span.attributes.metadata() {
183+
if let Ok(metadata_value) = serde_json::to_value(&metadata) {
184+
entry.metadata = Some(metadata_value);
185+
}
172186
}
173187
}
174-
if span.trace_type != 0 {
175-
entry.trace_type = span.trace_type;
188+
if let Some(trace_type) = span.attributes.trace_type() {
189+
entry.trace_type = trace_type.clone().into();
176190
}
177191

178-
if SpanType::from(span.span_type) == SpanType::EVALUATION {
192+
if span.span_type == SpanType::EVALUATION {
179193
entry.trace_type = 1;
180194
}
181195

182-
if span.parent_span_id == Uuid::nil() {
196+
if span.parent_span_id.is_none() {
183197
entry.top_span_id = Some(span.span_id);
184198
entry.top_span_name = Some(span.name.clone());
185-
entry.top_span_type = span.span_type as u8;
199+
entry.top_span_type = span.span_type.clone().into();
200+
}
201+
202+
if entry.top_span_name.is_none() {
203+
let path = span.attributes.path().unwrap_or_default();
204+
path.first()
205+
.map(|name| entry.top_span_name = Some(name.clone()));
186206
}
187207

188208
// Collect unique tags
189-
for tag in &span.tags_array {
190-
if !entry.tags.contains(tag) {
191-
entry.tags.push(tag.clone());
192-
}
209+
for tag in span.attributes.tags() {
210+
entry.tags.insert(tag);
193211
}
194212

195213
entry.num_spans += 1;

app-server/src/ch/utils.rs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use chrono::{DateTime, TimeZone, Utc};
1+
use chrono::{DateTime, Utc};
22

33
pub fn chrono_to_nanoseconds(chrono_dt: DateTime<Utc>) -> i64 {
44
let timestamp = chrono_dt.timestamp(); // seconds since the Unix epoch
@@ -9,11 +9,3 @@ pub fn chrono_to_nanoseconds(chrono_dt: DateTime<Utc>) -> i64 {
99

1010
total_nanos
1111
}
12-
13-
pub fn nanoseconds_to_chrono(nanos: i64) -> DateTime<Utc> {
14-
let seconds = nanos / 1_000_000_000;
15-
let nanos_remainder = (nanos % 1_000_000_000) as u32;
16-
Utc.timestamp_opt(seconds, nanos_remainder)
17-
.single()
18-
.unwrap_or(Utc.timestamp_opt(0, 0).single().unwrap())
19-
}

app-server/src/db/trace.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ pub async fn upsert_trace_statistics_batch(
207207
.bind(agg.output_cost)
208208
.bind(agg.total_cost)
209209
.bind(&agg.status)
210-
.bind(&agg.tags)
210+
.bind(&agg.tags.iter().collect::<Vec<_>>())
211211
.bind(agg.num_spans)
212212
.fetch_one(pool)
213213
.await?;

app-server/src/traces/consumer.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ async fn process_batch(
309309
}
310310

311311
// Process trace aggregations and update trace statistics
312-
let trace_aggregations = TraceAggregation::from_ch_spans(&ch_spans);
312+
let trace_aggregations = TraceAggregation::from_spans(&spans, &span_usage_vec);
313313
if !trace_aggregations.is_empty() {
314314
// Upsert trace statistics in PostgreSQL
315315
match upsert_trace_statistics_batch(&db.pool, &trace_aggregations).await {

frontend/components/traces/traces-table/columns.tsx

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { ColumnDef } from "@tanstack/react-table";
22
import { capitalize } from "lodash";
3-
import { X } from "lucide-react";
43

54
import ClientTimestampFormatter from "@/components/client-timestamp-formatter";
6-
import { NoSpanTooltip } from "@/components/traces/no-span-tooltip.tsx";
75
import SpanTypeIcon, { createSpanTypeIcon } from "@/components/traces/span-type-icon";
86
import { Badge } from "@/components/ui/badge.tsx";
97
import { ColumnFilter } from "@/components/ui/datatable-filter/utils";
@@ -75,32 +73,33 @@ export const columns: ColumnDef<TraceRow, any>[] = [
7573
accessorKey: "topSpanType",
7674
header: "Top level span",
7775
id: "top_span_type",
78-
cell: (row) => (
79-
<div className="cursor-pointer flex gap-2 items-center">
80-
<div className="flex items-center gap-2">
81-
{row.row.original.topSpanName ? (
82-
<SpanTypeIcon className="z-10" spanType={row.getValue()} />
83-
) : isStringDateOld(row.row.original.endTime) ? (
84-
<NoSpanTooltip>
85-
<div className="flex items-center gap-2 rounded-sm bg-secondary p-1">
86-
<X className="w-4 h-4" />
87-
</div>
88-
</NoSpanTooltip>
76+
cell: (row) => {
77+
const topSpanId = row.row.original.topSpanId;
78+
const hasTopSpan = !!topSpanId && topSpanId !== "00000000-0000-0000-0000-000000000000";
79+
const isOld = isStringDateOld(row.row.original.endTime);
80+
const shouldAnimate = !hasTopSpan && !isOld;
81+
82+
return (
83+
<div className="cursor-pointer flex gap-2 items-center">
84+
<div className="flex items-center gap-2">
85+
{hasTopSpan ? (
86+
<SpanTypeIcon className="z-10" spanType={row.getValue()} />
87+
) : (
88+
<SpanTypeIcon className={cn("z-10", shouldAnimate && "animate-pulse")} spanType={SpanType.DEFAULT} />
89+
)}
90+
</div>
91+
{hasTopSpan ? (
92+
<div className="text-sm truncate">{row.row.original.topSpanName}</div>
93+
) : row.row.original.topSpanName ? (
94+
<div className={cn("text-sm truncate text-muted-foreground", shouldAnimate && "animate-pulse")}>
95+
{row.row.original.topSpanName}
96+
</div>
8997
) : (
90-
<Skeleton className="w-6 h-6 bg-secondary rounded-sm" />
98+
<Skeleton className="w-14 h-4 text-secondary-foreground py-0.5 bg-secondary rounded-full text-sm" />
9199
)}
92100
</div>
93-
{row.row.original.topSpanName ? (
94-
<div className="text-sm truncate">{row.row.original.topSpanName}</div>
95-
) : isStringDateOld(row.row.original.endTime) ? (
96-
<NoSpanTooltip>
97-
<div className="flex text-muted-foreground">None</div>
98-
</NoSpanTooltip>
99-
) : (
100-
<Skeleton className="w-14 h-4 text-secondary-foreground py-0.5 bg-secondary rounded-full text-sm" />
101-
)}
102-
</div>
103-
),
101+
);
102+
},
104103
size: 150,
105104
},
106105
{

frontend/components/traces/traces-table/index.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ export default function TracesTable() {
142142

143143
const isTopSpan = spanData.parentSpanId === null;
144144

145+
// Extract inferred top span name from lmnr.span.path (first element)
146+
const spanPath = spanData.attributes?.["lmnr.span.path"];
147+
const inferredTopSpanName = Array.isArray(spanPath) && spanPath.length > 0 ? spanPath[0] : undefined;
148+
145149
if (existingTraceIndex !== -1) {
146150
// Update existing trace
147151
const newTraces = [...currentTraces];
@@ -153,6 +157,8 @@ export default function TracesTable() {
153157
const spanInputCost = spanData.attributes?.["gen_ai.usage.input_cost"] || 0;
154158
const spanOutputCost = spanData.attributes?.["gen_ai.usage.output_cost"] || 0;
155159

160+
const newTopSpanName = isTopSpan ? spanData.name : (inferredTopSpanName ?? existingTrace.topSpanName);
161+
156162
newTraces[existingTraceIndex] = {
157163
...existingTrace,
158164
startTime:
@@ -169,7 +175,7 @@ export default function TracesTable() {
169175
inputCost: existingTrace.inputCost + spanInputCost,
170176
outputCost: existingTrace.outputCost + spanOutputCost,
171177
totalCost: existingTrace.totalCost + spanInputCost + spanOutputCost,
172-
topSpanName: isTopSpan ? spanData.name : existingTrace.topSpanName,
178+
topSpanName: newTopSpanName,
173179
topSpanId: isTopSpan ? spanData.spanId : existingTrace.topSpanId,
174180
topSpanType: isTopSpan ? spanData.spanType : existingTrace.topSpanType,
175181
userId: spanData.attributes?.["lmnr.association.properties.user_id"] || existingTrace.userId,
@@ -200,7 +206,7 @@ export default function TracesTable() {
200206
metadata: spanData.attributes?.["metadata"] || null,
201207
topSpanId: isTopSpan ? spanData.spanId : null,
202208
traceType: "DEFAULT",
203-
topSpanName: isTopSpan ? spanData.name : null,
209+
topSpanName: isTopSpan ? spanData.name : inferredTopSpanName,
204210
topSpanType: isTopSpan ? spanData.spanType : null,
205211
status: spanData.status,
206212
userId: spanData.attributes?.["lmnr.association.properties.user_id"] || null,

0 commit comments

Comments
 (0)