From 69741ffb7c8d3354da965988e1bb15e54cacde05 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 19:33:37 +0200 Subject: [PATCH 1/2] fix(ingest): fall back to gen_ai.conversation.id for every vendor Eleven vendors read only their own session key, so a span that carried just the OTel GenAI conversation id got no session. Every vendor now tries gen_ai.conversation.id after its own keys, and unknown:openinference reads OpenInference's session.id first. --- apps/ingest/src/ai_session.rs | 67 +++++++++++++++++--- packages/agent-sessions/src/session-turns.ts | 8 +-- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/apps/ingest/src/ai_session.rs b/apps/ingest/src/ai_session.rs index a35d0a63e..ac2d10af8 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,7 @@ static UNKNOWN_TIER: &[Vendor] = &[ Vendor { id: "unknown:openinference", detect: detect_unknown_openinference, - session_keys: CONVERSATION_ID_ONLY, + session_keys: &["session.id", CONVERSATION_ID_KEY], }, Vendor { id: "unknown:other", @@ -1039,9 +1043,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 +1534,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 +2369,19 @@ mod tests { "unknown:openinference", Some("conv-9"), ); + // OpenInference's own session key ranks first. + classified( + "", + "llm", + &[ + ("openinference.span.kind", "LLM"), + ("gen_ai.conversation.id", "conv-9"), + ("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) } From 062ce5aa0d8b7e1f757993b187c59103b51e55e8 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 19:55:38 +0200 Subject: [PATCH 2/2] fix(ingest): rank the conversation id ahead of session.id for unknown:openinference Maple's browser SDK stamps its replay session as session.id on every span, so preferring it merged separate conversations from one browser tab. session.id now only fills in when the span has no conversation id. --- apps/ingest/src/ai_session.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/ingest/src/ai_session.rs b/apps/ingest/src/ai_session.rs index ac2d10af8..3e25f171b 100644 --- a/apps/ingest/src/ai_session.rs +++ b/apps/ingest/src/ai_session.rs @@ -1003,7 +1003,10 @@ static UNKNOWN_TIER: &[Vendor] = &[ Vendor { id: "unknown:openinference", detect: detect_unknown_openinference, - session_keys: &["session.id", CONVERSATION_ID_KEY], + // `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", @@ -2369,17 +2372,26 @@ mod tests { "unknown:openinference", Some("conv-9"), ); - // OpenInference's own session key ranks first. + // 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", "oi-1"), + ("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(