← AI Agents Production Engineering
🤖 AI Agents

LLM Observability with OpenTelemetry for Agents: Production Guide

Source: mortalapps.com
TL;DR
  • OpenTelemetry-based LLM observability provides a vendor-neutral standard for tracing, logging, and monitoring complex agentic workflows.
  • It solves the problem of opaque, non-deterministic agent execution loops and proprietary vendor lock-in by standardizing telemetry collection.
  • By implementing the emerging gen_ai.* semantic conventions, teams can unify performance tracking across diverse LLM providers and custom tools.
  • This approach enables precise latency, cost, and error attribution across complex multi-agent directed acyclic graphs (DAGs).
  • The primary tradeoff is the minor instrumentation overhead and the requirement to carefully manage sensitive prompt data within exported spans.

Why This Matters

In production agentic systems, traditional application performance monitoring (APM) fails to capture the non-deterministic, multi-turn nature of LLM execution. Implementing robust llm observability opentelemetry standards is critical because agents do not operate in simple request-response cycles; they execute complex loops of reasoning, tool execution, state transitions, and recursive LLM calls. Without standard instrumentation, debugging a failed agent run or identifying why a specific tool call latency spiked becomes an exercise in parsing unstructured logs.

OpenTelemetry (OTel) was invented to provide a vendor-neutral, open-source standard for collecting telemetry data. By extending OTel to generative AI through the emerging gen_ai semantic conventions, engineering teams can capture structured traces, metrics, and logs without being locked into proprietary observability platforms. If you ignore standardized OTel instrumentation in production, your monitoring architecture will fragment. You will end up with custom, brittle logging wrappers that break whenever you switch LLM providers or update agent frameworks.

Using OpenTelemetry for agent observability is the preferred approach over vendor-specific SDKs because it decouples telemetry generation from telemetry ingestion. Whether your backend is Datadog, Honeycomb, Dynatrace, Jaeger, or an internal Prometheus/Grafana stack, the application code remains identical. This decoupling is vital for enterprise compliance, cost attribution, and long-term architectural flexibility, especially when managing multi-agent systems where execution spans cross multiple microservices and organizational boundaries.

Core Concepts

To build a production-grade observability pipeline for AI agents, you must understand how OpenTelemetry concepts map to agentic behaviors.

  • TracerProvider and Tracer: The TracerProvider is the stateful factory that manages the configuration of your telemetry pipeline, including exporters and processors. A Tracer is obtained from the provider and is used to start and end spans. In an agent application, a single global TracerProvider is initialized at startup, and individual modules use localized Tracers to instrument agent loops.
  • Spans and Parent-Child Relationships: A span represents a single unit of work. In agentic systems, spans are nested hierarchically. The root span represents the overall user request or agent task. Child spans represent individual reasoning steps, LLM generation calls, vector database queries, or tool executions.
  • **Semantic Conventions (gen_ai.*)**: These are standardized attribute keys defined by the OpenTelemetry community to ensure consistent naming across different LLM providers. They prevent a fragmentation where one system logs model_name and another logs request.model.
  • Context Propagation: The mechanism by which trace identifiers (Trace ID, Span ID) are passed across thread boundaries, asynchronous event loops, or network boundaries (e.g., HTTP headers). This ensures that a multi-agent system executing across distinct microservices maintains a single, continuous trace.
  • Span Processors and Exporters: A SpanProcessor (such as BatchSpanProcessor) intercepts spans when they end and batches them to avoid blocking the application's execution path. The Exporter (such as OTLPSpanExporter) transmits the batched spans over gRPC or HTTP to an OTel Collector or APM backend.
Agent Concept OpenTelemetry Representation Key Semantic Attributes
Agent Loop Run Root Span service.name, session.id, agent.name
LLM Generation Child Span (Internal/Client) gen_ai.system, gen_ai.request.model, gen_ai.usage.input_tokens
Tool Execution Child Span (Internal/Client) tool.name, tool.input, error.type
RAG Retrieval Child Span (DB/Client) db.system, db.operation, db.vector.query

How It Works

