Skip to content

No spans for tool calls: execute_tool is defined in the semconv the repo already imports, but never emitted #494

Description

@lifeofzero

Summary

MAF-Go emits no spans for tool calls. An agent that spends 91 seconds making 14 MCP calls is a single opaque box in the trace. You can see that it was slow. You cannot see which tool, how many, or whether one call ate most of the time — which is the only question you have when you open a trace.

The OpenTelemetry GenAI semantic conventions define this operation, and the constant is already in the semconv package this repo imports:

semconv.GenAIOperationNameExecuteTool = GenAIOperationNameKey.String("execute_tool")
semconv.GenAIToolName

What a traced run looks like today

Only invoke_agent spans exist, one per agent:

01-researcher   66.02s     <- 14 tool calls happened in here. All invisible.
02-grapher      44.78s

What it looks like with tool spans (implemented locally as a wrapper)

01-researcher                66.02s
  execute_tool query          4.17s   args=41b   result=5,393b
  execute_tool search         1.54s   args=18b   result=20,992b
  execute_tool get_page       0.23s   args=54b   result=1,135b
  execute_tool query          3.41s   args=106b  result=19,604b
  execute_tool code_blast     0.13s   args=27b   result=280b
  ...

That immediately shows the tool results are 5-20KB each and are re-sent on every turn, which is exactly why the input-token count is high — a conclusion that is unreachable from the agent-level span alone.

Why this belongs in the framework, not in user code

I implemented it as a tool.Tool decorator that wraps Call. It works, but it has the classic footgun: you must remember to wrap every toolkit, and a tool you forget emits nothing, silently. A missing span is indistinguishable from a tool that was never called.

The framework already owns the tool-invocation path and could open the span there, once, for everyone. That is the difference between instrumentation that is on by default and instrumentation that is on when you remembered.

Suggested shape

Wherever the agent loop invokes a tool.FuncTool, wrap the call:

span name: "execute_tool <name>"
  gen_ai.operation.name = execute_tool     (semconv.GenAIOperationNameExecuteTool)
  gen_ai.tool.name      = <name>           (semconv.GenAIToolName)
  error status on failure

Two notes from doing it in anger:

  1. Record result SIZE, not result content. Tool results routinely contain user data (a wiki page, a DB row). Putting bodies in spans turns a trace file into an accidental data-exfiltration surface. Size answers the question that matters at trace level.

  2. An MCP tool can fail while returning no Go error — see mcptool: CallToolResult.IsError is never checked — a refused tool call returns err == nil and the agent believes it succeeded #492. A tool span that only watches the Go error will paint a refused call green, which is worse than no span at all. If tool spans land, they should also respect CallToolResult.IsError. The two issues are related and I would suggest fixing mcptool: CallToolResult.IsError is never checked — a refused tool call returns err == nil and the agent believes it succeeded #492 first.

I have a working implementation and am happy to contribute it, but the placement is a design call that is yours — say where you would want it and I will open a PR against that shape rather than guessing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions