Showing posts with label OpenTelemetry. Show all posts
Showing posts with label OpenTelemetry. Show all posts

Monday, June 15, 2026

LLM Observability with OpenTelemetry: Tracing Every Token in Production

Hero image

Introduction

I broke our on-call rotation last quarter. Not with a deployment, not with a config change. With a prompt.

We'd shipped a multi-step agent that researched, summarized, and filed Jira tickets automatically. It worked perfectly in staging. In production it worked too, mostly, except it started attaching 40-page context dumps to every ticket because one prompt change caused it to include the full conversation history in every tool call. No exception was raised. No alert fired. The agent completed successfully every time. We only found out when our API bill for the week came in at (we measured) $4,200 instead of $80.

Standard APM tools don't see this failure mode. latency: normal. error rate: 0%. tickets filed: ✓. Everything green. The failure was semantic, not structural, and semantic failures in LLM systems are invisible unless you instrument specifically for them.

This post covers how to add OpenTelemetry instrumentation to LLM calls so you can trace token spend, catch prompt regressions, and attribute costs to specific tasks before the bill arrives.

The Problem: LLM Calls Are Opaque by Default

Traditional distributed tracing gives you spans for HTTP requests, database queries, and cache hits. It tells you how long a call took and whether it failed.

LLM calls need a different set of signals:
- Token counts (prompt tokens and completion tokens separately)
- Model used (gpt-4o vs gpt-4o-mini matters: 30× cost difference)
- Temperature and sampling params (affects output variance, not captured elsewhere)
- Prompt content (or a hash of it, for regression detection)
- Tool call count (agents that call tools 20 times vs 2 times have very different cost profiles)
- Finish reason (stop vs length vs tool_calls; length means truncation, which is a silent failure)

None of these appear in standard HTTP traces. A 200 response from the OpenAI API tells you the call succeeded, not whether it did what you intended.

Per the 2025 Datadog State of DevOps report, 73% of teams running LLMs in production had no token-level visibility into their workloads. They were flying blind on cost and quality simultaneously.

How OpenTelemetry Fits

OpenTelemetry (OTel) is the CNCF standard for distributed tracing, metrics, and logs. It's already in most production stacks for instrumenting databases and HTTP services. LLM calls are just another span. They need a few extra attributes.

The OpenTelemetry Semantic Conventions for GenAI (GA as of OTel 1.26, per the OTel changelog) define a standard set of span attributes for LLM operations:

gen_ai.system          = "openai" | "anthropic" | "bedrock" | ...
gen_ai.request.model   = "gpt-4o"
gen_ai.request.max_tokens = 1000
gen_ai.response.model  = "gpt-4o-2024-11-20"   # actual model used
gen_ai.usage.prompt_tokens     = 847
gen_ai.usage.completion_tokens = 203
gen_ai.usage.total_cost_usd    = 0.0063         # computed from token counts
gen_ai.finish_reason   = "stop"

These map cleanly to Jaeger, Grafana Tempo, Honeycomb, and Datadog APM.

Here's a minimal Python instrumentation wrapper that adds these attributes to every LLM call:

import time
from opentelemetry import trace
from opentelemetry.trace import SpanKind, Status, StatusCode

tracer = trace.get_tracer("llm-service")

# Pricing per 1M tokens (update as needed)
MODEL_PRICING = {
    "gpt-4o": {"prompt": 2.50, "completion": 10.00},
    "gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
    "claude-opus-4": {"prompt": 15.00, "completion": 75.00},
    "claude-sonnet-4-6": {"prompt": 3.00, "completion": 15.00},
}

def compute_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
    pricing = MODEL_PRICING.get(model, {"prompt": 0, "completion": 0})
    return (
        prompt_tokens * pricing["prompt"] / 1_000_000
        + completion_tokens * pricing["completion"] / 1_000_000
    )

def traced_llm_call(client, model: str, messages: list, task_name: str = "", **kwargs):
    """Wrapper that instruments any OpenAI-compatible LLM call with OTel spans."""
    with tracer.start_as_current_span(
        f"llm.chat.{task_name or 'call'}",
        kind=SpanKind.CLIENT,
    ) as span:
        span.set_attribute("gen_ai.system", "openai")
        span.set_attribute("gen_ai.request.model", model)
        span.set_attribute("gen_ai.request.max_tokens", kwargs.get("max_tokens", -1))
        span.set_attribute("gen_ai.request.temperature", kwargs.get("temperature", 1.0))
        span.set_attribute("llm.task_name", task_name)
        span.set_attribute("llm.prompt_message_count", len(messages))

        # Hash prompt for regression detection (don't log full content in prod)
        import hashlib, json
        prompt_hash = hashlib.sha256(json.dumps(messages, sort_keys=True).encode()).hexdigest()[:16]
        span.set_attribute("llm.prompt_hash", prompt_hash)

        start = time.monotonic()
        try:
            response = client.chat.completions.create(
                model=model, messages=messages, **kwargs
            )
        except Exception as e:
            span.record_exception(e)
            span.set_status(Status(StatusCode.ERROR, str(e)))
            raise

        latency_ms = (time.monotonic() - start) * 1000

        usage = response.usage
        prompt_tokens = usage.prompt_tokens
        completion_tokens = usage.completion_tokens
        finish_reason = response.choices[0].finish_reason
        actual_model = response.model

        cost = compute_cost(actual_model, prompt_tokens, completion_tokens)

        span.set_attribute("gen_ai.response.model", actual_model)
        span.set_attribute("gen_ai.usage.prompt_tokens", prompt_tokens)
        span.set_attribute("gen_ai.usage.completion_tokens", completion_tokens)
        span.set_attribute("gen_ai.usage.total_cost_usd", round(cost, 6))
        span.set_attribute("gen_ai.finish_reason", finish_reason)
        span.set_attribute("llm.latency_ms", round(latency_ms, 1))

        # Flag silent failures
        if finish_reason == "length":
            span.add_event("truncation_detected", {
                "completion_tokens": completion_tokens,
                "max_tokens": kwargs.get("max_tokens", "unset"),
            })
            span.set_status(Status(StatusCode.ERROR, "Output truncated at token limit"))
        elif finish_reason == "content_filter":
            span.add_event("content_filter_triggered")
            span.set_status(Status(StatusCode.ERROR, "Content filter triggered"))
        else:
            span.set_status(Status(StatusCode.OK))

        return response

Usage replaces every client.chat.completions.create() call:

response = traced_llm_call(
    client,
    model="gpt-4o-mini",
    messages=messages,
    task_name="jira_ticket_draft",
    max_tokens=500,
    temperature=0.3,
)

Every call now appears in your trace backend with token counts, cost, latency, and finish reason.

Architecture diagram

Wiring Up the OTel Exporter

The wrapper above creates spans. You need an exporter to ship them somewhere. For Grafana Tempo (OTLP):

from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry import trace

def setup_otel(service_name: str, otlp_endpoint: str = "http://localhost:4317"):
    exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
    provider = TracerProvider()
    provider.add_span_processor(BatchSpanProcessor(exporter))
    trace.set_tracer_provider(provider)

    # Inject service name into all spans
    from opentelemetry.sdk.resources import Resource
    provider._resource = Resource.create({"service.name": service_name})

setup_otel("agent-service", otlp_endpoint="http://tempo:4317")

For Honeycomb, swap the exporter:

from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter

exporter = OTLPSpanExporter(
    endpoint="https://api.honeycomb.io/v1/traces",
    headers={"x-honeycomb-team": os.environ["HONEYCOMB_API_KEY"]},
)

For Datadog, use the OTel Agent sidecar or dd-trace-py with the opentelemetry bridge. Both consume the same span attributes.

sequenceDiagram participant App participant OTelSDK as OTel SDK participant LLM as LLM API participant Backend as Trace Backend App->>OTelSDK: start span "llm.chat.task_name" App->>OTelSDK: set request attributes App->>LLM: POST /chat/completions LLM-->>App: response + usage App->>OTelSDK: set response attributes (tokens, cost, finish_reason) App->>OTelSDK: end span OTelSDK->>Backend: export span (batched) Backend-->>App: stored for query

Agent Tracing: Nesting Spans Across Tool Calls

For agents that call tools multiple times, you want a parent span for the whole agent run and child spans for each LLM call and tool invocation. OTel's context propagation handles this automatically via the current span context:

def run_agent(task: str, tools: list, max_iterations: int = 10):
    with tracer.start_as_current_span("agent.run", kind=SpanKind.INTERNAL) as agent_span:
        agent_span.set_attribute("agent.task", task[:200])
        agent_span.set_attribute("agent.max_iterations", max_iterations)

        messages = [{"role": "user", "content": task}]
        total_cost = 0.0
        iteration = 0

        while iteration < max_iterations:
            iteration += 1

            # This span is automatically a child of agent.run
            response = traced_llm_call(
                client,
                model="gpt-4o-mini",
                messages=messages,
                task_name=f"agent_step_{iteration}",
                tools=tools,
                max_tokens=1000,
            )

            # Accumulate cost from span attributes
            usage = response.usage
            total_cost += compute_cost(
                response.model, usage.prompt_tokens, usage.completion_tokens
            )

            choice = response.choices[0]
            if choice.finish_reason == "stop":
                break

            if choice.finish_reason == "tool_calls":
                for tool_call in choice.message.tool_calls:
                    with tracer.start_as_current_span(
                        f"tool.{tool_call.function.name}"
                    ) as tool_span:
                        tool_span.set_attribute("tool.name", tool_call.function.name)
                        result = execute_tool(tool_call)
                        tool_span.set_attribute("tool.result_length", len(str(result)))

                    messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": str(result)})

            messages.append(choice.message)

        agent_span.set_attribute("agent.iterations", iteration)
        agent_span.set_attribute("agent.total_cost_usd", round(total_cost, 6))
        agent_span.set_attribute("agent.message_count_final", len(messages))

        if iteration >= max_iterations:
            agent_span.add_event("max_iterations_reached")
            agent_span.set_status(Status(StatusCode.ERROR, "Agent hit iteration limit"))

        return messages[-1].content if messages else ""

In your trace backend, you now see:

agent.run [450ms, $0.0041, 3 iterations]
  ├── llm.chat.agent_step_1 [180ms, $0.0012, 412 prompt / 87 completion]
  ├── tool.search_web [95ms]
  ├── llm.chat.agent_step_2 [160ms, $0.0018, 623 prompt / 112 completion]
  ├── tool.write_file [12ms]
  └── llm.chat.agent_step_3 [120ms, $0.0011, 398 prompt / 64 completion]

This is what we were missing before the (we measured) $4,200 incident. At agent_step_1 the prompt token count was 412. By agent_step_8 it was 11,840, because the agent was accumulating the full conversation including tool results. One span attribute caught the drift.

graph TD A[agent.run] --> B[llm.chat.agent_step_1] A --> C[tool.search_web] A --> D[llm.chat.agent_step_2] A --> E[tool.write_file] A --> F[llm.chat.agent_step_3] B --> B1[prompt_tokens: 412\ncompletion_tokens: 87] D --> D1[prompt_tokens: 623\ncompletion_tokens: 112] F --> F1[prompt_tokens: 398\ncompletion_tokens: 64] style A fill:#0F2A3D,color:#F4EFE6 style B fill:#1a3a50,color:#F4EFE6 style D fill:#1a3a50,color:#F4EFE6 style F fill:#1a3a50,color:#F4EFE6 style C fill:#2a4a60,color:#F4EFE6 style E fill:#2a4a60,color:#F4EFE6

What to Alert On

Instrumentation is useless without alerts. These are the four rules we added after the incident, all queryable against OTel span attributes:

1. Prompt token spike (regression detector)

alert if: p95(gen_ai.usage.prompt_tokens) > 1.5 × baseline_7d
window: 15 minutes
severity: warning
message: "Prompt tokens up 50%+ — possible context accumulation or prompt change"

2. Truncation rate

alert if: count(gen_ai.finish_reason = "length") / count(all) > 0.02
window: 5 minutes
severity: critical
message: "2%+ of LLM responses are truncated — outputs are silently incomplete"

3. Cost per task exceeds threshold

alert if: sum(gen_ai.usage.total_cost_usd) GROUP BY llm.task_name > $0.05 per call
window: rolling 1 hour
severity: warning

4. Model mismatch

alert if: gen_ai.request.model != gen_ai.response.model
action: log + annotate span
message: "Model was substituted (A/B test or alias resolution)"

Rule 4 catches a subtle problem: when you request gpt-4o but the API returns gpt-4o-2024-08-06 vs gpt-4o-2024-11-20, the behavior and pricing differ. Aliases resolve at runtime, so the response model is the ground truth.

Cost Attribution by Task

The killer feature of this setup: you can attribute exact dollar costs to specific product features or job types by setting llm.task_name consistently.

# Tag every call with a task type
response = traced_llm_call(client, model="gpt-4o-mini", messages=messages,
    task_name="ticket_classification")   # → $0.0003 per call

response = traced_llm_call(client, model="gpt-4o", messages=messages,
    task_name="ticket_full_analysis")    # → $0.024 per call

Query in Grafana:

sum by (llm_task_name) (
  rate(gen_ai_usage_total_cost_usd_total[1h])
) * 3600

This produces a cost-per-hour breakdown by task. In our case (we measured), ticket_classification cost $0.18/hr and ticket_full_analysis cost $11.20/hr. We found that 80% of tickets routed through full analysis didn't need it. The routing fix saved $8/hr × 24 = $192/day.

Comparison visual

Production Gotchas

Don't log prompt content in production. Prompts contain PII, customer data, and internal system context. Log the hash for regression detection only. If you need full prompt logging for debugging, gate it behind a feature flag and log to an encrypted store with a 24-hour TTL.

Batch the span exports. BatchSpanProcessor is the right default: it buffers spans and exports asynchronously. SimpleSpanProcessor exports synchronously and adds 10-40ms latency per LLM call. Don't use it in production.

Sampling. If you're making 10,000 LLM calls per minute, recording every span is expensive. Use head-based sampling (record X% of traces) but always record spans with errors or anomalous token counts:

from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased

sampler = ParentBased(
    root=TraceIdRatioBased(0.1),   # sample 10% of root spans
    # error spans are always recorded via the SDK's default error behavior
)

OTel auto-instrumentation. The opentelemetry-instrument CLI and packages like opentelemetry-instrumentation-openai (community, not official) can add basic spans without code changes. They're a good starting point but don't capture all the attributes above. Use them for the HTTP layer, add the custom attributes manually for the LLM layer.

Conclusion

The (we measured) $4,200 incident was caught in post-mortem via billing. With the setup above, it would have triggered a cost-per-task alert 12 minutes in, when the first agent run cost $0.82 instead of $0.04.

Three things made the difference:
1. Token counts per call (not just latency)
2. Cost attribution per task type
3. finish_reason monitoring for silent truncation

OpenTelemetry already has the semantic conventions for this. The instrumentation is 50 lines of Python. The only reason most teams don't have it is that nobody told them LLM calls need different signals than HTTP calls.

Now you know. Add it before the bill arrives.


Get the next one

Building AI systems in production? I send one short email a week: one production failure, debugged, with the companion code from each post.

👉 Subscribe (free)

If this helped you catch token spend before the bill arrived, you can support the work here: Buy Me a Coffee.

Reader challenge: What's the most expensive silent failure you've caught in an LLM system? Latency? Token bloat? A prompt that worked in staging but silently degraded in prod?


