Skip to content

fix(agent-sessions): span-kind classification - #1126

Closed
JeremyFunk wants to merge 4 commits into
mainfrom
fix/agent-sessions-span-classification
Closed

JeremyFunk wants to merge 4 commits into
mainfrom
fix/agent-sessions-span-classification

Conversation

@JeremyFunk

@JeremyFunk JeremyFunk commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Depends on #1121. Merge #1121 first. Until it lands, sessions from OpenInference-only emitters (older DSPy and smolagents instrumentations) show 0 LLM and tool calls on the session page while the list stays correct: the page reads openinference.span.kind / llm.model_name only for vendors that #1121 registers under the OpenInference integration. After #1121 merges, this branch merges origin/main and adds a session-turns regression test for an OpenInference-only smolagents TOOL span and a DSPy LLM span.

Migration number. #1123 also claims migration 0035 / local store v26 and also recreates ai_trace_index_mv. Whichever lands second renumbers to 0036 / v27 and regenerates the view DDL so it carries both PRs' expression changes.

Span-kind classification fixes for Agent Sessions: which spans count as model calls and tool calls, on the list (ai_trace_index), the session summary query, and the session page (classifyAiSpan). Found while replaying the agent-tracing guide captures (bugs #5 and #37 of the verification run).

#5: calls read off span names that merely mention one (6a1e021)

Symptom. A span with no known operation was a model call if its name contained chat or completion, a tool call if it contained tool, and an agent step if it contained agent or workflow. From the replayed captures:

  • LiteLLM proxy auth /chat/completions (scope litellm, no attributes): list shows 6 LLM calls for 3 requests.
  • LlamaIndex BaseWorkflowAgent.call_tool + aggregate_tool_results: every tool call counted 3x on list and page (4x with HITL).
  • DSPy ChatAdapter.__call__ (13 per run, beside 13 real LM.__call__), LangGraph tools / tool_executor / ChatPromptTemplate, Spring AI spring_ai chat_client / message_chat_memory / Tool Calling Advisor, an OpenAI Agents workflow named "… chat": counted as calls.
  • Haystack haystack.agent.step.llm (no operation, no model): read as an agent step, so raw Haystack sessions showed 0 LLM calls.
  • Effect AI Chat.generateText wraps LanguageModel.generateText, which already carries gen_ai.operation.name=chat: each call counted twice.

Cause. classifyAiSpan (packages/agent-sessions/src/session-turns.ts:77-85 before this PR) and its SQL copy genAiIsLlmCallCond / genAiIsToolCallCond (packages/domain/src/tinybird/gen-ai-columns.ts:184-231, via nameLooks) fell back to substring matches on the lower-cased span name.

Fix. Past the operation, a span is a tool call by its tool name, a model call by its model, and otherwise only by an exact framework span name: AI_INFERENCE_SPAN_NAMES = ["haystack.agent.step.llm"], AI_TOOL_SPAN_NAMES = ["haystack.agent.step.tool", "Toolkit.handle"] (packages/domain/src/gen-ai.ts). Everything else is an agent step. I checked every vendor in apps/ingest/src/ai_session.rs against the captures for spans that carry no operation, model or tool name. These three names are the only real calls among them:

  • Claude Code: already restated to gen_ai.operation.name at ingest. Its phase spans are unstamped.
  • Old-dialect Vercel AI SDK: calls carry ai.model.id / ai.toolCall.name.
  • Google ADK call_llm, LiteLLM SDK litellm_request, Semantic Kernel chat.completions: carry a model.
  • DSPy, OpenAI Agents, smolagents, CrewAI, LlamaIndex-via-OpenInference: OpenInference kinds.
  • Mastra, Strands, Pydantic AI, Spring AI, MAF, OpenRouter: operations.

The same rule is used in three places: the page (classifyAiSpan), the MV (genAiIsLlmCallCond / genAiIsToolCallCond) and the session summary (summaryMeasures_ in ai-sessions.ts, which had no name rules and now reads the same exact names).

Migration. 0035_ai_trace_index_call_classification drops and recreates ai_trace_index_mv (verbatim emitter DDL). The target table is unchanged, requiredForIngest: false. Forward-only: rows materialized before it keep their old IsLlmCall / IsToolCall until raw traces' 30-day TTL ages them out. The page and the summary read raw spans, so they are correct immediately. Local store v25 -> v26 (local-0025-to-0026-ai-trace-index-call-classification: drop the view, bootstrap recreates it).

Test. packages/agent-sessions/src/session-turns.test.ts covers the Haystack/Effect names that must still classify and the eight capture names that must not. SQL tests are in ai-span-columns.test.ts and ai-sessions.test.ts; the migration test is in migrations/index.test.ts.

#37: session page counts the LiteLLM proxy's FastAPI span as a model call (a967430)

Symptom. On a LiteLLM proxy session the page counted 9 LLM calls, the list 6, for 3 real requests. The other 3 on the list are the auth spans that #5 fixes. The proxy's FastAPI server span POST /chat/completions carries only gen_ai.request.model and http.*. The gateway does not stamp it and the index never holds it, yet inspect_span called it "AI agent span — inference".

Cause. classifyAiSpan (session-turns.ts:73) applied its fallbacks to any span with a decoded gen_ai key (isAiSpan, ai-integrations.ts:363), stamped or not. The summary query's model fallback (ai-sessions.ts:1618-1623) also ignored the stamp, although its tool fallback already required it.

Fix. Past the operation, a span the gateway did not stamp is other (the app's own work) on the page. The summary's model fallback now requires the vendor stamp. The list is unchanged because it only ever held stamped spans.

Test. session-turns.test.ts "classifies an unstamped span as other…" (the proxy span's shape from docs_litellm_proxy). There is also an SQL assertion in ai-sessions.test.ts.

Follow-up commit (84d061c). The summary's tool-call fallback required an absent operation (operation = ''). The page, the index and the summary's own model-call rule all fall back past any operation outside the known sets. It now uses the same not-in-known-operations guard.

Correction to the 6a1e021 commit message. The message says the three names are "the only calls in the replayed captures that carry no operation, model or tool name". That holds for the docs_* captures only. Across all 113 captures, the OpenInference-only DSPy/smolagents spans (covered by #1121) and the native LlamaIndex package (below) also rely on data the page could not read.

Decisions and known effects

  • Exact names only, no substring or naming-convention rule. No capture has a call that needs one. I also dropped the "name leads with the operation" (chat gpt-5) reading. An emitter that writes that name should also write gen_ai.operation.name, and every such span in the captures does.
  • Native llama-index-observability-otel package: known limit. For the llamaindex_agents capture, the page shows 16 LLM calls / 17 tool calls before this PR, 0 / 0 after, and the true figure is 8 / 3. The package's spans carry no operation, model or tool name; the LLM calls exist only as LLMChatStartEvent span events. The old counts came from substring matches that also counted call_tool, aggregate_tool_results, _prepare_chat_with_tools and nested astream_chat pairs, so no name rule gets close to the truth. The LlamaIndex guide steers users to the OpenInference instrumentor, which classifies correctly.
  • OpenLLMetry / Traceloop (unknown:other): spans named {name}.tool with no gen_ai.tool.name used to be tool calls by substring and are now agent steps. There is no capture to verify this against. The principled fix is to translate traceloop.span.kind the way openinference.span.kind is translated; that's a follow-up.
  • Sibling overlap: expect conflicts with fix(agent-sessions): token, cost and call roll-up correctness #1122 (ai-sessions.ts). fix(agent-sessions): session checks, vitals and turn labels #1120 also edits session-turns.ts (turn anchors and labels, not classifyAiSpan). Since Widget lab + heatmap layout fix #37, an unstamped span with no operation is other, so it can no longer open a turn as an agent root.

…ention one

Symptom: a span with no known operation was a model call when its name
contained "chat" or "completion", a tool call when it contained "tool", and
an agent step when it contained "agent" or "workflow". LiteLLM proxy's
`auth /chat/completions` span doubled LLM calls (list: 6 for 3 requests);
LlamaIndex's `BaseWorkflowAgent.call_tool` and `aggregate_tool_results`
counted every tool call 3x; DSPy's `ChatAdapter.__call__`, LangGraph's
`tools` node, Spring AI's `chat_client` advisor and an OpenAI Agents
workflow named "... chat" were counted as calls; Haystack's
`haystack.agent.step.llm` read as an agent step, so raw Haystack sessions
showed 0 LLM calls.

Cause: `classifyAiSpan` (packages/agent-sessions/src/session-turns.ts) and
its SQL transcription `genAiIsLlmCallCond` / `genAiIsToolCallCond`
(packages/domain/src/tinybird/gen-ai-columns.ts) fell back to substring
matches on the span name.

Fix: past the operation, a span is a tool call by its tool name, a model
call by its model, and otherwise by an exact framework span name
(`AI_INFERENCE_SPAN_NAMES` / `AI_TOOL_SPAN_NAMES` in @maple/domain/gen-ai):
`haystack.agent.step.llm`, `haystack.agent.step.tool` and Effect AI's
`Toolkit.handle`, the only calls in the replayed captures that carry no
operation, model or tool name. Everything else is an agent step. The
session summary query gains the same names. Migration 0035 recreates
ai_trace_index_mv with the new flags (forward-only, nothing backfilled);
local store v25 -> v26.

Seen in: trace-capture docs_litellm_proxy, llamaindex_agents, dspy_agents,
langgraph_agents, spring_ai_agents, openai_agents_sdk_agents,
haystack_agents, effect_ai_agents.
…call counts

Symptom: on a LiteLLM proxy session the session page counted 9 LLM calls
where the list counted 6 (3 real requests; the other 3 are the proxy's
`auth` spans, fixed separately). The proxy's FastAPI server span
`POST /chat/completions` carries only `gen_ai.request.model` and http.*,
so the ingest gateway does not stamp it and the list's index never holds
it, but the page classified it as an inference span.

Cause: `classifyAiSpan` (packages/agent-sessions/src/session-turns.ts)
applied its model and name fallbacks to any span with a decoded gen_ai
key (`isAiSpan`), stamped or not. The session summary query
(`summaryMeasures_` in packages/query-engine-integrations/src/ai/ai-sessions.ts)
did the same for its model fallback.

Fix: past the operation, a span the gateway did not stamp is "other" —
the app's own work — on the page, and the summary's model fallback
requires the vendor stamp, as its tool fallback already did. The list is
unchanged: it only ever saw stamped spans.

Seen in: trace-capture docs_litellm_proxy (EU org service
docs-verify-litellm, proxy session).
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 16 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 2b28912d-ed74-4b11-b8d4-0c89ddf33600

📥 Commits

Reviewing files that changed from the base of the PR and between f8b99d8 and 84d061c.

⛔ Files ignored due to path filters (2)
  • packages/domain/src/generated/clickhouse-schema.ts is excluded by !**/generated/**
  • packages/domain/src/generated/tinybird-project-manifest.ts is excluded by !**/generated/**
📒 Files selected for processing (22)
  • apps/cli/src/server/local-schema-history.ts
  • apps/cli/src/server/local-schema-version.ts
  • apps/cli/src/server/local-store-migrations/steps.ts
  • apps/cli/src/server/schema-identity.ts
  • apps/cli/src/server/schema/local-inserts.json
  • apps/cli/src/server/schema/local-schema-v26.sql
  • apps/cli/src/server/schema/local-schema.sql
  • apps/cli/test/local-store-migrations.test.ts
  • apps/cli/test/native-local-store-migration.sh
  • apps/ingest/src/clickhouse_insert_mappings.rs
  • packages/agent-sessions/src/session-turns.test.ts
  • packages/agent-sessions/src/session-turns.ts
  • packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts
  • packages/domain/src/clickhouse/migrations/0035_ai_trace_index_call_classification.ts
  • packages/domain/src/clickhouse/migrations/index.test.ts
  • packages/domain/src/clickhouse/migrations/index.ts
  • packages/domain/src/gen-ai.ts
  • packages/domain/src/tinybird/gen-ai-columns.ts
  • packages/query-engine-integrations/src/__sql_baseline__/integrations.sql
  • packages/query-engine-integrations/src/ai/ai-sessions.test.ts
  • packages/query-engine-integrations/src/ai/ai-sessions.ts
  • packages/query-engine-integrations/src/ai/ai-span-columns.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@maple-review-bot

maple-review-bot Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 4/5 · likely safe to merge
One interaction to watch: findAnchors rule 2 opens turns only for spans that classify agent, so an unstamped agent span no longer anchors a turn.
quality 100/100 · no findings · tests covered · risk medium

Span-kind classification now reads a model or tool call from the span's tool name, its model, or an exact framework span name instead of substrings, and drops spans the ingest gateway never stamped. The page, the ai_trace_index view and the session summary agree, and the migration matches the emitted schema.

  • classifyAiSpan returns "other" for any span without a maple_ai.vendor.id
  • genAiIsLlmCallCond/genAiIsToolCallCond read calls from tool name, model, exact names
  • summaryMeasures_ gates its model fallback on the vendor stamp
  • Migration 0035 recreates ai_trace_index_mv; local store v25 → v26
What was checked
  • Migration 0035's CREATE is byte-equal to local-schema.sql and local-schema-v26.sql (compared in the sandbox)
  • No substring name rule or nameLooks caller survives outside the frozen v16–v22 snapshots
  • Client and SQL rules agree for stamped spans: tool name, then model, then exact names

a967430 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +1628 to +1633
const isToolCall = operation.in_(...AI_TOOL_OPERATIONS).or(
operation
.eq("")
.and(isAi)
.and(toolName.neq("").or(model.eq("").and($.SpanName.in_(...AI_TOOL_SPAN_NAMES)))),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unknown-operation tool calls vanish from summaries

A stamped tool span with an unknown nonempty operation and a toolName contributes no summary tool call. The index classifier accepts unknown operations, so the list counts that call.

Learn more

The summary query aggregates spans for the session detail page. Its tool-call fallback currently runs only when the operation is empty. The page classifier and index classifier instead fall through whenever an operation is not one of the known operations. The summary therefore disagrees with both on a tool span carrying an open-set operation.

Example: A stamped span with gen_ai.operation.name=invoke_tool and gen_ai.tool.name=search counts as one tool call on the list and page but zero in the summary.

Recommended fix: Gate the summary fallback on the operation being outside all known operation sets, as genAiIsToolCallCond does. Preserve the vendor check and tool-name-before-model precedence.

Suggested change
const isToolCall = operation.in_(...AI_TOOL_OPERATIONS).or(
operation
.eq("")
.and(isAi)
.and(toolName.neq("").or(model.eq("").and($.SpanName.in_(...AI_TOOL_SPAN_NAMES)))),
)
const isToolCall = operation.in_(...AI_TOOL_OPERATIONS).or(
operation
.notIn(...AI_INFERENCE_OPERATIONS, ...AI_RETRIEVAL_OPERATIONS, ...AI_TOOL_OPERATIONS, ...AI_AGENT_OPERATIONS)
.and(isAi)
.and(toolName.neq("").or(model.eq("").and($.SpanName.in_(...AI_TOOL_SPAN_NAMES)))),
)

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84d061c: the summary's tool fallback now uses the same not-in-known-operations guard as its model-call fallback, the index and classifyAiSpan.

…nown operation (#5)

Symptom: the session summary query counted a tool call off its tool name
only when the span had no operation at all, so a stamped span with an
operation the convention does not name (LangSmith `chain`, Spring AI
`framework`) and a tool name was a tool call on the page and in the index
but not in the summary.

Cause: `summaryMeasures_` (packages/query-engine-integrations/src/ai/ai-sessions.ts)
guarded its tool fallback with `operation = ''`, where its model-call
fallback, `genAiIsToolCallCond` and `classifyAiSpan` all fall back past
any operation outside the known sets.

Fix: the tool fallback uses the same not-in-known-operations guard.

Found by the independent verification of the #5 commit.
@maple-review-bot

maple-review-bot Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 4/5 · likely safe to merge
The summary rules now mirror the page and the MV on every input I followed; only one new test asserts a property it cannot fail on.
quality 98/100 · 1 note · tests partial · risk medium

Aligns the session summary's model- and tool-call rules with the page's classifyAiSpan: past a known operation, evidence now needs the gateway stamp, a tool name, or the exact framework span names. The three copies agree on every input I followed, so this is safe to merge.

  • summaryMeasures_ fallback needs the gateway stamp for model and tool calls
  • Tool-call fallback now passes any operation the convention does not name
  • Model-call fallback also reads AI_INFERENCE_SPAN_NAMES

Findings

Note · F1 · New model-call test passes off the tool-call rule's SQL

tests · packages/query-engine-integrations/src/ai/ai-sessions.test.ts:1423-1425

The expected substring stops at 'agent_step') AND SpanAttributes['maple_ai.vendor.id'] != ''), which the tool-call rule's NOT IN (...) clause (ai-sessions.ts:1630-1637) also contains, so the test still passes if isLlmCall loses .and(isAi) or the haystack.agent.step.llm name — the one thing the test name claims is untested. Anchor the substring inside the model clause.

Extend the expected string past the `!= '')` so only the model-call clause can satisfy it, e.g. `"'agent_step') AND SpanAttributes['maple_ai.vendor.id'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.response.model']"`.
What was checked
  • Followed both new clauses against classifyAiSpan (session-turns.ts:69-91): tool name outranks model, known operations outrank evidence
  • Compared with genAiIsLlmCallCond/genAiIsToolCallCond, which drop isAi only because every index row is stamped
  • Read the emitted SQL in integrations.sql for both clauses, including the NOT IN lists
Copy all findings (1)
Findings from an automated review of commit 84d061c543753c9ac1a0bea07c04ba33952261ce. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F1 · Note · tests · packages/query-engine-integrations/src/ai/ai-sessions.test.ts:1423-1425
New model-call test passes off the tool-call rule's SQL
The expected substring stops at `'agent_step') AND SpanAttributes['maple_ai.vendor.id'] != '')`, which the tool-call rule's `NOT IN (...)` clause (ai-sessions.ts:1630-1637) also contains, so the test still passes if `isLlmCall` loses `.and(isAi)` or the `haystack.agent.step.llm` name — the one thing the test name claims is untested. Anchor the substring inside the model clause.
Suggested fix: Extend the expected string past the `!= '')` so only the model-call clause can satisfy it, e.g. `"'agent_step') AND SpanAttributes['maple_ai.vendor.id'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.response.model']"`.

84d061c · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

…an-classification

# Conflicts:
#	packages/query-engine-integrations/src/ai/ai-sessions.ts
@maple-review-bot

Copy link
Copy Markdown

Note

Maple is reviewing this pull request at 7c33e70. This comment updates with the review when it finishes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant