diff --git a/apps/ingest/src/ai_session.rs b/apps/ingest/src/ai_session.rs index a35d0a63e..3e25f171b 100644 --- a/apps/ingest/src/ai_session.rs +++ b/apps/ingest/src/ai_session.rs @@ -35,10 +35,11 @@ //! //! Detection is ordered first-match over the vendor predicates below; the //! session ID is the first non-empty session-granularity attribute for the -//! matched vendor. A vendor with no session-level key of its own (its -//! instrumentation only emits run/user-scoped IDs, or nothing) and every -//! unknown-tier bucket read the OTel GenAI key, `gen_ai.conversation.id`, -//! which the public docs give any emitter as the way to group its traces. +//! matched vendor. Every vendor and unknown-tier bucket falls back to the OTel +//! GenAI key, `gen_ai.conversation.id`, after its own keys: the public docs +//! give any emitter that key as the way to group its traces, and for a vendor +//! with no session-level key of its own (its instrumentation only emits +//! run/user-scoped IDs, or nothing) it is the only one. //! //! One vendor is not a framework: `maple` matches any span carrying a //! `maple_ai.session.id` attribute. That is the one key an emitter both writes @@ -842,12 +843,15 @@ struct Vendor { id: &'static str, detect: DetectFn, /// Session-granularity span attribute keys; the first non-empty value wins. + /// [`CONVERSATION_ID_KEY`] is tried last unless the list ranks it itself. session_keys: &'static [&'static str], } -/// The session key of a dialect with none of its own: the OTel GenAI -/// conversation id, which the docs tell every emitter to set. -const CONVERSATION_ID_ONLY: &[&str] = &["gen_ai.conversation.id"]; +/// The OTel GenAI conversation id, which the docs tell every emitter to set. +const CONVERSATION_ID_KEY: &str = "gen_ai.conversation.id"; + +/// The session keys of a dialect with none of its own. +const CONVERSATION_ID_ONLY: &[&str] = &[CONVERSATION_ID_KEY]; /// Ordered: first match wins. `maple` leads because its key is an explicit /// opt-in rather than a framework fingerprint (see the module doc). Then @@ -999,7 +1003,10 @@ static UNKNOWN_TIER: &[Vendor] = &[ Vendor { id: "unknown:openinference", detect: detect_unknown_openinference, - session_keys: CONVERSATION_ID_ONLY, + // `session.id` only after the conversation id: Maple's browser SDK + // stamps its replay session under that key on every span, so it can + // span several conversations. + session_keys: &[CONVERSATION_ID_KEY, "session.id"], }, Vendor { id: "unknown:other", @@ -1039,9 +1046,13 @@ fn run_predicates( .iter() .chain(UNKNOWN_TIER) .find(|vendor| (vendor.detect)(&ctx))?; + let fallback = + (!vendor.session_keys.contains(&CONVERSATION_ID_KEY)).then_some(CONVERSATION_ID_KEY); let session_id = vendor .session_keys .iter() + .copied() + .chain(fallback) .find_map(|key| session_value(span_attrs, key)); Some(AiClassification { vendor: vendor.id, @@ -1526,6 +1537,36 @@ mod tests { ); } + #[test] + fn vendors_with_their_own_key_fall_back_to_the_conversation_id() { + for (scope, span_name, vendor) in [ + ("crewai.telemetry", "Crew.kickoff", "crewai"), + ("strands.telemetry.tracer", "invoke_agent", "strands"), + ("openinference.instrumentation.agno", "agent.run", "agno"), + ] { + classified( + scope, + span_name, + &[("gen_ai.conversation.id", "conv-8")], + &[], + vendor, + Some("conv-8"), + ); + // The vendor's own key still wins when both are set. + classified( + scope, + span_name, + &[ + ("gen_ai.conversation.id", "conv-8"), + ("session.id", "own-1"), + ], + &[], + vendor, + Some("own-1"), + ); + } + } + #[test] fn vendor_matched_but_session_key_absent_or_empty() { classified( @@ -2331,6 +2372,28 @@ mod tests { "unknown:openinference", Some("conv-9"), ); + // OpenInference's `session.id` fills in only without a conversation id: + // a browser replay session under that key must not merge conversations. + classified( + "", + "llm", + &[ + ("openinference.span.kind", "LLM"), + ("gen_ai.conversation.id", "conv-9"), + ("session.id", "browser-1"), + ], + &[], + "unknown:openinference", + Some("conv-9"), + ); + classified( + "", + "llm", + &[("openinference.span.kind", "LLM"), ("session.id", "oi-1")], + &[], + "unknown:openinference", + Some("oi-1"), + ); classified( "", "task", diff --git a/packages/agent-sessions/src/session-turns.ts b/packages/agent-sessions/src/session-turns.ts index 2c58c88e2..229360686 100644 --- a/packages/agent-sessions/src/session-turns.ts +++ b/packages/agent-sessions/src/session-turns.ts @@ -402,10 +402,10 @@ function findAnchors(ordered: readonly AiSessionSpan[]): readonly TurnAnchor[] { const byConversation = new Map() for (const span of ordered) { const conversationId = span.genAi.conversationId - // Six vendors (flue, google_adk, mastra, microsoft_agent_framework, - // openai_agents_sdk, pydantic_ai) derive `maple_ai.session.id` FROM - // `gen_ai.conversation.id`, so for them the id names the session and - // repeats on every span — a partition of one, not a turn key. + // The gateway derives `maple_ai.session.id` FROM `gen_ai.conversation.id` + // whenever a vendor's own session key is absent (and for some vendors + // ahead of it), so there the id names the session and repeats on every + // span — a partition of one, not a turn key. if (conversationId === undefined || sessionIds.has(conversationId)) continue if (!byConversation.has(conversationId)) byConversation.set(conversationId, span) }