The lifecycle of an instrumented agent run follows a structured path from initialization to telemetry ingestion. Understanding this internal flow is critical for debugging missing traces or high latency overhead.

Phase 1: Pipeline Initialization

At application startup, the OpenTelemetry SDK is configured. A TracerProvider is instantiated and bound to a BatchSpanProcessor. This processor is configured with an OTLPSpanExporter pointing to an OTel Collector or APM endpoint. The global tracer is registered, allowing any module in the codebase to retrieve it without re-passing configuration objects.

Phase 2: Root Span Creation and Context Binding

When a user or system triggers an agent run, the orchestrator starts a root span. This span is marked with attributes identifying the agent's identity, the session ID, and the high-level task. The SDK binds this span to the current execution context (using thread-local storage in synchronous Python or context variables in asyncio). Any subsequent spans created within this execution context automatically inherit the root span's Trace ID and establish a parent-child relationship.

Phase 3: LLM Call Instrumentation

When the agent decides to query an LLM, the wrapper or SDK interceptor starts a child span. Before the network request is sent, the client-side attributes are populated (e.g., gen_ai.request.model, gen_ai.request.temperature). The request is sent to the LLM provider. Once the response is received, the span is updated with server-side attributes (e.g., gen_ai.response.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens). If the LLM call fails due to rate limits or API errors, the span status is set to ERROR, the exception is recorded as a span event, and the span is closed.

Phase 4: Tool Execution and Error Capture

If the LLM response contains a tool call, the agent's execution engine intercepts this and starts a tool-specific child span. The tool's name and arguments are recorded. If the tool executes successfully, the output is captured (subject to redaction policies) and the span ends. If the tool raises an exception, the exception is caught, recorded on the span, the span status is set to ERROR, and the exception is re-raised or handled by the agent's self-correction logic.

Phase 5: Asynchronous Exporting

When a span ends, it is not immediately sent over the network. Instead, the BatchSpanProcessor places it into an in-memory queue. A background worker thread periodically flushes this queue, serializing the spans into the OpenTelemetry Protocol (OTLP) format (typically Protobuf over gRPC or HTTP/JSON) and sending them to the OTel Collector. This ensures that telemetry collection does not add blocking latency to the agent's execution path.

Architecture

The instrumentation architecture consists of four distinct layers: the Agent Application, the local OpenTelemetry SDK, the OpenTelemetry Collector, and the Observability Backend.

Execution starts within the Agent Application, where the agent orchestrator, LLM clients, and custom tools reside. The application is wrapped by the OpenTelemetry SDK. When the orchestrator initiates a run, the SDK generates a Trace ID and starts the root span. As execution flows through the agent's state machine, the SDK manages the context propagation, ensuring that asynchronous tasks or multi-threaded tool executions carry the correct parent span context.

The SDK's BatchSpanProcessor collects ended spans in an in-memory ring buffer. Periodically, or when the buffer reaches a configured threshold, the processor flushes the spans to the OTLPSpanExporter. The exporter serializes the spans and transmits them via gRPC or HTTP to the OpenTelemetry Collector.

The OpenTelemetry Collector runs as a sidecar or a centralized gateway service. It consists of a receiver (which accepts the OTLP data), processors (which perform batching, memory limiting, attribute filtering, and PII redaction), and exporters (which translate the OTel data into vendor-specific formats). The Collector then pushes the processed telemetry to the final Observability Backend (such as Jaeger for local debugging, or Datadog, Honeycomb, or Dynatrace for production monitoring), where engineers can query, visualize, and alert on the trace data.

The Anatomy of gen_ai Semantic Conventions

To achieve true vendor-neutral observability, your instrumentation must strictly adhere to the OpenTelemetry semantic conventions for generative AI. These conventions define a standardized set of attributes that must be attached to spans representing LLM interactions. Using these standardized keys allows APM platforms to automatically parse your telemetry and generate out-of-the-box dashboards for token usage, cost, and latency.

