Skip to content

feat(claude-agents)!: emit invoke_agent, chat and execute_tool spans - #31

Draft
apucacao wants to merge 2 commits into
ag/py-telemetry-claude-messagesfrom
ag/py-telemetry-claude-agents
Draft

feat(claude-agents)!: emit invoke_agent, chat and execute_tool spans#31
apucacao wants to merge 2 commits into
ag/py-telemetry-claude-messagesfrom
ag/py-telemetry-claude-agents

Conversation

@apucacao

@apucacao apucacao commented Aug 11, 2026

Copy link
Copy Markdown

Replaces one flat span per call with the tree the TypeScript SDK emits, for claude-agents.

invoke_agent                     one per call, carries the LD identity and the run total
├── chat {model}                 one per inference the Agent SDK reports
└── execute_tool {name}          one per tool call, a sibling of chat

Subagent identity was invisible

The Agent SDK reports a request id, a session id and a subagent type for every inference, and none of it reached a span. A trace could not say which subagent ran, or group turns by conversation.

  • gen_ai.response.id and gen_ai.agent.name now go on the chat span.
  • gen_ai.conversation.id goes on all three span types: the root learns it from the CLI's init message, the chat span from the inference, and the tool span from the hook input, which is where this side sees it without waiting for a message.

The streaming path had a real bug

The blocking path already held the vendor's query generator in a variable and awaited aclose() on it, because a bare return inside async for abandons it and asyncio's finalizer then raises RuntimeError when it is suspended inside a real await in the SDK. The streaming path iterated the generator inline with no held reference and no such cleanup, so it carried the same bug the blocking path was patched for. Both now close it in the same finally that ends the spans, and the streaming path gets the test the blocking path already had.

A run abandoned mid-stream now also closes any tool span whose PostToolUse hook never fired, which is otherwise the one span with no path to being ended.

Other changes

  • Cache tokens reach the span, folded into the input total.
  • The finish reason is written when the SDK reports one, which in practice is almost never: measured against Agent SDK 0.3.220, stop_reason is null on every assistant message and only the run-level result carries one. Deriving a reason from the presence of a tool-use block would put a value on the span the provider never returned, so the write stays guarded and usually absent.
  • gen_ai.response.model on the chat span is the model the turn actually used, not the requested name. This handler and openai-messages are the only two where those differ.

Breaking change

The span is renamed from claude.query to invoke_agent. Queries selecting on the old name will not match. Prompt and completion content is no longer on spans unless the caller passes capture_content=True.

Where this sits