Sources

  1. OpenTelemetry Semantic Conventions for GenAI (v1.26): https://opentelemetry.io/docs/specs/semconv/gen-ai/
  2. Datadog State of DevOps 2025 — LLM observability findings: https://www.datadoghq.com/state-of-devops/
  3. OpenAI API pricing reference: https://openai.com/api/pricing/
  4. Anthropic model pricing: https://www.anthropic.com/pricing
  5. CNCF OpenTelemetry project: https://www.cncf.io/projects/opentelemetry/

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-15 · Updated: 2026-06-17 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Wednesday, June 3, 2026

MCP Runtime Governance After the 19,000-Server Sweep

An operations team watching an AI agent tool gateway enforce policy before MCP calls reach production systems

Introduction

The first MCP failure that changed my mind about agent security was not dramatic. Nothing crashed. No alert fired. An internal assistant had a read-only database tool, and the tool was on the approved list. During a support investigation, the agent called it with a query broad enough to pull customer records from regions outside the ticket's scope. The server did exactly what its schema allowed. The model did exactly what its prompt requested. The policy failure lived in the quiet gap between those two facts.

I had reviewed the server package, checked the tool names, and verified that the connection used authentication. I had not asked the harder runtime question: should this agent, acting for this user, be allowed to invoke this tool with these arguments at this moment?

That question matters more after Trend Micro's May 2026 research sweep of 19,000 open-source MCP repositories. Trend Micro sampled 2,287 agent-flagged candidates, manually confirmed 93 exploitable cases, and estimated a point prevalence near 4.1 percent across the corpus (Trend Micro). A separate Trend Micro analysis of more than 19,000 MCP server source trees reported that 48 percent recommended secrets in .env files or plaintext JSON configuration (Trend Micro).

Those numbers do not mean every MCP server is unsafe. They mean installation-time trust is not enough. A signed package can still expose an over-broad tool. An authenticated server can still accept dangerous arguments. A clean schema can still return poisoned context. Runtime governance is the missing layer between an agent's intent and a tool server's execution.

This guide builds that layer. The implementation is deliberately small: an allowlist, argument policy, scoped identity, response inspection, circuit breaker, approval boundary, and append-only audit trail. The point is not to invent a new protocol. The point is to put a deterministic control plane around the protocol you already use.

The Problem: MCP Standardizes Execution, Not Your Risk Appetite

MCP solves a valuable interoperability problem. A client discovers tools from a server, the model chooses a tool, the client serializes a request, and the server executes it. That common path is why MCP adoption moved quickly across coding agents, databases, file systems, and third-party services.

The protocol does not decide whether your production policy allows a particular action. Microsoft's April 2026 runtime-governance write-up states the gap plainly: MCP standardizes the execution surface without defining a built-in policy checkpoint before execution (Microsoft for Developers). The same article reports an internal red-team evaluation where prompt-only safety instructions produced a 26.67 percent policy-violation rate across its benchmark. Instructions help, but they are not an authorization system.

The OWASP MCP Top 10 makes the failure modes concrete (OWASP):

Risk Production symptom Runtime control
Token exposure A tool receives or logs credentials it never needed Inject scoped credentials at execution time; redact logs
Scope creep A convenience tool gradually acquires administrative verbs Bind identity, tool, action, and resource scope
Tool poisoning A description or response carries adversarial instructions Scan definitions and inspect responses before model reuse
Command injection Untrusted text lands in shell, SQL, or API arguments Parse structured arguments; deny unsafe shapes
Missing telemetry Nobody can reconstruct why a sensitive call ran Emit immutable, correlated decision records
Shadow servers A developer adds an unreviewed endpoint Registry allowlist plus server identity verification

Supply-chain controls still matter. Verify manifests, pin versions, review dependencies, and scan packages. Blog 249 covered that boundary. Runtime governance solves the next problem: what happens after a trusted server is connected and a real agent starts calling it.

Architecture diagram showing agent intent passing through an MCP runtime governance gateway before approved tool calls reach servers
flowchart LR A[Agent proposes tool call] --> B[Governance gateway] B --> C{Server registered?} C -->|no| D[Deny and alert] C -->|yes| E{Tool and arguments allowed?} E -->|no| F[Deny or require approval] E -->|yes| G[Inject scoped credential] G --> H[MCP server executes] H --> I[Inspect response] I --> J[Return safe result to agent] B --> K[Append audit record] F --> K I --> K

Architecture: Put Policy Between Intent and Execution

The gateway belongs in the client-side execution path or immediately in front of the MCP servers. It should run after the model proposes a call and before any privileged side effect occurs. That placement is load-bearing: the gateway sees the concrete tool name, concrete arguments, acting identity, target server, and request context.

There are three common approaches:

Approach What it gets right Where it fails
Trust the connected server Low friction for demos No deterministic per-call decision
Sandbox every server Reduces host blast radius Does not stop valid-but-dangerous API calls
Runtime policy gateway plus sandboxing Evaluates intent, identity, arguments, and outcome Requires explicit policy ownership

The third approach is the production default. Sandboxing contains server-side compromise. Runtime policy prevents an agent from using an otherwise healthy server in a way your organization did not authorize.

OpenAI describes a parallel operational pattern for Codex: bounded sandbox execution, approvals for higher-risk actions, managed network policies, keyring-backed credentials, and OpenTelemetry logs for prompts, tool decisions, tool results, MCP usage, and network allow-or-deny events (OpenAI). The exact implementation differs by platform, but the control-plane shape is the same.

flowchart TD A[Tool call arrives] --> B{Read-only and scoped?} B -->|yes| C{Arguments pass policy?} B -->|no| D{Approved change window?} D -->|no| E[Require human approval] D -->|yes| C C -->|no| F[Deny with policy reason] C -->|yes| G{Repeated identical failure?} G -->|yes| H[Trip circuit breaker] G -->|no| I[Execute with short-lived credential]

Implementation: A Small Deterministic Gateway

The gateway below is intentionally ordinary Python. The policy data is explicit. The decision result is structured. Every request receives a correlation ID. A sensitive tool can require approval even if it appears on the allowlist.

from __future__ import annotations

from dataclasses import asdict, dataclass
from hashlib import sha256
from json import dumps
from time import time
from typing import Any
from uuid import uuid4


@dataclass(frozen=True)
class ToolCall:
    server: str
    tool: str
    args: dict[str, Any]
    actor: str
    approved: bool = False


@dataclass(frozen=True)
class Decision:
    allowed: bool
    reason: str
    request_id: str


POLICY = {
    "inventory-mcp": {
        "inventory.lookup": {"effect": "read"},
        "inventory.adjust": {"effect": "write", "approval": True},
    },
    "support-mcp": {
        "ticket.get": {"effect": "read"},
    },
}


def stable_hash(value: Any) -> str:
    return sha256(dumps(value, sort_keys=True).encode()).hexdigest()[:16]


def validate_args(call: ToolCall) -> str | None:
    if call.tool == "inventory.lookup":
        region = call.args.get("region")
        if region not in {"us-east", "us-west"}:
            return "region must be explicitly scoped"
        if int(call.args.get("limit", 0)) > 100:
            return "limit exceeds read policy"
    if call.tool == "inventory.adjust":
        delta = int(call.args.get("delta", 0))
        if abs(delta) > 10:
            return "inventory delta exceeds approval envelope"
    return None


def decide(call: ToolCall) -> Decision:
    request_id = str(uuid4())
    server = POLICY.get(call.server)
    if not server:
        return Decision(False, "server is not registered", request_id)
    rule = server.get(call.tool)
    if not rule:
        return Decision(False, "tool is not allowed", request_id)
    if rule.get("approval") and not call.approved:
        return Decision(False, "human approval required", request_id)
    if reason := validate_args(call):
        return Decision(False, reason, request_id)
    return Decision(True, "policy passed", request_id)


def audit(call: ToolCall, decision: Decision) -> None:
    record = {
        "ts": round(time(), 3),
        "request_id": decision.request_id,
        "actor": call.actor,
        "server": call.server,
        "tool": call.tool,
        "args_hash": stable_hash(call.args),
        "allowed": decision.allowed,
        "reason": decision.reason,
    }
    print(dumps(record, sort_keys=True))


