Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 71 additions & 8 deletions apps/ingest/src/ai_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions packages/agent-sessions/src/session-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,10 +402,10 @@ function findAnchors(ordered: readonly AiSessionSpan[]): readonly TurnAnchor[] {
const byConversation = new Map<string, AiSessionSpan>()
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)
}
Expand Down
Loading