Skip to content

feat(telemetry): emit execute_tool spans for tool calls#1430

Merged
ajbozarth merged 1 commit into
generative-computing:mainfrom
ajbozarth:feat/1049-tool-post-invoke-span
Jul 23, 2026
Merged

feat(telemetry): emit execute_tool spans for tool calls#1430
ajbozarth merged 1 commit into
generative-computing:mainfrom
ajbozarth:feat/1049-tool-post-invoke-span

Conversation

@ajbozarth

Copy link
Copy Markdown
Contributor

Pull Request

Issue

Fixes #1049

Description

Adds OpenTelemetry tracing coverage for tool calls. A new ToolTracingPlugin emits one execute_tool {name} span per tool invocation via the existing tool_pre_invoke / tool_post_invoke hooks, following the OTel Gen-AI tool-execution semantic convention.

What it adds

  • ToolTracingPlugin (mellea/telemetry/tracing_plugins.py) — opens the span on tool_pre_invoke, closes it (success/error) on tool_post_invoke, correlated via a new tool_invocation_id. Registered alongside the Backend, Component, and Streaming tracing plugins. Hooks run SEQUENTIAL (the OTel context token must detach on the same task).
  • execute_tool span attributes:
    • gen_ai.operation.name, gen_ai.tool.name (required), gen_ai.tool.type, gen_ai.tool.description, gen_ai.tool.call.id — semconv request-side attrs
    • gen_ai.tool.call.arguments / gen_ai.tool.call.result — content-gated (MELLEA_TRACES_CONTENT), truncated to 500 chars
    • mellea.tool.status, mellea.tool.execution_time_ms, mellea.tool.is_control_flow, mellea.tool.arguments_hash — Mellea extensions
  • Span helpers in tracing.py (start_tool_span / finish_tool_span_*) routed through the shared _start_application_span / _finish_application_span_* core, mirroring the action span. Object-unpacking lives in get_tool_call_attrs.
  • Provider tool-call id captureModelToolCall gains an optional tool_call_id, populated from the provider response where available (OpenAI-compatible, LiteLLM, Ollama, Watsonx); None for raw-string parsing.
  • Content gating extracted — a single get_capture_content_value helper now handles the gate + 500-char truncation for mellea.response, gen_ai.tool.call.arguments, and gen_ai.tool.call.result (previously duplicated inline).
  • Docs — added the execute_tool span to observability/tracing.md (with a restructure of the Application spans section) and updated the tool counter's label in observability/metrics.md.

Parenting

Tool execution happens after the generating action/chat completes, so execute_tool spans are not nested inside the requesting action — they are siblings under the long-lived session span (or root spans outside a session). Verified end-to-end in the integration test.

Breaking changes

  1. Tool metric label rename: mellea.tool.calls now tags the tool name under gen_ai.tool.name (was tool), aligning the metric with the new span attribute. No deprecation shim, as telemetry is still early enough that dashboards are unlikely to depend on the old label.
  2. Internal module rename: mellea/telemetry/_tracing_setters.py_tracing_helpers.py (private module; not a public API change). It now holds non-setter helpers too (content_capture_enabled, get_capture_content_value, get_tool_call_attrs).

Testing

  • Tests added to the respective file if code was changed
  • New code has 100% coverage if code was added
  • Ensure existing tests and github automation passes (a maintainer will kick off the github automation when the rest of the PR is populated)

Test coverage:

  • Unit (test_tracing_plugins.py, mock tracer) — full ToolTracingPlugin lifecycle: pre/post, success/error, content gating on/off, no-op when disabled, skip-without-id, missing optional attrs.
  • Unit (test_tracing_helpers.py) — get_tool_call_attrs unpacking + hash stability + arg gating; get_capture_content_value truncation boundary.
  • Integration (test_tracing_tools.py, real OTel SDK + in-memory exporter) — one m.transform() with two tool calls (one raises), only inference faked: asserts both execute_tool spans emit, per-call success/error status, and parenting under the real session span.
  • Updated test_metrics_operational.py for the gen_ai.tool.name label and test_all_payloads.py for the new tool_invocation_id payload field.

Attribution

  • AI coding assistants used

Assisted-by: Claude Code


Adding a new component, requirement, sampling strategy, or tool?

  • Component
  • Requirement
  • Sampling Strategy
  • Tool

@ajbozarth

Copy link
Copy Markdown
Contributor Author

Two follow-ups from this work:

@ajbozarth ajbozarth added the area/telemetry OTel spans, metrics, tracing, semconv label Jul 22, 2026
Add OpenTelemetry tracing coverage for tool calls. A new ToolTracingPlugin
emits one `execute_tool {name}` span per tool invocation, opening it on the
tool_pre_invoke hook and closing it on tool_post_invoke, following the OTel
Gen-AI tool-execution semantic convention.

- ToolTracingPlugin opens the span on tool_pre_invoke and closes it (success
  or error) on tool_post_invoke, correlated via a new tool_invocation_id;
  span helpers route through the shared application-span core.
- Capture the provider tool-call id on ModelToolCall where available, emitted
  as gen_ai.tool.call.id.
- Extract the content gate + truncation into get_capture_content_value, shared
  by the action response and tool argument/result attributes.
- Rename mellea.tool.calls metric label `tool` -> `gen_ai.tool.name` to match
  the span attribute (breaking; no shim).
- Rename _tracing_setters -> _tracing_helpers (private) now that it holds
  non-setter helpers.

Closes generative-computing#1049

Assisted-by: Claude Code
Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
@ajbozarth
ajbozarth force-pushed the feat/1049-tool-post-invoke-span branch from 1923775 to 524bd5f Compare July 23, 2026 16:19

@AngeloDanducci AngeloDanducci left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall looks good, may be worth adding a test for an edge case that asserts no execute_tool span nests under the action span.

The integration test asserts tool spans parent under session, but doesn't negatively assert they are not children of action — the specific parentage claim in the PR description and docs ("not nested inside the action that requested them").

If a refactor re-parents tool spans under action the current assertions would still pass (session is an ancestor of action). A boom_span.parent.span_id != action_span.context.span_id assertion would lock the intended shape.

Scope: test/telemetry/test_tracing_tools.py — add the action span to _spans_by_name lookup and one negative-parentage assertion.

@ajbozarth

Copy link
Copy Markdown
Contributor Author

If a refactor re-parents tool spans under action the current assertions would still pass

This is actually a bit intentional, you would normally expect it to nest there, but that's not how our code path works, so I would say if we changed it in the future it would be improving it. The nesting test is to check that the attach to context part of tracing is working correctly.

The note in the docs is actually to clarify that it doesn't nest the intuitive way (tool under action) rather than an actual rule

@ajbozarth

Copy link
Copy Markdown
Contributor Author

cc @jakelorocco if you want to double check that the non-telemetry addition of tool_call_id looks good

@ajbozarth
ajbozarth added this pull request to the merge queue Jul 23, 2026
Merged via the queue into generative-computing:main with commit d024969 Jul 23, 2026
10 checks passed
@ajbozarth
ajbozarth deleted the feat/1049-tool-post-invoke-span branch July 23, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/telemetry OTel spans, metrics, tracing, semconv enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: tool call spans via tool_post_invoke hook

3 participants