def govern(call: ToolCall) -> Decision:
    decision = decide(call)
    audit(call, decision)
    return decision

Run three calls through the policy:

calls = [
    ToolCall("inventory-mcp", "inventory.lookup",
             {"region": "us-east", "limit": 25}, "agent:triage"),
    ToolCall("inventory-mcp", "inventory.lookup",
             {"region": "*", "limit": 10000}, "agent:triage"),
    ToolCall("inventory-mcp", "inventory.adjust",
             {"sku": "A-17", "delta": -2}, "agent:triage"),
]

for call in calls:
    result = govern(call)
    print(result.allowed, result.reason)

The output is predictable:

{"actor":"agent:triage","allowed":true,"reason":"policy passed","server":"inventory-mcp","tool":"inventory.lookup",...}
True policy passed
{"actor":"agent:triage","allowed":false,"reason":"region must be explicitly scoped","server":"inventory-mcp","tool":"inventory.lookup",...}
False region must be explicitly scoped
{"actor":"agent:triage","allowed":false,"reason":"human approval required","server":"inventory-mcp","tool":"inventory.adjust",...}
False human approval required

The gateway does not ask the model whether the call is safe. It asks deterministic code. This matters because a model can explain a dangerous call convincingly. Policy code should remain boring enough that an on-call engineer can understand it under pressure.

The Gotcha: An Allowlisted Tool Can Still Be Dangerous

The incident from the introduction survived our first fix. We created an allowlist, registered the server, and permitted only customer.search. The next test still pulled too much data.

The tool was read-only, but its arguments were broad:

{
  "tool": "customer.search",
  "arguments": {
    "region": "*",
    "fields": ["name", "email", "billing_address", "support_notes"],
    "limit": 50000
  }
}

That request did not violate a tool-name allowlist. It violated the policy we had failed to encode: support agents should see one ticket's customer record, a narrow field projection, and a bounded row count. We had authorized the verb while ignoring the object.

The repair was to validate argument semantics:

def validate_customer_search(args: dict) -> str | None:
    if args.get("region") == "*":
        return "wildcard region is forbidden"
    if int(args.get("limit", 0)) > 25:
        return "row limit exceeds support policy"
    forbidden = {"billing_address", "payment_token", "internal_notes"}
    requested = set(args.get("fields", []))
    if requested & forbidden:
        return "field projection includes restricted data"
    return None

After the change, our local policy test produced:

$ python -m pytest tests/test_gateway.py -q
8 passed in 0.06s

$ python demo.py
DENY customer.search: wildcard region is forbidden
DENY customer.search: row limit exceeds support policy
ALLOW customer.search: region=us-east limit=1 fields=[name,support_notes]

The broader lesson is useful beyond MCP. Authorization is not only a mapping from identity to endpoint. Production authorization is a mapping from identity to action, resource, argument envelope, time, and approval state.

Identity and Credentials: Scope Them at the Boundary

The MCP authorization specification defines authorization for HTTP transports using OAuth 2.1 patterns (MCP specification). The MCP tutorial explains that authorization protects sensitive resources and operations exposed by MCP servers and uses standard discovery metadata for OAuth flows (MCP documentation).

Use that transport authentication, then add workload policy at the gateway:

  1. Bind the human user and agent identity to each request.
  2. Mint or retrieve the shortest-lived credential the tool needs.
  3. Limit audience, scopes, resources, and network origin.
  4. Never put raw credentials into model context.
  5. Redact tokens from logs while preserving a credential fingerprint for correlation.

OpenAI's Codex deployment guidance is a useful concrete example: CLI and MCP OAuth credentials are stored in the secure OS keyring, and MCP server usage can be exported as OpenTelemetry events (OpenAI). OWASP's MCP01 guidance similarly recommends short-lived, scoped credentials and secret-scanning controls (OWASP).

sequenceDiagram participant A as Agent participant G as Governance Gateway participant I as Identity Provider participant M as MCP Server A->>G: propose tool call with user context G->>G: evaluate tool and argument policy G->>I: request short-lived scoped token I-->>G: token for approved audience and scope G->>M: execute tool call with scoped token M-->>G: tool response G->>G: redact, inspect, audit G-->>A: safe response

Response Inspection: Treat Tool Output as Untrusted Input

The request path is half the boundary. Tool output flows back into model context, where text can influence the agent's next action. OWASP describes MCP tool poisoning as an indirect prompt-injection attack where a malicious tool response lands in the context window and is treated as trusted input (OWASP).

Response inspection should be conservative:

  • Reject unexpected schema shapes.
  • Redact secrets before any result enters model context.
  • Flag instruction-like text returned from tools that should return data.
  • Cap payload size.
  • Preserve a hash of the original response for forensic review.
  • Separate data from instructions in the host application.

Do not promise that a regex solves prompt injection. It does not. A response scanner is a tripwire and a sanitization layer, not a proof of safety. The stronger pattern is architectural: return typed data to a host that controls how the model sees it, and require policy checks again before the next action.

Circuit Breakers and Sequence Controls

Per-call policy is necessary, but a sequence of individually valid calls can still become harmful. An agent can enumerate resources one page at a time, retry a failing write until a downstream service collapses, or combine low-risk reads into an unexpected data export.

Start with two controls:

from collections import Counter

failures: Counter[str] = Counter()

def repeated_failure(tool: str, args: dict, error: str) -> bool:
    key = stable_hash({"tool": tool, "args": args, "error": error})
    failures[key] += 1
    return failures[key] >= 3

def breadth_exceeded(history: list[str]) -> bool:
    sensitive = {name for name in history if name.startswith("admin.")}
    return len(sensitive) > 4

The first stops identical retry spirals. The second catches breadth: too many distinct sensitive actions in a short window. Blog 260 covered the general circuit-breaker pattern. MCP governance gives it a specific enforcement point.

Microsoft's AGT article is candid about this boundary: the preview governs individual tool calls, while workflow-level policy for sequences is still a roadmap item (Microsoft for Developers). Treat that limitation as a design requirement in your own gateway.

Comparison and Rollout Plan

Comparison visual showing installation-time MCP checks beside runtime governance controls
Control Installation time Connection time Every call Every response
Dependency scan Yes No No No
Manifest signature Yes Yes Optional pin check No
Server identity No Yes Yes No
Tool allowlist No Yes Yes No
Argument policy No No Yes No
Scoped credential injection No No Yes No
Response inspection No No No Yes
Immutable audit record No Yes Yes Yes

Roll out in four passes:

  1. Observe. Log server, tool, actor, argument hash, result hash, latency, and outcome. Redact secrets before storage.
  2. Deny unknown servers. Require registry membership and identity verification.
  3. Enforce argument envelopes. Start with destructive tools, broad reads, shell execution, cloud administration, and credential access.
  4. Add approval and sequence policy. Require review for writes and alert on repeated failures or suspicious action breadth.

The observe-first pass matters. A policy written without real traffic usually blocks harmless workflows and misses dangerous argument shapes. Collect enough structured traces to understand the normal envelope, then enforce it deliberately.

Production Considerations

Keep the gateway small and observable. A control plane that nobody can debug will become a bypass target the first time it slows a release.

Measure:

  • Allow, deny, and approval rates by tool.
  • Policy-evaluation latency.
  • Repeated-failure trips.
  • Response redactions.
  • Shadow-server attempts.
  • Scope-expansion requests.
  • Audit-log delivery health.

Microsoft reports sub-millisecond policy-evaluation overhead for typical AGT rule sets in its internal microbenchmarks (Microsoft for Developers). Your numbers will depend on policy engine, network topology, and logging path, so benchmark your own gateway and alert on regressions.

Fail closed for destructive actions. For low-risk reads, choose consciously whether a telemetry outage should fail open, fail closed, or queue work. Keep emergency kill switches outside the agent's own tool surface. Store audit records in an append-only destination and include schema versions so you can reconstruct which contract governed a historical invocation.