Needs the usage layer (#28) and the content layer (#29). Independent of the other five handler PRs; the stack orders them only because gh stack is linear.

Tests: 729 to 763. This is the largest of the six handlers.


Note

Overview
Breaking: replaces the flat claude.query span with the shared three-span tree (invoke_agent root, one chat child per model response, execute_tool siblings per tool call). Queries on the old span name will miss. Prompt/completion/tool content is no longer written unless capture_content=True.

Span logic moves into a new spans.py. InferenceSpans derives chat boundaries from the Agent SDK message stream (grouped on Anthropic message_id), and Pre/PostToolUse hooks open and close tool spans. Conversation id, subagent name, response id, cache tokens, and CLI-native tools (widened from init) now land on the right spans.

Also fixes the streaming path: it now holds and aclose()s the vendor query generator (same AIC-2950 bug the blocking path already fixed), and abandoned streams close any open tool spans.

Reviewed by Cursor Bugbot for commit 078c7a5. Bugbot is set up for automated code reviews on this repo. Configure here.

@apucacao

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/claude-agents/src/launchdarkly_ai_claude_agents/spans.py
Comment thread packages/claude-agents/src/launchdarkly_ai_claude_agents/spans.py
@apucacao
apucacao force-pushed the ag/py-telemetry-claude-agents branch from c73f2a4 to 41575b5 Compare August 11, 2026 20:43
@apucacao

Copy link
Copy Markdown
Author

bugbot run

@cursor cursor 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 41575b5. Configure here.

One flat span named claude.query becomes the tree the TypeScript SDK emits: an
invoke_agent root, one `chat {model}` child per inference the Agent SDK
reports, one `execute_tool {name}` child per tool call.

BREAKING CHANGE: the span this handler emits is renamed from `claude.query` and
`claude.query.stream` to `invoke_agent`. Queries selecting on the old names
will not match. Prompt and completion content is no longer on spans unless the
caller passes capture_content=True.

Subagent identity was invisible. The Agent SDK reports a request id, a session
id and a subagent type for every inference, and none of it reached a span, so a
trace could not say which subagent ran or group turns by conversation. The chat
span now carries gen_ai.response.id and gen_ai.agent.name, and
gen_ai.conversation.id goes on all three span types: the root learns it from
the CLI's init message, the chat span from the inference, and the tool span
from the hook input, which is where this side sees it without waiting for a
message.

Cache tokens now reach the span, folded into the input total, because Anthropic
reports cache reads and writes beside the input count rather than inside it.

The finish reason is written when the SDK reports one, which in practice is
almost never: measured against Agent SDK 0.3.220, stop_reason is null on every
assistant message and only the run-level result carries one. Deriving a reason
from the presence of a tool-use block would put a value on the span the
provider never returned, so the write stays guarded and usually absent.

The streaming path needed more than a finally. The blocking path already held
the vendor's query generator in a variable and awaited aclose() on it, because
a bare return inside `async for` abandons it and asyncio's finalizer then
raises RuntimeError when it is suspended inside a real await in the SDK. The
streaming path iterated the generator inline with no held reference and had no
such cleanup, so it carried the same bug the blocking path was patched for.
Both now close the generator in the same finally that ends the spans, and the
streaming path gets the test the blocking path already had.

A run abandoned mid-stream now closes any tool span whose PostToolUse hook
never fired, which is otherwise the one span with no path to being ended.

Tests: 56 to 90. The telemetry tests are rewritten rather than extended,
because they pinned the old flat span, and every test that was not about
telemetry is preserved under its original name.
@apucacao
apucacao force-pushed the ag/py-telemetry-claude-agents branch from 41575b5 to 8c8604f Compare August 11, 2026 21:01
@apucacao

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py
_build_query_options passed tools=[] whenever a config had no native tools. An
explicit empty list is not the same as omitting the key: it tells the Agent SDK
there are no tools, which switches off the Claude Code built-ins. A run with only
MCP tools, or none at all, silently lost Read, Bash and the rest.

main omitted the key in that case, leaving the SDK default. Restored, so the
condition is back where it was and only a non-empty list is ever passed.

Two tests, one per branch. The empty case fails when the regression is put back.

Found by Bugbot on #31 at High severity. This is a behaviour regression the span
port introduced, not a telemetry change.
@apucacao

Copy link
Copy Markdown
Author

bugbot run

@cursor cursor 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 078c7a5. Configure here.

@apucacao

Copy link
Copy Markdown
Author

bugbot run

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 078c7a5. Configure here.

],
)
succeed_span(span)
return {"output": message.result, "usage": raw_usage}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

None result breaks output contract

Medium Severity

The blocking path now returns message.result directly. A successful run with a null result used to coerce to an empty string via message.result or "", so callers that treat output as a string can now receive None. The same branch also runs json.dumps on that null value when building captured content, producing a literal "null" text part.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 078c7a5. Configure here.

if native_tool_names or mcp_allowed_tools:
hooks, tool_telemetry = build_tool_hooks(
native_tool_map, parent, capture_content
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Builtin tools skip execute spans

Medium Severity

Tool hooks are only installed when the config contributes native or MCP tools. A run that relies on Claude Code built-ins alone still omits the tools option on purpose so Read/Bash/etc. stay available, but with no hooks those calls never open execute_tool spans, so the new span tree is incomplete for the common no-config-tools case.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 078c7a5. Configure here.

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