The core attributes are divided into request and response categories:

  • gen_ai.system: Identifies the provider or system being queried (e.g., openai, anthropic, cohere, azure_openai, bedrock).
  • gen_ai.request.model: The specific model requested by the client (e.g., gpt-4o, claude-3-5-sonnet).
  • gen_ai.response.model: The actual model that generated the response, which may differ from the requested model due to routing or aliasing.
  • gen_ai.request.temperature: The temperature setting used for the generation.
  • gen_ai.usage.input_tokens: The number of prompt tokens processed.
  • gen_ai.usage.output_tokens: The number of completion tokens generated.

When instrumenting an agent, these attributes must be set programmatically on the span before it is closed. For example, when using the OpenAI Python SDK, you extract these values from the Completion response object and write them to the active span using span.set_attribute().

Context Propagation Across Distributed Agent Nodes

In multi-agent systems, workflows are rarely confined to a single process. An orchestrator agent might call a specialized sub-agent running on a separate microservice, or delegate a task to an external tool server via the Model Context Protocol (MCP). To maintain a single, unbroken trace across these boundaries, you must implement context propagation.

OpenTelemetry uses the W3C Trace Context specification as its default propagation format. This specification defines two HTTP headers:

  1. traceparent: A structured string containing the version, Trace ID, Parent Span ID, and trace flags (e.g., 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01).
  2. tracestate: A set of key-value pairs used to carry vendor-specific filtering and routing information.

When an agent makes an outbound HTTP request to another service, the client-side instrumentation injects the current context into the request headers. On the receiving end, the server-side instrumentation extracts this context and uses it as the parent for its own root span. Here is the conceptual flow of context injection and extraction:

# Client-side Injection
from opentelemetry.propagate import inject
headers = {}
inject(headers)
# headers now contains {'traceparent': '...'}
response = requests.post("http://sub-agent-service/run", headers=headers)

# Server-side Extraction
from opentelemetry.propagate import extract
context = extract(request.headers)
with tracer.start_as_current_span("sub_agent_run", context=context):
    # This span is now correctly linked to the client's trace
    execute_sub_agent_logic()

Instrumenting Non-Deterministic Agent Loops (ReAct, State Machines)

Agent architectures like ReAct (Reasoning and Acting) or state-machine-based pipelines present a unique challenge for tracing. Because the execution path is non-deterministic and can loop multiple times before reaching a terminal state, a flat trace structure becomes unreadable.

To solve this, you must structure your spans to reflect the logical iterations of the loop. The overall agent execution should be represented by a long-lived parent span. Each iteration of the loop should be wrapped in a distinct child span (e.g., agent_loop_iteration). Within each iteration, you create nested child spans for the reasoning phase (LLM call) and the action phase (tool execution).

This hierarchical structuring allows you to easily isolate which specific iteration of a loop caused a failure or introduced unexpected latency. If an agent gets stuck in an infinite loop, the trace will clearly show a repeating pattern of identical child spans, making it simple to identify the root cause of the loop budget exhaustion.

Handling Streaming Responses in OpenTelemetry

Streaming responses (using Server-Sent Events) are standard in production agent systems to minimize perceived latency (Time to First Token). However, streaming complicates telemetry collection because the final token counts and completion details are not available when the initial response starts.

To instrument streaming calls correctly, you must keep the LLM span open while the stream is being consumed. As chunks are read from the stream, you accumulate the generated text. Once the stream is fully consumed, you calculate or extract the final token counts, set the corresponding gen_ai.usage.* attributes, and then end the span.

If the stream is aborted prematurely by the client or interrupted by a network error, you must record this partial execution. Set the span status to ERROR, record the exception, and write the accumulated token count (if available) before closing the span. This prevents "orphaned" spans that remain open indefinitely, which can lead to memory leaks in the SDK.

Redacting PII and Sensitive Data in Spans

By default, capturing the raw prompts and completions in your traces is highly valuable for debugging. However, in enterprise environments, prompts often contain personally identifiable information (PII), proprietary source code, or financial data. Exporting this data to third-party APM vendors can violate compliance frameworks like GDPR, HIPAA, or SOC2.