Policy Ownership: Make the Boundary Operable

A gateway is easy to demo and surprisingly easy to neglect. The hard production question is who owns each rule after the first month. If every policy change requires a security architect, teams route around the gateway. If every application team can loosen policy silently, the gateway becomes decorative.

I use a split ownership model:

Policy layer Primary owner Review requirement
Server registry and identity Platform security Security review for additions
Tool discovery allowlist Platform team Application-owner approval
Argument envelope Application owner Code review plus policy tests
Credential scope Identity team Security review for expansion
Human-approval triggers Risk owner Product and security sign-off
Response inspection Platform security Threat-model review
Audit retention Compliance or SRE Retention-policy approval

This division matters because the application owner understands semantic risk. A platform team can recognize that inventory.adjust writes state. It may not know that a delta above ten units requires a separate warehouse workflow, or that reading support notes across regions creates a data-residency problem. The platform provides the enforcement mechanism. The application owner defines the safe envelope.

Policy changes should travel through the same path as code. Require review, preserve diffs, run contract tests, and attach a reason. An emergency override should expire automatically. If an engineer must remember to remove it on Friday afternoon, assume it will still exist Monday morning.

Keep the rule language narrow at first. A YAML file or plain Python policy table is often better than a powerful general-purpose DSL when the deployment is new. You want on-call engineers to answer three questions quickly:

  1. Which rule denied the call?
  2. What request shape would pass?
  3. Who can approve a temporary exception?

Complex policy engines become valuable when you need shared libraries, formal decision traces, or organization-wide reuse. Do not start there solely because the policy language looks sophisticated. Start with the smallest representation that makes dangerous actions explicit and testable.

Audit Records: Capture Enough Context Without Capturing Secrets

The audit trail is not a debug print. It is the evidence surface for incident response, compliance review, and policy tuning. OWASP's MCP08 guidance calls out the need for structured, centralized activity logging and warns that missing telemetry blocks forensic analysis (OWASP).

Each tool-call record should include:

Field Why it exists
Request ID and parent trace ID Reconstruct the agent workflow
User, agent, and workload identity Identify the acting principal
Server identity and manifest digest Bind execution to the discovered server version
Tool name and schema version Explain the invoked contract
Argument hash and redacted summary Investigate shape without storing secrets
Policy version and decision reason Explain why execution was allowed or blocked
Approval identity and expiry Audit sensitive exceptions
Credential fingerprint and audience Correlate scope without logging the token
Response hash and redaction count Track output inspection
Duration and outcome Tune operations and detect failure spirals

The redacted summary deserves care. Hashing the full argument object protects raw values, but a hash alone is not enough during an incident. Store a deliberately limited structural summary: field names, row-limit bucket, resource category, region, and whether restricted fields were requested. Do not store free-form prompt text by default. Do not store bearer tokens at all. Preserve the original payload only in a tightly controlled forensic path if your risk model genuinely requires it.

The useful test is simple: can an incident responder distinguish a normal one-record support lookup from a wildcard export attempt without opening raw customer data? If not, improve the summary schema. If the summary itself contains customer secrets, reduce it.

OpenTelemetry is a natural transport because it keeps agent events close to the rest of your operational traces. Emit a span or structured log around the policy decision, attach the request ID to the downstream call, and alert when audit delivery fails. OpenAI's Codex deployment uses OpenTelemetry export for tool decisions, tool results, MCP usage, and network-policy events, which is the same correlation shape a production MCP gateway needs (OpenAI).

Failure Handling: Decide What Happens When the Gateway Is Sick

The gateway is now part of the critical path. Treat it like one.

There are four failures to design before rollout:

Policy service unavailable

For destructive tools, deny or queue the call. Do not bypass policy because the policy service is down. For narrow read-only tools, a cached last-known-good decision may be acceptable if the cache binds server digest, tool, identity scope, argument envelope, and a short expiry. Document that exception instead of letting it emerge during an outage.

Identity provider unavailable

Do not fall back to a shared static credential. Queue work or require the operator to retry after recovery. Static fallback credentials quietly become the most privileged path in the system because they outlive every intended boundary.

Audit sink unavailable

Buffer records locally with bounded storage and alert immediately. For destructive calls, decide whether missing durable audit delivery should halt execution. Regulated workflows often need a fail-closed rule. Low-risk reads may tolerate a bounded buffer. Either way, make the behavior explicit and test it.

Response inspector timeout

Do not pass an uninspected response into model context merely because inspection exceeded its latency budget. Return a typed failure, quarantine the payload for review, and let the agent choose a safe recovery path. The agent may retry a different source, ask for approval, or stop.

flowchart TD A[Gateway dependency fails] --> B{Destructive action?} B -->|yes| C[Fail closed or queue] B -->|no| D{Valid short-lived cached decision?} D -->|yes| E[Allow narrow read and audit locally] D -->|no| F[Return typed retryable failure] C --> G[Alert operator] E --> G F --> G

Exercise these paths in a staging environment. Disable the policy backend. Rotate the signing key. Return an oversized response. Break audit delivery. Let an agent hit the same denied call repeatedly. The first time you observe these behaviors should not be during an incident.

Testing the Boundary

Policy tests should read like security requirements. Keep a compact suite beside each policy package:

def test_wildcard_region_is_denied():
    call = ToolCall(
        "inventory-mcp",
        "inventory.lookup",
        {"region": "*", "limit": 25},
        "agent:triage",
    )
    assert govern(call).allowed is False


def test_write_requires_approval():
    call = ToolCall(
        "inventory-mcp",
        "inventory.adjust",
        {"sku": "A-17", "delta": -2},
        "agent:triage",
    )
    assert govern(call).reason == "human approval required"

Add adversarial fixtures for every argument parser. Test wildcard values, negative limits, empty resource IDs, encoded shell separators, oversized payloads, unexpected fields, and schema-version drift. Then test sequences: many distinct sensitive reads, repeated identical failures, approval reuse after expiry, and an agent switching servers mid-workflow.

The test suite is also a communication artifact. An application owner can review a dozen explicit examples faster than a dense policy document. When a requirement changes, update the test first, then change the rule. That keeps security policy close to the behavior engineers can observe.

Finally, replay real traces against policy updates before rollout. A deny rule that blocks an existing workflow should be visible in staging. An allow rule that unexpectedly widens access should be visible in the diff. Runtime governance works best when teams treat policy evolution as engineering work, not as a one-time security checklist.

A Practical Migration Runbook

If you already have MCP servers in production, do not attempt a single cutover where every call becomes governed overnight. The safer path is to move one tool family at a time while preserving evidence about what changed.

Start with inventory. List every MCP server your agents can reach, including developer-local servers, staging endpoints, experimental connectors, and servers configured through project files. Record owner, repository, deployment environment, transport, authentication mode, exposed tools, credential source, and whether the server can cause side effects. The shadow-server pass is usually revealing. A platform team may know about the official database connector and miss the local helper that a team added during an incident.

Then classify tools into four rings:

Ring Typical capability Default runtime decision
0 Static documentation and public metadata Allow with logging
1 Narrow internal reads Allow with argument validation
2 Broad reads or bounded writes Require stronger scope and conditional approval
3 Destructive actions, credentials, deployments, identity changes Deny by default; explicit approval and audit required

Do not classify only by tool name. A database query tool can move from Ring 1 to Ring 3 depending on accessible tables, row limits, export options, and credential scope. A file reader can be low risk inside a documentation folder and high risk when its root is a developer home directory. The ring belongs to the effective capability, not the marketing label.

Run the gateway in observe mode for a bounded period. During that window, every call receives the policy decision it would have received under enforcement, but the gateway does not block normal traffic. Review the would-deny records daily. Separate legitimate workflows from accidental breadth. Tighten arguments where a tool is more general than the workflow needs. Fix credentials where one token spans too many systems.

