Skip to content

Commit 48e8396

Browse files
authored
Merge pull request #625 from lmnr-ai/dev
langgraph, playground enhancements, tracing enhancements, evals UI enhancements
2 parents b3a6f46 + d49c152 commit 48e8396

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+3369
-1340
lines changed

app-server/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ tonic = {version = "0.13", features = ["gzip"]}
5656
url = "2.5.4"
5757
uuid = {version = "1.16.0", features = ["v4", "fast-rng", "macro-diagnostics", "serde"]}
5858
reqwest = { version = "0.12.15", features = ["json"] }
59+
itertools = "0.14.0"
5960

6061
[build-dependencies]
6162
tonic-build = "0.13"

app-server/src/evaluations/utils.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub struct EvaluationDatapointResult {
2424
#[serde(default)]
2525
pub target: Value,
2626
#[serde(default)]
27-
pub metadata: HashMap<String, Value>,
27+
pub metadata: Option<HashMap<String, Value>>,
2828
#[serde(default)]
2929
pub executor_output: Option<Value>,
3030
#[serde(default)]
@@ -62,7 +62,7 @@ pub fn get_columns_from_points(points: &Vec<EvaluationDatapointResult>) -> Datap
6262

6363
let metadatas = points
6464
.iter()
65-
.map(|point| point.metadata.clone())
65+
.map(|point| point.metadata.clone().unwrap_or_default())
6666
.collect::<Vec<_>>();
6767

6868
let executor_outputs = points

app-server/src/language_model/costs/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
use std::{collections::HashMap, sync::Arc};
22

33
use crate::{
4-
cache::{keys::LLM_PRICES_CACHE_KEY, Cache, CacheTrait},
4+
cache::{Cache, CacheTrait, keys::LLM_PRICES_CACHE_KEY},
55
db::{
6-
prices::{get_price, DBPriceEntry},
76
DB,
7+
prices::{DBPriceEntry, get_price},
88
},
99
traces::spans::InputTokens,
1010
};
@@ -47,7 +47,7 @@ pub async fn estimate_output_cost(
4747
let cache_res = cache.get::<LLMPriceEntry>(&cache_key).await.ok()?;
4848

4949
let price_per_million_tokens = match cache_res {
50-
Some(price) => price.input_price_per_million,
50+
Some(price) => price.output_price_per_million,
5151
None => {
5252
let price = get_price(&db.pool, provider, model).await.ok()?;
5353
let price = LLMPriceEntry::from(price);

app-server/src/traces/mod.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::sync::Arc;
22

3+
use itertools::Itertools;
34
use utils::prepare_span_for_recording;
45
use uuid::Uuid;
56

@@ -50,6 +51,28 @@ pub async fn process_spans_and_events(
5051
let span_usage =
5152
get_llm_usage_for_span(&mut span.get_attributes(), db.clone(), cache.clone()).await;
5253

54+
let mut has_seen_first_token = false;
55+
56+
// OpenLLMetry auto-instrumentation sends this event for every chunk
57+
// While this is helpful to get TTFT, we don't want to store excessive,
58+
// so we only keep the first one.
59+
let events = events
60+
.into_iter()
61+
.sorted_by(|a, b| a.timestamp.cmp(&b.timestamp))
62+
.filter(|event| {
63+
if event.name == "llm.content.completion.chunk" {
64+
if !has_seen_first_token {
65+
has_seen_first_token = true;
66+
true
67+
} else {
68+
false
69+
}
70+
} else {
71+
true
72+
}
73+
})
74+
.collect();
75+
5376
let trace_attributes = prepare_span_for_recording(span, &span_usage, &events);
5477

5578
if let Some(span_path) = span.get_attributes().path() {

app-server/src/traces/producer.rs

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,7 @@ pub async fn push_spans_to_queue(
3131
let events = otel_span
3232
.events
3333
.into_iter()
34-
.filter_map(|event| {
35-
// OpenLLMetry auto-instrumentation sends this event for every chunk
36-
// While this is helpful to get TTFT, we don't want to store excessive
37-
// events
38-
if event.name == "llm.content.completion.chunk" {
39-
None
40-
} else {
41-
Some(Event::from_otel(event, span.span_id, project_id))
42-
}
43-
})
34+
.map(|event| Event::from_otel(event, span.span_id, project_id))
4435
.collect::<Vec<Event>>();
4536

4637
if !span.should_save() {

app-server/src/traces/spans.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -935,8 +935,12 @@ fn output_message_from_completion_content(
935935
}
936936
} else {
937937
let mut out_vec = if let Some(Value::String(s)) = msg_content {
938-
let text_block = ChatMessageContentPart::Text(ChatMessageText { text: s.clone() });
939-
vec![serde_json::to_value(text_block).unwrap()]
938+
if s.is_empty() {
939+
vec![]
940+
} else {
941+
let text_block = ChatMessageContentPart::Text(ChatMessageText { text: s.clone() });
942+
vec![serde_json::to_value(text_block).unwrap()]
943+
}
940944
} else {
941945
vec![]
942946
};

frontend/app/api/completion/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { openai } from "@ai-sdk/openai";
2+
import { generateText } from "ai";
3+
4+
export async function POST(req: Request) {
5+
const { prompt }: { prompt: string } = await req.json();
6+
7+
if (!process.env.OPENAI_API_KEY) {
8+
return Response.json({ error: "OPENAI_API_KEY is not set" }, { status: 500 });
9+
}
10+
11+
const { text } = await generateText({
12+
model: openai("gpt-4.1-nano"),
13+
system: "You are a helpful assistant.",
14+
prompt,
15+
});
16+
17+
return Response.json({ text });
18+
}

frontend/app/api/projects/[projectId]/evaluations/[evaluationId]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { and, asc, eq, inArray, sql } from "drizzle-orm";
22
import { NextRequest } from "next/server";
33

4+
import { DatatableFilter } from "@/components/ui/datatable-filter/utils";
45
import { searchSpans } from "@/lib/clickhouse/spans";
56
import { SpanSearchType } from "@/lib/clickhouse/types";
67
import { db } from "@/lib/db/drizzle";
@@ -10,7 +11,6 @@ import {
1011
EvaluationScoreDistributionBucket,
1112
EvaluationScoreStatistics,
1213
} from "@/lib/evaluation/types";
13-
import { DatatableFilter } from "@/lib/types";
1414

1515
// Constants for distribution calculation
1616
const DEFAULT_LOWER_BOUND = 0.0;

frontend/app/api/projects/[projectId]/traces/[traceId]/spans/route.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { and, asc, eq, inArray, sql } from "drizzle-orm";
1+
import { and, asc, eq, inArray, not, sql } from "drizzle-orm";
2+
import { partition } from "lodash";
23
import { NextRequest, NextResponse } from "next/server";
34

45
import { searchSpans } from "@/lib/clickhouse/spans";
56
import { SpanSearchType } from "@/lib/clickhouse/types";
67
import { TimeRange } from "@/lib/clickhouse/utils";
78
import { db } from "@/lib/db/drizzle";
8-
import { events, spans } from "@/lib/db/migrations/schema";
9+
import { events, labelClasses, labels, spans } from "@/lib/db/migrations/schema";
10+
import { FilterDef, filtersToSql } from "@/lib/db/modifiers";
11+
912
export async function GET(
1013
req: NextRequest,
1114
props: { params: Promise<{ projectId: string; traceId: string }> }
@@ -15,6 +18,63 @@ export async function GET(
1518
const traceId = params.traceId;
1619
const searchQuery = req.nextUrl.searchParams.get("search");
1720
const searchType = req.nextUrl.searchParams.getAll("searchIn");
21+
22+
const urlParamFilters = (() => {
23+
try {
24+
const rawFilters = req.nextUrl.searchParams.getAll("filter").map((f) => JSON.parse(f) as FilterDef);
25+
return Array.isArray(rawFilters) ? rawFilters : [];
26+
} catch {
27+
return [];
28+
}
29+
})();
30+
31+
const [filters, statusFilters] = partition(urlParamFilters, (f) => f.column !== "status");
32+
const [otherFilters, tagsFilters] = partition(filters, (f) => f.column !== "tags");
33+
34+
const statusSqlFilters = statusFilters.map((filter) => {
35+
if (filter.value === "success") {
36+
return filter.operator === "eq" ? sql`status IS NULL` : sql`status IS NOT NULL`;
37+
} else if (filter.value === "error") {
38+
return filter.operator === "eq" ? sql`status = 'error'` : sql`status != 'error' OR status IS NULL`;
39+
}
40+
return sql`1=1`;
41+
});
42+
43+
const tagsSqlFilters = tagsFilters.map((filter) => {
44+
const name = filter.value;
45+
const inArrayFilter = inArray(
46+
spans.spanId,
47+
db
48+
.select({ span_id: spans.spanId })
49+
.from(spans)
50+
.innerJoin(labels, eq(spans.spanId, labels.spanId))
51+
.innerJoin(labelClasses, eq(labels.classId, labelClasses.id))
52+
.where(and(eq(labelClasses.name, name)))
53+
);
54+
return filter.operator === "eq" ? inArrayFilter : not(inArrayFilter);
55+
});
56+
57+
const processedFilters = otherFilters.map((filter) => {
58+
if (filter.column === "path") {
59+
filter.column = "(attributes ->> 'lmnr.span.path')";
60+
} else if (filter.column === "tokens") {
61+
filter.column = "(attributes ->> 'llm.usage.total_tokens')::int8";
62+
} else if (filter.column === "cost") {
63+
filter.column = "(attributes ->> 'gen_ai.usage.cost')::float8";
64+
} else if (filter.column === "model") {
65+
filter.column = "(attributes ->> 'gen_ai.request.model')";
66+
}
67+
return filter;
68+
});
69+
70+
const sqlFilters = filtersToSql(
71+
processedFilters,
72+
[new RegExp(/^\(attributes\s*->>\s*'[a-zA-Z_\.]+'\)(?:::int8|::float8)?$/)],
73+
{
74+
latency: sql<number>`EXTRACT(EPOCH FROM (end_time - start_time))`,
75+
}
76+
);
77+
1878
let searchSpanIds: string[] = [];
1979
if (searchQuery) {
2080
const timeRange = { pastHours: "all" } as TimeRange;
@@ -32,7 +92,7 @@ export async function GET(
3292
const spanEventsQuery = db.$with("span_events").as(
3393
db
3494
.select({
35-
spanId: events.spanId,
95+
eventSpanId: sql`events.span_id`.as("eventSpanId"),
3696
projectId: events.projectId,
3797
events: sql`jsonb_agg(jsonb_build_object(
3898
'id', events.id,
@@ -78,12 +138,15 @@ export async function GET(
78138
.from(spans)
79139
.leftJoin(
80140
spanEventsQuery,
81-
and(eq(spans.spanId, spanEventsQuery.spanId), eq(spans.projectId, spanEventsQuery.projectId))
141+
and(eq(spans.spanId, spanEventsQuery.eventSpanId), eq(spans.projectId, spanEventsQuery.projectId))
82142
)
83143
.where(
84144
and(
85145
eq(spans.traceId, traceId),
86146
eq(spans.projectId, projectId),
147+
...sqlFilters,
148+
...statusSqlFilters,
149+
...tagsSqlFilters,
87150
...(searchQuery !== null ? [inArray(spans.spanId, searchSpanIds)] : [])
88151
)
89152
)
@@ -94,7 +157,7 @@ export async function GET(
94157
return NextResponse.json(
95158
spanItems.map((span) => ({
96159
...span,
97-
parentSpanId: searchSpanIds.length > 0 ? null : span.parentSpanId,
160+
parentSpanId: searchSpanIds.length > 0 || urlParamFilters.length > 0 ? null : span.parentSpanId,
98161
}))
99162
);
100163
}

0 commit comments

Comments
 (0)