Skip to content

feat(otel): emit a span for every governed MCP tool call - #68

Merged
prashar32 merged 1 commit into
mainfrom
feat/otel-tool-spans
Jun 6, 2026
Merged

feat(otel): emit a span for every governed MCP tool call#68
prashar32 merged 1 commit into
mainfrom
feat/otel-tool-spans

Conversation

@prashar32

Copy link
Copy Markdown
Owner

Emit a span for every governed MCP tool call

The OTel export gave me one span per model call, but nothing for tool calls — so when the gateway blocked a tool by the allowlist or denied it at the approval gate, that decision was invisible in my trace backend. The governance that matters most (side-effecting tools) was the part leaving no trace.

This emits one span per governed tools/call, right next to the model-call spans:

  • name execute_tool {tool} (e.g. execute_tool write_file)
  • gen_ai.tool.name — the standard GenAI attribute for the tool
  • riskkernel.tool.side_effect — the classified side effect (empty = read-only)
  • riskkernel.tool.statusapproved, blocked (allowlist), denied (approval gate), or timeout

A refused call carries an error span status so it stands out in the trace UI. It reuses the gateway's existing per-call timing + SQLite audit path and the same nil-safe tracer the proxy already uses, so it's a no-op when no OTLP endpoint is configured — still zero telemetry by default.

Contract

Aligned api/v1/otel-genai.md to what's actually emitted: the standard gen_ai.tool.name replaces the placeholder riskkernel.tool.name (documented but never emitted), and riskkernel.tool.status is now pinned. Documented the execute_tool span shape.

Verified

  • go test ./... green. New unit tests assert the span + attributes at the tracer (TestRecordToolCall_Attributes) and through the gateway (TestToolCallEmitsSpan).
  • End-to-end against a local Jaeger (key-free, via the MCP demo): the three governed calls land as spans —
    • execute_tool searchapproved
    • execute_tool delete_everythingblocked
    • execute_tool write_filedenied (side_effect tool)

The OTel export covered model calls but not tool calls, so allowlist
blocks and approval denials were invisible in the trace backend — the
governance that matters most for side-effecting tools left no trace next
to the model spans.

Emit one span per governed tools/call (`execute_tool {tool}`) carrying the
standard `gen_ai.tool.name` plus `riskkernel.tool.side_effect` and
`riskkernel.tool.status` (approved | blocked | denied | timeout). A refused
call gets an error span status so it stands out. This reuses the gateway's
existing per-call timing/audit path and the same nil-safe tracer the proxy
uses, so it stays a no-op when no OTLP endpoint is configured — still zero
telemetry by default.

Align api/v1/otel-genai.md to what's actually emitted: the standard
`gen_ai.tool.name` replaces the never-emitted `riskkernel.tool.name`, and
`riskkernel.tool.status` is documented; add the example and a dashboard
query for tool refusals.
@prashar32
prashar32 merged commit e2bf6fe into main Jun 6, 2026
5 checks passed
@prashar32 prashar32 mentioned this pull request Jun 6, 2026
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