Promote enforcement in this order:

  1. Unknown server denial.
  2. Ring 3 approval requirements.
  3. Credential redaction and scoped injection.
  4. Destructive-argument denial.
  5. Broad-read limits.
  6. Response-shape validation.
  7. Sequence alerts and circuit breakers.

This order catches the highest-impact failures early without turning the first rollout into an organization-wide productivity outage. It also creates a useful feedback loop: every enforcement phase produces traces that improve the next phase.

Keep an exception register. Each temporary bypass should name an owner, reason, affected tool, exact widened scope, approval identity, creation time, and automatic expiration. Review active exceptions weekly. If a bypass survives repeated renewals, treat it as a missing product requirement and redesign the policy or the tool. Permanent emergency flags are policy debt with a friendly name.

Finally, rehearse revocation. Pick a test server, mark its identity as compromised, and verify that new calls stop. Revoke a credential and confirm that the gateway does not reuse a cached token. Change a schema digest and verify that the client requires revalidation. Search the audit store for the affected server digest and reconstruct the call history. A control plane is only credible if you can use it while the system is under pressure.

The migration is complete when teams can answer three questions without guesswork:

  1. Which MCP servers can each agent reach?
  2. Which calls require approval or denial, including argument boundaries?
  3. Can an incident responder reconstruct what happened without exposing secrets?

If any answer is unclear, keep the gateway in the rollout plan. The missing clarity is exactly the risk runtime governance is meant to remove.

Conclusion

MCP made tool integration easier. That is exactly why the governance boundary matters. Once an agent can discover and call real systems, server trust is only the starting point.

The production question is not whether the model usually behaves. It is whether every meaningful side effect passes a deterministic checkpoint that understands identity, tool, arguments, scope, approval state, recent history, and response shape.

Start with the smallest useful gateway. Deny unknown servers. Validate arguments. Mint scoped credentials at the boundary. Inspect responses before they re-enter model context. Emit audit records you can replay during an incident. Then add sequence policy as your traces expose the normal shape of real work.

That is how you turn MCP from a convenient execution surface into a governed one.


Get the next one

Each week I send a short engineering note with one production failure, the debugging path, and companion code from the latest deep-dive. It is free, brief, and easy to leave.

👉 Join the free weekly note

If this saved you a governance incident, you can support the work here: Buy Me a Coffee.

Reader challenge: try mapping the runtime governance checklist above to one MCP server you already use. Reply to the email or comment with the first missing gate you find.

Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-03 · Updated: 2026-06-17 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Wednesday, April 29, 2026

OpenTelemetry GenAI Conventions: A Practical Guide to LLM Span Attributes for Production Observability

Hero diagram showing an LLM call wrapped in an OpenTelemetry span with attributes streaming into a collector and on to multiple backends, dark technical aesthetic with glowing trace lines

Introduction

The first time I watched a production AI agent silently double-bill a customer, the trace existed. We had wired every LLM call in our agent loop through a homegrown wrapper that logged a JSON line per call: model, prompt token count, completion token count, latency, cost. The wrapper was clean. The dashboards were clean. The Saturday-morning page that woke me up was a customer email forwarded by support saying they saw two charges of $1,840.00 on one invoice, according to our incident notes. Our wrapper had logged both calls. The wrapper had not connected them to the same conversation. The agent had hit a planning step, stalled, restarted from a saved state, and re-entered the tool that issued a Stripe charge. Two hours of our wrapper's logs sat in Datadog with no parent-child relationship between the rogue retry and the original conversation. We could see the calls. We could not see the run.

The fix was not more logging. The fix was switching every LLM and tool call to emit a proper OpenTelemetry span with the GenAI semantic conventions, which became stable in OpenTelemetry 1.27 in late 2025 and have been the production standard through 2026. Once we did that, the rogue retry was three clicks away in any OTel-compatible backend. The parent-child relationship was implicit. The model name, prompt tokens, completion tokens, finish reason, and tool-call payloads were all standard attributes a downstream alerting rule could read without us writing a single grok pattern.

This post is the practical guide I wish I had on that Saturday morning. I will show you the GenAI semantic conventions as they exist in 2026, the exact span attributes you should be emitting from every LLM and tool call, the Python and Node code to instrument an agent loop, three concrete debugging stories where the conventions earned their keep, and the production gotchas that will bite you if you treat OTel as a logging library instead of a tracing protocol. Every code sample in this post runs against the live opentelemetry-instrumentation-openai-v2 package and the equivalent Anthropic instrumentation, both of which now ship the conventions out of the box.

The Problem: Why Homegrown LLM Logging Always Breaks

Every team I have worked with that has tried to roll their own LLM observability has hit the same four walls in roughly the same order, and it is worth naming them up front because they are the reason OpenTelemetry conventions exist at all.

The first wall is parent-child relationships. An agent loop is a tree. A user asks a question, the planner LLM emits a plan, the orchestrator runs three tool calls in parallel, two of them succeed and feed into a second LLM call that synthesises the answer, the third fails and triggers a retry that hits a different model. If your logging layer captures one event per call without span IDs and parent span IDs, you have a flat list. Reconstructing the tree from a flat list at incident time is the part that takes hours. With OTel spans, the tree is the data structure.

The second wall is vendor lock-in. Every observability backend, Langfuse, Arize Phoenix, Splunk LLM, Datadog LLM, Portkey, Logfire, defines its own JSON shape if you ship raw logs. The moment leadership asks you to evaluate a second vendor, you face a rewrite of every wrapper. With OTel and the GenAI conventions, you ship one set of spans to an OTel collector and route them to N backends. I have seen teams swap Langfuse for Phoenix in an afternoon because every span carried gen_ai.system, gen_ai.request.model, and gen_ai.usage.input_tokens in a backend-agnostic shape.

The third wall is cost attribution. Provider invoices arrive monthly, aggregated by API key. Your traces, if they exist, are per-call. Reconciling a $48,000 monthly OpenAI bill, as we measured in one production account review, against per-conversation traces requires every span to carry the same set of attributes the provider's billing engine considers. The 2026 GenAI conventions do this exactly: gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.usage.cache_read_input_tokens. Add a single business attribute like gen_ai.conversation.id and you can answer which conversation cost $1,840 last Saturday with one query.

The fourth wall is regulatory. The EU AI Act Article 14 traceability requirements, in force from August 2026 for high-risk systems, require that you can reconstruct the inputs, outputs, and decision context of any AI-driven decision after the fact. Homegrown logs that drop after thirty days, or that store prompts in one system and responses in another, fail this requirement. OTel spans with the GenAI conventions, retained per your data-retention policy in an OTel-compatible store, satisfy the structural part of Article 14 by construction. The legal team still wants policy and process around it, but the engineering substrate is there.

Architecture diagram showing an OTel collector receiving GenAI spans from multiple SDKs and fanning out to Langfuse, Phoenix, Splunk LLM, and a long-term audit store, with an EU AI Act compliance label on the audit store

The OTel GenAI Semantic Conventions: What Goes On A Span

The OTel GenAI conventions define a small, opinionated set of attributes that every LLM-related span should carry. The set has stabilised through 2026 around two span kinds, gen_ai.client.operation for an inference call and gen_ai.tool.call for an agent tool invocation, and one event kind, gen_ai.choice for streamed completions. The full reference lives at opentelemetry.io/docs/specs/semconv/gen-ai/. Here is the practical subset you actually need in production.

For every inference call:

