diff --git a/litellm-rust/crates/traces-clickhouse/query/list_traces.sql b/litellm-rust/crates/traces-clickhouse/query/list_traces.sql index e98fcf38c89..404929818b0 100644 --- a/litellm-rust/crates/traces-clickhouse/query/list_traces.sql +++ b/litellm-rust/crates/traces-clickhouse/query/list_traces.sql @@ -1,9 +1,12 @@ WITH - (Framework IN ('claude-code', 'claude-agent-sdk') AND (SpanAttributes['session.id'] != '' OR SpanAttributes['agent_id'] != '')) AS native, + coalesce(nullIf(SpanAttributes['query_source_safe'], ''), SpanAttributes['query_source']) AS native_source, + ((Framework = 'claude-code' OR (Framework = 'claude-agent-sdk' AND + (startsWith(SpanName, 'claude_code.') OR SpanAttributes['agent_id'] != '' OR native_source != ''))) + AND (SpanAttributes['session.id'] != '' OR SpanAttributes['agent_id'] != '')) AS native, if(SpanAttributes['session.id'] = '', TraceId, SpanAttributes['session.id']) AS native_session, (native AND SpanAttributes['agent_id'] != '') AS native_child, (native AND NOT native_child AND (SpanName = 'claude_code.interaction' OR - coalesce(nullIf(SpanAttributes['query_source_safe'], ''), SpanAttributes['query_source']) IN + native_source IN ('repl_main_thread', 'sdk', 'sdk_main_thread', 'generate_session_title', 'prompt_suggestion'))) AS native_root, page AS ( SELECT TraceId AS trace_id, @@ -45,11 +48,12 @@ LEFT JOIN ( arraySort(groupUniqArrayIf(toString(Framework), Framework != '')) AS frameworks, groupUniqArrayIf(native_session, native_root) AS root_sessions, groupUniqArrayIf((native_session, SpanId), native_root AND SpanName = 'claude_code.interaction') AS root_interactions, - uniqExactIf((native_session, SpanAttributes['agent_id']), native_child) - + length(root_sessions) AS native_count, + groupUniqArrayIf((native_session, SpanAttributes['agent_id']), native_child) AS child_identities, + groupUniqArrayIf((native_session, SpanAttributes['agent_id'], SpanId), native_child AND SpanName = 'claude_code.interaction') AS child_interactions, + length(child_identities) + length(root_sessions) AS native_count, native_count + uniqExactIf(if(AgentName = '', SpanName, AgentName), ObservationType = 'agent' AND NOT native) AS agent_count, uniqExactIf(SpanId, ObservationType = 'agent' AND NOT native) - + uniqExactIf((native_session, SpanAttributes['agent_id']), native_child) + + arraySum(arrayMap(child -> greatest(1, arrayCount(interaction -> (interaction.1, interaction.2) = child, child_interactions)), child_identities)) + arraySum(arrayMap(session -> greatest(1, arrayCount(interaction -> interaction.1 = session, root_interactions)), root_sessions)) AS resolved_invocations FROM otel_traces WHERE Timestamp >= (SELECT min(trace_start) FROM page) diff --git a/litellm-rust/crates/traces-clickhouse/tests/reads.rs b/litellm-rust/crates/traces-clickhouse/tests/reads.rs index 7a1e663ed68..5cceac47bfb 100644 --- a/litellm-rust/crates/traces-clickhouse/tests/reads.rs +++ b/litellm-rust/crates/traces-clickhouse/tests/reads.rs @@ -841,6 +841,20 @@ async fn native_list_counts_each_root_session_and_does_not_count_children_as_roo "agent", "claude_code.interaction", ), + ( + "child-resumed", + "session-a", + "child", + "agent", + "claude_code.interaction", + ), + ( + "legacy-sdk", + "sdk-session", + "", + "agent", + "ClaudeAgentSDK.query", + ), ]; insert_rows(client, &writer, DATABASE, InsertTable::OtelTraces, records.into_iter().map(|(id, session, actor, kind, name)| BTreeMap::from([ ("Timestamp".into(), json!(1_790_000_000_000_000_000_i64)), @@ -848,9 +862,9 @@ async fn native_list_counts_each_root_session_and_does_not_count_children_as_roo ("SpanId".into(), json!(id)), ("SpanName".into(), json!(name)), ("ObservationType".into(), json!(kind)), - ("Framework".into(), json!(framework)), + ("Framework".into(), json!(if id == "legacy-sdk" { "claude-agent-sdk" } else { framework })), ("AgentName".into(), json!("assistant")), - ("SpanAttributes".into(), json!({"session.id": session, "agent_id": actor, "query_source_safe": "sdk"})), + ("SpanAttributes".into(), json!({"session.id": session, "agent_id": actor, "query_source_safe": if id == "legacy-sdk" { "" } else { "sdk" }})), ("TeamId".into(), json!("team-a")), ("ApiKeyHash".into(), json!("key-a")), ])).collect()).await?; @@ -879,10 +893,10 @@ async fn native_list_counts_each_root_session_and_does_not_count_children_as_roo .get_trace(&store, &access, "mixed-sessions", &summary.trace_ref) .await? .ok_or("missing trace")?; - assert_eq!((summary.agent_count, summary.agent_invocations), (3, 4)); + assert_eq!((summary.agent_count, summary.agent_invocations), (4, 6)); assert_eq!( (detail.summary.agent_count, detail.summary.agent_invocations), - (3, 4) + (4, 6) ); Ok(()) } diff --git a/litellm-rust/crates/traces/src/resolve/actors.rs b/litellm-rust/crates/traces/src/resolve/actors.rs index 6b60308515a..40366ba3e35 100644 --- a/litellm-rust/crates/traces/src/resolve/actors.rs +++ b/litellm-rust/crates/traces/src/resolve/actors.rs @@ -1,11 +1,17 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; +use sha2::{Digest, Sha256}; + use crate::query::named::TraceSpansRow; use super::graph::Graph; pub(super) fn native(row: &TraceSpansRow) -> bool { - matches!(row.framework.as_str(), "claude-code" | "claude-agent-sdk") + (row.framework == "claude-code" + || (row.framework == "claude-agent-sdk" + && (row.name.starts_with("claude_code.") + || !row.native_agent_id.is_empty() + || !row.query_source.is_empty()))) && (!row.session_id.is_empty() || !row.native_agent_id.is_empty()) } @@ -15,7 +21,15 @@ fn identity(row: &TraceSpansRow, agent: &str) -> String { } else { &row.session_id }; - format!("claude-code:{}:{session}:{agent}", session.len()) + let mut digest = Sha256::new(); + digest.update((session.len() as u64).to_be_bytes()); + digest.update(session); + digest.update(agent); + format!("claude-code:{:x}", digest.finalize()) +} + +fn label(value: &str) -> String { + value.chars().take(256).collect() } fn root(row: &TraceSpansRow) -> String { @@ -112,7 +126,7 @@ impl Actors { id: id.clone(), parent: (id != &root(row)).then(|| root(row)), name: if id == &root(row) { - row.agent.clone() + label(&row.agent) } else { "Subagent".to_owned() }, @@ -138,7 +152,7 @@ impl Actors { if let Some((_, name)) = name && !name.is_empty() { - actor.name = name.to_owned(); + actor.name = label(name); } } } diff --git a/litellm-rust/crates/traces/tests/resolve.rs b/litellm-rust/crates/traces/tests/resolve.rs index 5b70c0de56e..9f2ec799591 100644 --- a/litellm-rust/crates/traces/tests/resolve.rs +++ b/litellm-rust/crates/traces/tests/resolve.rs @@ -1700,3 +1700,45 @@ fn unresolved_native_actors_are_distinguished_from_legacy_grouping( assert_eq!(trace.spans[0].actor_id, None); assert_eq!(trace.spans[0].actor_unassigned, unassigned); } + +#[rstest] +fn openinference_sdk_session_keeps_recorded_root_ownership() { + let root = TraceSpansRow { + framework: "claude-agent-sdk".into(), + session_id: "sdk-session".into(), + ..row("root", "", "ClaudeAgentSDK.query", "agent", "sdk-reader") + }; + let trace = resolve_trace("trace", "ref", &[root], &[]).unwrap(); + assert_eq!(trace.summary.agent_count, 1); + assert_eq!(trace.agents[0].name, "sdk-reader"); + assert!(!trace.spans[0].actor_unassigned); +} + +#[rstest] +fn inherited_native_actor_identity_and_labels_have_bounded_size() { + let root = TraceSpansRow { + framework: "claude-code".into(), + session_id: "s".into(), + native_agent_id: "a".repeat(65_536), + query_source: format!("agent.custom.{}", "n".repeat(65_536)), + ..row("root", "", "claude_code.interaction", "agent", "assistant") + }; + let children = (0..1_024).map(|index| TraceSpansRow { + framework: "claude-code".into(), + session_id: "s".into(), + ..row( + &format!("child-{index}"), + "root", + "Read", + "tool", + "assistant", + ) + }); + let rows: Vec<_> = std::iter::once(root).chain(children).collect(); + let trace = resolve_trace("trace", "ref", &rows, &[]).unwrap(); + assert_eq!(trace.summary.agent_count, 1); + assert!(trace.spans.iter().all(|span| { + span.actor_id.as_ref().is_some_and(|id| id.len() <= 80) && span.agent.len() <= 1_024 + })); + assert!(serde_json::to_vec(&trace).unwrap().len() < 3_000_000); +} diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts index d6421058233..04e862fed23 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts @@ -616,41 +616,42 @@ describe("coding sessions", () => { }); }); -it("keeps explicit native actors separate across workflow branches and background resumes", () => { - const span = (id: string, actor: string, parentActor: string | null, offset: number): Span => ({ - ...root, - span_id: id, - name: "claude_code.llm_request", - type: "llm", - framework: "claude-code", - parent_span_id: "root", - actor_id: actor, - parent_actor_id: parentActor, - agent: "general-purpose", - start_offset_ms: offset, - }); - const spans = [ - { ...root, framework: "claude-code", actor_id: "root-actor", parent_actor_id: null, agent: "claude-code" }, - span("first", "actor-one", "root-actor", 1), - span("second", "actor-two", "root-actor", 2), - span("nested", "actor-three", "actor-one", 3), - { ...span("resumed", "actor-one", "root-actor", 4), parent_span_id: null }, - ]; - const details = new Map(spans.map((span) => [span.span_id, detail(span.span_id, "", `Reply ${span.span_id}`)])); - const items = buildConversation(spans, details, true); - expect(items.filter((item) => item.span.span_id === "resumed").map((item) => item.agentId)).toEqual(["actor-one"]); - const groups = groupConversation(items, spans); - const branches = groups.filter((group) => group.kind === "branch"); - expect(branches.map((group) => group.id)).toEqual(["actor-one", "actor-two"]); - expect(branches[0].children.map((group) => (group.kind === "branch" ? group.id : group.item.span.span_id))).toEqual([ - "first", - "actor-three", - "resumed", - ]); - expect( - items.filter((item) => ["first", "resumed"].includes(item.span.span_id)).map((item) => item.agentName), - ).toEqual(["general-purpose (1)", "general-purpose (1)"]); -}); +it.each(["claude-code", "claude-agent-sdk"])( + "keeps explicit %s actors separate across workflow branches and background resumes", + (framework) => { + const span = (id: string, actor: string, parentActor: string | null, offset: number): Span => ({ + ...root, + span_id: id, + name: "claude_code.llm_request", + type: "llm", + framework, + parent_span_id: "root", + actor_id: actor, + parent_actor_id: parentActor, + agent: "general-purpose", + start_offset_ms: offset, + }); + const spans = [ + { ...root, framework, actor_id: "root-actor", parent_actor_id: null, agent: "claude-code" }, + span("first", "actor-one", "root-actor", 1), + span("second", "actor-two", "root-actor", 2), + span("nested", "actor-three", "actor-one", 3), + { ...span("resumed", "actor-one", "root-actor", 4), parent_span_id: null }, + ]; + const details = new Map(spans.map((span) => [span.span_id, detail(span.span_id, "", `Reply ${span.span_id}`)])); + const items = buildConversation(spans, details, true); + expect(items.filter((item) => item.span.span_id === "resumed").map((item) => item.agentId)).toEqual(["actor-one"]); + const groups = groupConversation(items, spans); + const branches = groups.filter((group) => group.kind === "branch"); + expect(branches.map((group) => group.id)).toEqual(["actor-one", "actor-two"]); + expect(branches[0].children.map((group) => (group.kind === "branch" ? group.id : group.item.span.span_id))).toEqual( + ["first", "actor-three", "resumed"], + ); + expect( + items.filter((item) => ["first", "resumed"].includes(item.span.span_id)).map((item) => item.agentName), + ).toEqual(["general-purpose (1)", "general-purpose (1)"]); + }, +); it.each([false, true])("preserves legacy history while isolating unconfirmed actors (%s)", (unassigned) => { const spans: Span[] = [ diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts index 47ee1cdefd3..f0ebbf6c48a 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts @@ -239,7 +239,7 @@ function agentLabels(agents: readonly Span[], details: ReadonlyMap