Skip to content

feat(openai-messages)!: emit invoke_agent, chat and execute_tool spans - #32

Draft
apucacao wants to merge 4 commits into
ag/py-telemetry-claude-agentsfrom
ag/py-telemetry-openai-messages
Draft

feat(openai-messages)!: emit invoke_agent, chat and execute_tool spans#32
apucacao wants to merge 4 commits into
ag/py-telemetry-claude-agentsfrom
ag/py-telemetry-openai-messages

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 openai-messages.

invoke_agent                     one per call, carries the LD identity and the run total
├── chat {model}                 one per model turn, with that turn's own tokens
└── execute_tool {name}          one per tool call, a sibling of chat

Cached tokens were absent entirely

OpenAI reports them under input_tokens_details.cached_tokens, which nothing here read, so every span understated what a prompt-cached call actually reused.

They now appear in gen_ai.usage.cache_read.input_tokens and, unlike Anthropic, are not added on top of the input figure: OpenAI already counts them inside it, and adding them would double-count. Cache creation is always zero, because OpenAI has no such concept.

Two things specific to this handler

  1. It is the only one of the six that reports the model which actually answered rather than the one requested, on both the root and the chat spans. OpenAI resolves an alias like gpt-4o to a dated snapshot, and this handler has the resolved value to hand.

  2. Finish reasons are derived, not mapped. The Responses API has no finish_reason field, so the shared mapping table does not apply and is deliberately not imported. The value comes from a closed three-way check: a function call in the output means tool_calls, an incomplete status means length or content_filter depending on the reported cause, a completed status means stop, and anything else writes no attribute. The function-call check comes first, because status alone reports completed for a turn that stopped to call a tool.

Other changes

The streaming path gets a finally, so a consumer that breaks out of the iteration no longer leaves the root span unended and unexported, taking the whole run out of AI Config Monitoring along with the feature_flag event it carries.

Breaking change

The span is renamed from openai.response 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: 763 to 781.


Note

Overview
Breaking: replaces the flat openai.response span with the TypeScript-aligned tree (invoke_agent root, one chat {model} per turn, sibling execute_tool spans). Prompt/completion content is off by default; pass capture_content=True to emit it.

Adds a dedicated spans.py module and rewires both blocking and streaming paths around shared helpers. OpenAI cached tokens now surface as cache_read without double-counting into input. Finish reasons are derived from Responses status/output (tool_calls / stop / length / content_filter). Root and chat spans report the model that answered, not just the requested alias.

Streaming gets a finally so abandoned consumers still end and export spans (marked abandoned, not ERROR). Failed runs still report spend from completed turns.

Reviewed by Cursor Bugbot for commit 910e8a7. Bugbot is set up for automated code reviews on this repo. 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.

✅ 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 5b44877. Configure here.

@apucacao
apucacao force-pushed the ag/py-telemetry-openai-messages branch from 5b44877 to b2f5fa1 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 b2f5fa1. Configure here.

@apucacao
apucacao force-pushed the ag/py-telemetry-openai-messages branch from b2f5fa1 to 77d1074 Compare August 11, 2026 21:01
@apucacao

Copy link
Copy Markdown
Author

bugbot run

One flat span named openai.response becomes the tree the TypeScript SDK emits:
an invoke_agent root, one `chat {model}` child per model turn, one
`execute_tool {name}` child per tool call.

BREAKING CHANGE: the span this handler emits is renamed from `openai.response`
and `openai.response.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.

Cached tokens were absent entirely. OpenAI reports them under
input_tokens_details.cached_tokens, which nothing here read, so every span
understated what a prompt-cached call actually reused. They are now reported in
gen_ai.usage.cache_read.input_tokens and, unlike Anthropic, not added on top of
the input figure: OpenAI already counts them inside it, and adding them would
double-count. Cache creation is always zero, because OpenAI has no such
concept.

This handler is the only one of the six that reports the model which actually
answered rather than the one requested, on both the root and the chat spans.
OpenAI resolves an alias like gpt-4o to a dated snapshot, and this handler has
the resolved value to hand.

Finish reasons are derived, not mapped. The Responses API has no finish_reason
field, so the shared mapping table does not apply and is deliberately not
imported. The value comes from a closed three-way check: a function call in the
output means tool_calls, an incomplete status means length or content_filter
depending on the reported cause, a completed status means stop, and anything
else writes no attribute at all. The function-call check comes first because
status alone reports completed for a turn that stopped to call a tool.

The streaming path gets a finally, so a consumer that breaks out of the
iteration no longer leaves the root span unended and unexported, taking the
whole run out of AI Config Monitoring along with the feature_flag event it
carries.

Tests: 62 to 80.
…apper

The wrapper never passed capture_content to the factory, so it stayed in kwargs
and reached config(), which takes no such argument. A caller asking for content
on spans got a TypeError rather than content.

Lifted out alongside variables, which was already handled the same way and for
the same reason: one configures the handler, the other belongs to the
invocation, and config() accepts neither.

Two tests, one per branch, asserting the flag reaches the factory and does not
reach config().

Found by Bugbot on #33 against openai-agents. Five of the six wrappers had it;
each is fixed in its own layer.
… its span

The success-side content write and the span finish sat outside the try, so a
raise while recording the result skipped both the finish and the failure path.
The tool span was never ended, so the exporter never saw it: the run showed a
root marked ERROR and no sign the tool had been called.

Reachable rather than theoretical. Serialising a tool result raises TypeError
whenever capture_content is on and the result is not JSON-serialisable, which is
any object a handler happens to return.

Inherited from the claude-messages handler this one was modelled on, which had
it in the wrong place. The TypeScript handlers have always done this inside the
try.

Found by Bugbot on #34.
…t ends it

The content writes on both sides of the provider call sat outside the try that
fails the chat span, so a raise while serialising conversation content failed
only the root. The chat span was never ended and never exported: a run showed an
errored root with no sign a model call had happened.

Reachable through capture_content, where serialising any non-JSON-serialisable
value raises TypeError. The tool path in this same file already kept its
serialisation inside the guard, which is what makes the model path's omission
look accidental rather than considered. It was.

Found by Bugbot on #32.
@apucacao
apucacao force-pushed the ag/py-telemetry-openai-messages branch from 77d1074 to 910e8a7 Compare August 11, 2026 21:18
@apucacao

Copy link
Copy Markdown
Author

bugbot run

from opentelemetry import trace
from opentelemetry.trace import StatusCode as SpanStatusCode
from opentelemetry import trace # noqa: F401
from opentelemetry.trace import StatusCode as SpanStatusCode # noqa: F401

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dead OTel gate left in handler

Medium Severity

OTel usage moved to spans.py, but handler.py still defines _HAS_OTEL and imports trace / SpanStatusCode under # noqa: F401. Nothing in the handler reads them anymore, and tests that patch handler._HAS_OTEL no longer disable telemetry.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 910e8a7. 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.

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit 910e8a7. 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