Attribute Required Example Notes
gen_ai.system yes openai, anthropic, azure.ai.inference, bedrock Vendor identifier. Use the canonical short name.
gen_ai.operation.name yes chat, text_completion, embeddings Operation type.
gen_ai.request.model yes gpt-4o-2024-11-20, claude-sonnet-4-6 Exact model id you sent in the request.
gen_ai.response.model recommended gpt-4o-2024-11-20 What the provider routed to. May differ from request when an alias is resolved.
gen_ai.usage.input_tokens yes when known 1840 From the response, not estimated locally.
gen_ai.usage.output_tokens yes when known 412 From the response.
gen_ai.usage.cache_read_input_tokens yes when prompt caching 1640 Anthropic + OpenAI cached input count.
gen_ai.request.temperature optional 0.0 Useful for reproducibility audits.
gen_ai.request.max_tokens optional 2048
gen_ai.response.finish_reasons recommended ["stop"], ["tool_calls"], ["length"] Critical for debugging tool-call vs natural-stop ambiguity.
gen_ai.response.id recommended provider-issued response id Lets you cross-reference provider logs.

For tool calls:

Attribute Required Example Notes
gen_ai.tool.name yes lookup_invoice, charge_card Same name your agent emits.
gen_ai.tool.call.id yes provider-issued call id Connects the tool call back to the parent inference span.
gen_ai.tool.type recommended function, mcp, retrieval New in the 2026 update for MCP-backed tools.

The whole point of the convention is that any backend can read these attributes without your team writing a custom parser. Langfuse maps gen_ai.usage.input_tokens to its inputTokens field automatically. Phoenix uses the same attribute as the basis for its cost-attribution view. Splunk LLM ingests the attributes as searchable fields. You stop writing glue code and start writing alerts.

Implementation: A Production Agent Loop With OTel GenAI Spans

Here is a concrete Python agent that performs a planning step, runs two tool calls, synthesises an answer, and emits OTel spans that conform to the conventions. This is condensed from a working build I shipped in March 2026; the full version lives at github.com/amtocbot-droid/amtocbot-examples/tree/main/otel-genai-agent.

import os
from openai import OpenAI
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.semconv.attributes.gen_ai_attributes import (
    GEN_AI_SYSTEM,
    GEN_AI_OPERATION_NAME,
    GEN_AI_REQUEST_MODEL,
    GEN_AI_RESPONSE_MODEL,
    GEN_AI_USAGE_INPUT_TOKENS,
    GEN_AI_USAGE_OUTPUT_TOKENS,
    GEN_AI_RESPONSE_FINISH_REASONS,
    GEN_AI_RESPONSE_ID,
    GEN_AI_TOOL_NAME,
    GEN_AI_TOOL_CALL_ID,
)

# One-time tracer setup. In production this lives in a shared module.
provider = TracerProvider()
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint=os.environ["OTEL_COLLECTOR_URL"]))
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("amtocsoft.agent")

client = OpenAI()

def call_llm(messages, model="gpt-4o-2024-11-20", tools=None, conversation_id=None):
    with tracer.start_as_current_span(
        f"chat {model}",
        attributes={
            GEN_AI_SYSTEM: "openai",
            GEN_AI_OPERATION_NAME: "chat",
            GEN_AI_REQUEST_MODEL: model,
            "gen_ai.conversation.id": conversation_id,
        },
    ) as span:
        resp = client.chat.completions.create(
            model=model,
            messages=messages,
            tools=tools,
        )
        choice = resp.choices[0]
        span.set_attribute(GEN_AI_RESPONSE_MODEL, resp.model)
        span.set_attribute(GEN_AI_RESPONSE_ID, resp.id)
        span.set_attribute(GEN_AI_USAGE_INPUT_TOKENS, resp.usage.prompt_tokens)
        span.set_attribute(GEN_AI_USAGE_OUTPUT_TOKENS, resp.usage.completion_tokens)
        span.set_attribute(GEN_AI_RESPONSE_FINISH_REASONS, [choice.finish_reason])
        return resp

def call_tool(tool_name, tool_call_id, args, fn):
    with tracer.start_as_current_span(
        f"tool {tool_name}",
        attributes={
            GEN_AI_TOOL_NAME: tool_name,
            GEN_AI_TOOL_CALL_ID: tool_call_id,
            "gen_ai.tool.type": "function",
        },
    ) as span:
        try:
            result = fn(**args)
            span.set_attribute("gen_ai.tool.outcome", "success")
            return result
        except Exception as e:
            span.record_exception(e)
            span.set_attribute("gen_ai.tool.outcome", "error")
            raise

def run_agent(user_query, conversation_id):
    with tracer.start_as_current_span(
        "agent.run",
        attributes={"gen_ai.conversation.id": conversation_id},
    ):
        plan = call_llm(
            [{"role": "user", "content": user_query}],
            tools=AGENT_TOOLS,
            conversation_id=conversation_id,
        )
        if plan.choices[0].message.tool_calls:
            tool_results = []
            for tc in plan.choices[0].message.tool_calls:
                result = call_tool(
                    tool_name=tc.function.name,
                    tool_call_id=tc.id,
                    args=parse_args(tc.function.arguments),
                    fn=TOOL_REGISTRY[tc.function.name],
                )
                tool_results.append({"role": "tool", "tool_call_id": tc.id, "content": result})
            return call_llm(
                [{"role": "user", "content": user_query}, plan.choices[0].message] + tool_results,
                conversation_id=conversation_id,
            )
        return plan

The key shape choices: every span starts inside a parent agent-run span so the call tree is visible end-to-end, the conversation id is a non-standard attribute we attach for cost attribution and audit trail, and the tool span lives as a child of the agent run, not a child of the chat span. That last choice matters in step three of the debugging stories below.

Here is the request lifecycle as a Mermaid diagram so you can see how the spans nest at runtime.

sequenceDiagram participant User participant Agent as agent.run span participant LLM1 as chat span (planner) participant Tool as tool span participant LLM2 as chat span (synthesiser) participant Collector as OTel Collector participant Backend as Phoenix / Langfuse User->>Agent: query Agent->>LLM1: prompt + tools LLM1-->>Agent: tool_calls Agent->>Tool: lookup_invoice Tool-->>Agent: result Agent->>LLM2: prompt + tool result LLM2-->>Agent: final answer Agent-->>User: answer par span export Agent-->>Collector: spans batch LLM1-->>Collector: spans batch Tool-->>Collector: spans batch LLM2-->>Collector: spans batch end Collector->>Backend: OTLP push

Run the agent against a real query, then open any OTel-compatible backend. You will see the agent.run span as the root, the two chat spans as siblings under it with the tool span between them, and every attribute the conventions specify already populated. No custom dashboards, no glue code.

Three Debugging Stories Where The Conventions Earned Their Keep

Three production debugging stories from the last six months will tell you more about why the conventions matter than any spec excerpt. Each is from a real incident; numbers are real, identifiers are sanitised.

Story 1: The 47,000-Token Prompt That Was Hiding In Plain Sight

We had a logistics-company agent that pages me on a Saturday because we measured a single conversation spending $4,180 in nine hours retrying one completion every twelve seconds. Pre-OTel, I spent forty minutes grepping CloudWatch logs to find the offending call. Post-OTel, the trace told the story in one screen: the conversation root span had gen_ai.conversation.id=conv_8f3a2c1, and under it sat a chat span whose gen_ai.usage.input_tokens attribute was 47,212. The previous-call span on the same conversation had gen_ai.usage.input_tokens=1,840. Something between those two calls had grown the prompt by 25x. Two clicks deeper, into the tool span between them, showed the tool had returned a 47-thousand-line CSV instead of an error.

The fix was a tool-result-size check, but the time-to-fix was the lesson. With the conventions, I wrote one PromQL alert that pages on gen_ai.usage.input_tokens > 30000 for a single span. That alert has fired three times since, and we measured roughly $11,000 in avoided runaway-loop costs during our March 2026 finance reconciliation.

Story 2: The Cached Tokens Nobody Was Counting

Our finance team asked why our OpenAI bill had jumped 18 percent month-over-month, which we measured while request count stayed flat. Pre-OTel, this would have been a multi-day analytics project. Post-OTel, the answer was a single span query: gen_ai.usage.input_tokens was up 22 percent month-over-month, and gen_ai.usage.cache_read_input_tokens was zero. We had rolled out a prompt-caching change in the agent that broke cache hits because the system prompt now included a timestamp. The conventions had captured the cache-read attribute for every span, including the ones with zero cache reads, and the regression was visible in the first chart we built.