To mitigate this risk, you should implement a custom SpanProcessor or configure the OpenTelemetry Collector to redact sensitive data before it leaves your network boundary.

Within the application, you can write a custom SpanProcessor that intercepts spans before they are passed to the exporter. This processor inspects the span's attributes (such as gen_ai.prompt or custom tool input attributes) and applies regular expressions or calls a PII detection service to mask sensitive values.

Alternatively, the OpenTelemetry Collector's redaction processor can be configured at the gateway level. This is the preferred architectural pattern as it centralizes compliance rules and ensures that even if application developers accidentally log sensitive data, it is stripped before egressing the corporate network.

Code Example

A complete, production-grade implementation of an instrumented agent loop using the official OpenTelemetry SDK. This example demonstrates manual span creation, setting standardized gen_ai.* attributes, error handling, and tool execution tracing.
Python
import os
import logging
from typing import Dict, Any
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from opentelemetry.trace import StatusCode, SpanKind

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("agent_observability")

# Initialize OpenTelemetry SDK
# In production, you would use an OTLPSpanExporter pointing to an OTel Collector
provider = TracerProvider()
processor = BatchSpanProcessor(ConsoleSpanExporter())
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)

tracer = trace.get_tracer("mortalapps.agent_system", "1.0.0")

class ToolExecutionError(Exception):
    """Raised when a tool fails to execute."""
    pass

# Mock Tool
def calculate_tax(income: float) -> float:
    # Instrument the tool execution with a dedicated span
    with tracer.start_as_current_span("tool_calculate_tax", kind=SpanKind.CLIENT) as span:
        span.set_attribute("tool.name", "calculate_tax")
        span.set_attribute("tool.input", str({"income": income}))
        
        try:
            if income < 0:
                raise ToolExecutionError("Income cannot be negative")
            tax = income * 0.25
            span.set_attribute("tool.output", str({"tax": tax}))
            span.set_status(StatusCode.OK)
            return tax
        except Exception as e:
            span.record_exception(e)
            span.set_status(StatusCode.ERROR, str(e))
            raise