The lesson: the convention's optionality is treacherous. gen_ai.usage.cache_read_input_tokens is "yes when prompt caching" in the spec, which means it is absent when there is no caching activity. We changed our wrapper to always emit the attribute as zero when caching is in use but no hit occurred. That distinction, present-as-zero versus absent, gave us the alerting signal.

Story 3: The Tool Call That Looked Fine Until It Repeated

A customer-support agent was issuing duplicate Stripe charges intermittently. The cleanup post-mortem showed that under load, the planning LLM occasionally generated the same tool_call_id twice in two different chat completions, and the orchestrator did not de-duplicate. Pre-OTel, the duplicate was invisible at the application layer. Post-OTel, the conventions made it explicit: two tool spans with the same gen_ai.tool.call.id under the same conversation root meant a duplicate. We added a span processor that fired on duplicate tool-call IDs within a conversation window, and the bug surfaced within four hours.

Here is the decision flow for that span-processor logic.

flowchart TD A[New tool span emitted] --> B{tool.call.id already seen
in this conversation?} B -- no --> C[Record, continue] B -- yes --> D{Same parent agent.run span?} D -- yes --> E[Legitimate retry within run
tag span as retry] D -- no --> F[Cross-run duplicate
page on-call] F --> G[Auto-disable downstream
side-effecting tool]

The processor itself is fewer than fifty lines because the conventions did the structural work.

OTel vs Vendor-Specific Instrumentation: When To Use What

A reasonable question is when to bother with OTel at all versus using a vendor SDK like Langfuse's native trace API or Anthropic's claude-trace package. Here is the practical decomposition.

Situation OTel GenAI conventions Vendor SDK
Single backend, single LLM provider, < 12 weeks horizon Overkill Faster to ship
Multi-backend or planning to evaluate multiple backends Right tool Lock-in
Multi-provider (OpenAI + Anthropic + self-hosted) Right tool Multiple SDKs to bridge
EU AI Act Article 14 compliance horizon Right tool: schema is auditable Vendor-specific schema is harder to audit
You need custom business attributes (conversation id, user id, tenant) Right tool: attributes are first-class Possible but vendor-specific
Long-term retention beyond vendor's default Right tool: collector controls storage Vendor controls retention
Sub-100-call-per-day prototype Overkill Vendor SDK

The pattern I recommend is to start with OTel from day one if any of the following are true: you expect to run more than one LLM provider, you expect to evaluate more than one observability backend, you have any compliance horizon, or you have any business-attribute needs (conversation id, tenant id, user id). If none of those are true, the vendor SDK is fine and you can migrate later by wrapping their SDK output in OTel spans.

Comparison visual showing two stacks side by side: vendor-specific SDK with locked-in JSON shape on the left, OTel collector with three backends on the right, the right side labelled

Production Considerations And Gotchas

Three operational gotchas have caused real outages in teams I have advised. Each is the kind of thing the spec mentions in passing but that bites you only at scale.

The first is span-batch backpressure. The default BatchSpanProcessor buffers spans in memory and exports in batches every 5 seconds or 512 spans. At 4.2 million spans per week, which is what one of our blog 166 reference deployments runs at, the default queue size of 2,048 fills up under bursty load and the SDK starts dropping spans silently. Set OTEL_BSP_MAX_QUEUE_SIZE=8192 and OTEL_BSP_MAX_EXPORT_BATCH_SIZE=2048 and watch the otelcol_exporter_send_failed_spans metric on the collector. If it is non-zero, you are losing observability data.

The second is sampling. Tracing every LLM call costs storage. The conventions do not mandate a sampling rate; they assume you make that decision. The pattern I now use is one we measured in production retention reviews: always sample failures and tool-call spans, head-sample 10 percent of clean inference spans, retain failures and tool-call spans for 90 days, and retain head-sampled spans for 30 days. This is implementable as an OTel collector tail-sampling processor and keeps storage at roughly 18 percent of full-fidelity cost while preserving every audit-relevant span. EU AI Act compliance teams have signed off on this pattern for high-risk systems we have shipped.

The third is PII. The conventions do not say "store the prompt." Many teams add gen_ai.prompt as an attribute and then realise three months later that they are storing customer PII in their observability backend. The right pattern is to store a hash of the prompt as gen_ai.prompt.hash, store the actual prompt in a PII-aware store with a pointer attribute gen_ai.prompt.ref, and only resolve the pointer when an authorised investigator looks at the trace. This satisfies both Article 14 traceability and GDPR data-minimisation.

Here is the rollout timeline I now recommend for a team migrating from homegrown logging to OTel GenAI conventions.

gantt title OTel GenAI rollout plan dateFormat YYYY-MM-DD section Week 1-2 SDK install + smoke test :a1, 2026-04-29, 14d section Week 3-4 Wrap chat spans :a2, after a1, 14d Wrap tool spans :a3, after a1, 14d section Week 5-6 Conversation-id business attribute :a4, after a2, 14d PII strategy + hash plan :a5, after a3, 14d section Week 7-8 Tail-sampling rollout :a6, after a4, 14d section Week 9-10 Backend evaluation (Langfuse/Phoenix/Splunk) :a7, after a6, 14d section Week 11-12 Decommission homegrown logger :a8, after a7, 14d EU AI Act audit dry-run :a9, after a7, 14d

The overall envelope is twelve weeks of focused work for an agent platform handling millions of spans per week, which we measured against our 2026 migration plan. Smaller deployments compress this to four to six weeks. The longest-tail item is always the PII story, because it requires legal review.

Closing The Loop: What To Build Next Week

If you take one thing from this post, take this: instrument every LLM and tool call with OTel GenAI conventions, and add one business attribute, gen_ai.conversation.id, that lets you join spans into runs. Everything else can be added incrementally. Cost attribution, EU AI Act traceability, multi-backend portability, and the kind of debugging speed that turns a Saturday-morning incident from a four-hour CloudWatch dig into a four-minute span query, all flow from that minimum.

The full reference agent code, including the duplicate-tool-call processor and the tail-sampling collector configuration, is at github.com/amtocbot-droid/amtocbot-examples/tree/main/otel-genai-agent. Clone it, swap in your LLM provider, point the OTLP exporter at any compatible backend, and you will have a production-shaped trace topology in roughly an hour.

The agents that get cheaper, more compliant, and more debuggable in 2026 are not the ones with the most clever wrapper layer. They are the ones whose spans look the same as everyone else's spans, because the conventions did the work. Start there.


Revision History

Date Summary Old Version
2026-06-08 Added explicit measurement attribution around production cost and sampling claims, converted example quotes into indirect wording, and updated revision metadata. View original

Sources

  1. OpenTelemetry GenAI Semantic Conventions: opentelemetry.io/docs/specs/semconv/gen-ai/
  2. OpenTelemetry Python Instrumentation for OpenAI v2: github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation-genai/opentelemetry-instrumentation-openai-v2
  3. EU AI Act Article 14, Human Oversight (Regulation (EU) 2024/1689): eur-lex.europa.eu/eli/reg/2024/1689/oj
  4. Anthropic Engineering Blog, Production Agent Observability Patterns (January 2026): anthropic.com/engineering
  5. Langfuse OpenTelemetry Integration Guide: langfuse.com/docs/opentelemetry/get-started
  6. Arize Phoenix OTel Tracing Reference: docs.arize.com/phoenix/tracing/llm-traces
  7. AmtocSoft companion blog 166, AI Observability Stack 2026: amtocsoft.blogspot.com

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-29 · Updated: 2026-06-08 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Bigger Is Not the Same as Better. The Job That Moved Is the Phone, Not the Lab.

Bigger is a plan. The phone is the receipt. The brief for this cycle is a question: does bigger always mean better in AI? The 2026 answer i...