# Instrumented LLM Client Wrapper
def call_mock_llm(prompt: str, model: str = "gpt-4o") -> Dict[str, Any]:
    # Start an LLM span with gen_ai semantic conventions
    with tracer.start_as_current_span("llm_generation", 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.temperature", 0.0)
        
        # In production, retrieve API keys securely from env vars
        api_key = os.environ.get("OPENAI_API_KEY", "mock-key-for-local-run")
        if not api_key:
            span.set_status(StatusCode.ERROR, "Missing API Key")
            raise ValueError("API Key not configured")

        try:
            # Simulating LLM response
            logger.info(f"Sending prompt to LLM: {prompt}")
            
            # Mocking a successful tool call decision response
            response_content = "{\"tool\": \"calculate_tax\", \"args\": {\"income\": 100000.0}}"
            
            # Set response attributes upon successful generation
            span.set_attribute("gen_ai.response.model", model)
            span.set_attribute("gen_ai.usage.input_tokens", 150)
            span.set_attribute("gen_ai.usage.output_tokens", 45)
            span.set_status(StatusCode.OK)
            
            return {
                "content": response_content,
                "model": model,
                "usage": {"input_tokens": 150, "output_tokens": 45}
            }
        except Exception as e:
            span.record_exception(e)
            span.set_status(StatusCode.ERROR, str(e))
            raise

# Main Agent Loop
def run_agent(user_query: str):
    # Root span representing the entire agent execution
    with tracer.start_as_current_span("agent_run") as root_span:
        root_span.set_attribute("agent.name", "TaxAdvisorAgent")
        root_span.set_attribute("session.id", "sess_987654321")
        
        try:
            # Step 1: LLM decides what to do
            llm_res = call_mock_llm(prompt=user_query)
            
            # Step 2: Parse decision and execute tool
            # (In a real system, use robust JSON parsing and validation)
            import json
            decision = json.loads(llm_res["content"])
            
            tool_name = decision["tool"]
            tool_args = decision["args"]
            
            if tool_name == "calculate_tax":
                tax_result = calculate_tax(income=tool_args["income"])
                logger.info(f"Tool execution completed. Tax: {tax_result}")
                
            root_span.set_status(StatusCode.OK)
            
        except Exception as e:
            logger.error(f"Agent execution failed: {e}")
            root_span.record_exception(e)
            root_span.set_status(StatusCode.ERROR, f"Agent failed at step: {str(e)}")

if __name__ == "__main__":
    # Run the instrumented agent
    run_agent("Calculate the tax for an income of 100000 dollars.")
    
    # Force flush the spans to ensure they are printed to console before exit
    provider.shutdown()
Expected Output
INFO:agent_observability:Sending prompt to LLM: Calculate the tax for an income of 100000 dollars.
INFO:agent_observability:Tool execution completed. Tax: 25000.0
{
    "name": "tool_calculate_tax",
    "context": {
        "trace_id": "0x...",
        "span_id": "0x...",
        "trace_state": "[]"
    },
    "parent_id": "0x...",
    "attributes": {
        "tool.name": "calculate_tax",
        "tool.input": "{'income': 100000.0}",
        "tool.output": "{'tax': 25000.0}"
    },
    "status": {
        "status_code": "UNSET"
    } 
}
... (additional JSON outputs representing the 'llm_generation' and 'agent_run' spans with their respective gen_ai.* attributes)

Key Takeaways

OpenTelemetry provides a vendor-neutral, future-proof standard for instrumenting generative AI and agentic systems.
Adhering to the gen_ai.* semantic conventions is critical for automatic dashboarding and cross-provider compatibility.
Hierarchical span structures are required to make non-deterministic, looping agent execution paths readable and debuggable.
Context propagation using W3C Trace Context is essential for maintaining trace continuity across distributed agent nodes and tool servers.
Streaming LLM responses require specialized span management to avoid orphaned spans and ensure accurate token and latency tracking.
Enterprise deployments must implement strict PII redaction and tail-based sampling to balance compliance, security, and APM costs.

Frequently Asked Questions

What is the difference between OpenTelemetry and proprietary LLM tracing tools? +
OpenTelemetry is an open-source, vendor-neutral standard. Proprietary tools lock your instrumentation code into their specific SDKs and backends, whereas OTel allows you to change your observability backend (e.g., Datadog, Jaeger, Honeycomb) without modifying a single line of application code.
When should I avoid using OpenTelemetry for agent observability? +
Avoid OTel only in ultra-low latency, resource-constrained edge environments where the memory footprint of the OTel SDK and background thread overhead cannot be tolerated. For standard cloud-based agent deployments, OTel is always recommended.
How do I handle streaming LLM responses with OpenTelemetry? +
Keep the LLM generation span active while consuming the stream. Accumulate the response chunks, calculate or extract the final token counts once the stream ends, attach those counts as span attributes, and then close the span.
What happens to my traces if the OpenTelemetry Collector goes down? +
The OpenTelemetry SDK's BatchSpanProcessor uses an in-memory ring buffer. If the collector is unreachable, the buffer will fill up, and the SDK will begin dropping new spans to prevent application memory exhaustion, ensuring your core agent application remains stable.
How do I propagate trace context to an external tool server? +
Inject the current trace context into the HTTP or gRPC headers of the outbound request using W3C Trace Context format. The tool server must extract this context from the headers and use it as the parent context for its own spans.
Can I use OpenTelemetry to track agent costs? +
Yes. By recording the standardized `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` attributes along with the `gen_ai.response.model`, you can write queries in your APM backend to calculate real-time costs based on model pricing.
How do I prevent sensitive user prompts from being exported in traces? +
Implement a custom SpanProcessor in your application to scrub attributes, or configure the redaction processor in the OpenTelemetry Collector to mask PII and secrets before they are transmitted to your APM vendor.
What is the performance overhead of OpenTelemetry instrumentation on an agent? +
The CPU overhead of creating spans is negligible (microseconds). The primary overhead is network and serialization, which is mitigated by using the BatchSpanProcessor to export spans asynchronously over gRPC to a local collector sidecar.