← Blog
LLM Engineering

OpenTelemetry for LLM Applications: A 2026 Production Tracing Setup Guide

·14 min read·by Kunal Gaba
Share:Share on XShare on LinkedIn
ObservabilityOpenTelemetryAI EngineeringProduction AI

In 2026, Datadog's State of AI Engineering report found that 5% of all LLM spans in production end in error — and 60% of those errors are rate limits (Datadog, 2026). Most teams find out about these failures from user complaints, not their observability stack.

The reason is structural. Standard APM tools — Datadog APM, New Relic, Dynatrace — were built for HTTP request/response cycles and database queries. They don't capture what matters for LLMs: token counts, model latency distributions, prompt caching hits, or the parent-child span hierarchy of a multi-step agent run. When a LangGraph agent fails on step three of seven, your current traces show a 500ms gap and nothing more.

OpenTelemetry fixes this. The project's official gen_ai.* semantic conventions — actively developed in the open-telemetry/semantic-conventions-genai repository and supported by Datadog, Honeycomb, and Grafana from version 1.37 onward — define a standard way to instrument any LLM provider. In May 2026, OpenTelemetry graduated to CNCF Graduated status, the highest maturity level, with 41% of organizations already running it in production (CNCF, 2026).

This guide walks through a complete OTel setup for production LLM applications: from a raw Anthropic SDK call to a full agent with tool calls and retrieval spans, exportable to Jaeger (local), Datadog, Honeycomb, or Grafana Tempo.

Key Takeaways

  • In 2026, 5% of LLM production spans fail; 60% of those errors are rate limits that standard APM doesn't surface clearly (Datadog State of AI Engineering, 2026)
  • OpenTelemetry's gen_ai.* semantic conventions define 12+ standard attributes for LLM spans — input tokens, output tokens, model name, finish reason — available since OTel 1.27
  • A production-ready OTel setup requires four span types: model call, tool call, retrieval, and agent loop — each with different attribute sets
  • Exporting to Jaeger takes 10 minutes locally; switching to Datadog or Honeycomb is a one-line exporter swap

If your token counts are already climbing and you haven't set up tracing yet, start with context engineering for AI agents — it covers how context bloat develops before it becomes visible in your costs.


Before You Begin

What you'll need:

  • Python 3.9+
  • opentelemetry-api>=1.27 and opentelemetry-sdk>=1.27
  • anthropic>=0.26 (Python SDK examples)
  • Docker (for running Jaeger locally — optional but recommended)
  • Basic familiarity with distributed tracing concepts

Time: ~45–60 minutes
Difficulty: Intermediate

bash
pip install opentelemetry-api opentelemetry-sdk \
            opentelemetry-exporter-jaeger \
            opentelemetry-exporter-otlp \
            anthropic

If you're using LangChain or LangGraph, the Traceloop OpenLLMetry library auto-instruments both frameworks with a single import:

bash
pip install traceloop-sdk

Step 1: Why Standard APM Breaks for LLM Applications

Standard APM captures what happened at the network layer. LLM applications fail for reasons that live below that layer — and above it.

Token counts don't map to bytes. A 500ms response that consumed 8,000 input tokens costs 16× more than a 500ms response that consumed 500. Standard APM sees two spans with identical duration and HTTP 200 status. You see two "healthy" requests. Your finance team sees a bill that doesn't match your request volume.

Agent spans have nesting that HTTP spans don't. A LangGraph agent makes a model call → which emits two tool calls → which each trigger a retrieval span → which loop back to another model call. Standard APM captures those as five unrelated spans. OTel's trace context propagation keeps them in a single parent-child tree, so you can see total cost per agent run, failure point per step, and which tool call caused the retry cascade.

Latency distributions are bimodal. Prompt cache hits complete in under 100ms. Cold calls complete in 800–4,000ms. A single P99 hides both populations. You need to split by gen_ai.request.model and cache status — which is only possible if those attributes exist on the span.

In 2026, Datadog's State of AI Engineering report documented this blind spot directly: rate-limit errors account for 60% of all LLM errors, yet most engineering teams have no alerting on them because their APM tools classify 429s identically to any other client error, with no retry-cascade context (Datadog, 2026).

LLM Error Types in Production (2026) LLM Error Types in Production Source: Datadog State of AI Engineering, 2026 Rate Limits 60% Timeouts 18% Model Errors 15% Auth Errors 7% n = production LLM spans across monitored organizations
LLM error types in production — rate limits account for 60% but are often invisible in standard APM dashboards. Source: Datadog State of AI Engineering, 2026.

For teams already managing multi-provider traffic through an LLM gateway, OTel traces belong at the gateway layer — instrument it once and all downstream services inherit observability automatically.


Step 2: Install OpenTelemetry and Configure the gen_ai.* Conventions

OpenTelemetry's gen_ai.* semantic conventions define the attributes every LLM span should carry. They're provider-agnostic: whether you're calling Anthropic, OpenAI, or a self-hosted Llama model, the attribute names stay the same. The spec is currently experimental — actively evolving through versions 1.37–1.41 in the open-telemetry/semantic-conventions-genai repository — but major backends (Datadog, Honeycomb, Grafana Tempo) already support it natively from v1.37 onward, making it safe to adopt in production today.

The core attributes for model call spans:

Attribute Type Description Example
gen_ai.system string Provider identifier "anthropic"
gen_ai.operation.name string Operation type "chat"
gen_ai.request.model string Model requested "claude-sonnet-4-6"
gen_ai.response.model string Model that responded "claude-sonnet-4-6"
gen_ai.usage.input_tokens int Prompt token count 1240
gen_ai.usage.output_tokens int Completion token count 340
gen_ai.request.max_tokens int Max tokens setting 4096
gen_ai.request.temperature float Temperature 0.7
gen_ai.response.finish_reasons string[] Why generation stopped ["end_turn"]
error.type string Error class on failure "rate_limit_error"

Set up your TracerProvider once at application startup:

python
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.jaeger.thrift import JaegerExporter

def setup_tracing(service_name: str = "my-llm-app"):
    jaeger_exporter = JaegerExporter(
        agent_host_name="localhost",
        agent_port=6831,
    )
    provider = TracerProvider()
    provider.add_span_processor(BatchSpanProcessor(jaeger_exporter))
    trace.set_tracer_provider(provider)
    return trace.get_tracer(service_name)

tracer = setup_tracing()

For production, swap JaegerExporter for OTLPSpanExporter — a one-line change shown in Step 5.

According to the OpenTelemetry specification, gen_ai.* conventions are designed to work alongside existing HTTP and RPC conventions, not replace them (OpenTelemetry GenAI Semantic Conventions, 2026). Your existing HTTP trace for the /api/chat endpoint can parent the gen_ai span — giving you end-to-end visibility from user request to model response in a single trace tree.

One easily-missed finding from the Datadog 2026 report: only 28% of LLM call spans used prompt caching despite broad provider support (Datadog, 2026). With token-aware tracing in place, you can finally see which calls are caching and which aren't — and quantify the cost delta. Teams that enabled caching and deduplicated retrieval queries reduced token spend by over 30% and cut p95 latency in half, according to the same report.


Step 3: Instrument Your First LLM Call

By the end of this step, you'll have a working trace for a single Anthropic SDK call — with token counts, model name, and finish reason captured as span attributes.

python
import anthropic
from opentelemetry import trace

client = anthropic.Anthropic()
tracer = trace.get_tracer("my-llm-app")

def call_claude(prompt: str, model: str = "claude-sonnet-4-6") -> str:
    with tracer.start_as_current_span("gen_ai.chat") as span:
        # Set request attributes before the call
        span.set_attribute("gen_ai.system", "anthropic")
        span.set_attribute("gen_ai.operation.name", "chat")
        span.set_attribute("gen_ai.request.model", model)
        span.set_attribute("gen_ai.request.max_tokens", 1024)

        try:
            response = client.messages.create(
                model=model,
                max_tokens=1024,
                messages=[{"role": "user", "content": prompt}]
            )

            # Set response attributes after the call
            span.set_attribute("gen_ai.response.model", response.model)
            span.set_attribute("gen_ai.usage.input_tokens", response.usage.input_tokens)
            span.set_attribute("gen_ai.usage.output_tokens", response.usage.output_tokens)
            span.set_attribute("gen_ai.response.finish_reasons", [response.stop_reason])

            return response.content[0].text

        except anthropic.RateLimitError as e:
            span.set_attribute("error.type", "rate_limit_error")
            span.set_status(trace.StatusCode.ERROR, str(e))
            raise
        except anthropic.APIError as e:
            span.set_attribute("error.type", type(e).__name__)
            span.set_status(trace.StatusCode.ERROR, str(e))
            raise

Verify it works: Start Jaeger locally and call your function:

bash
docker run -d -p 6831:6831/udp -p 16686:16686 jaegertracing/all-in-one
python -c "from your_module import call_claude; call_claude('Hello')"

Open localhost:16686, find the my-llm-app service, and confirm the span shows gen_ai.usage.input_tokens and gen_ai.usage.output_tokens in the tags panel.

When your trace carries token counts, cost attribution becomes a query: (input_tokens / 1_000_000) * input_price + (output_tokens / 1_000_000) * output_price. Store this as a derived metric in your backend and you've replaced the monthly invoice surprise with a per-request cost dashboard.

Developer monitor displaying syntax-highlighted code in a dark workspace, representing the instrumentation of an LLM application with OpenTelemetry span attributes


Step 4: Add Multi-Step Agent Spans (Tool Calls and Retrieval)

Single model call spans are the easy part. The real value of OTel for LLM applications is tracing the full agent graph — where a failure in tool call three cascades into four retries, each burning tokens, before surfacing as a user-visible error.

For a well-instrumented agent, you need four span types in a parent-child hierarchy:

code
[agent.run span]                     ← parent: full run cost + total duration
  ├── [gen_ai.chat span]             ← model call: tokens, model, finish reason
  │     └── [gen_ai.tool_call span] ← tool call: tool name, input params, result
  └── [gen_ai.retrieval span]        ← retrieval: query, doc count, latency

Here's how to instrument this hierarchy manually (works with any framework):

python
def run_agent(user_query: str) -> str:
    with tracer.start_as_current_span("agent.run") as agent_span:
        agent_span.set_attribute("agent.query", user_query[:200])

        total_input_tokens = 0
        total_output_tokens = 0

        for iteration in range(10):  # max iterations guard
            agent_span.set_attribute("agent.iteration", iteration)

            # Model call span — child of agent span
            with tracer.start_as_current_span("gen_ai.chat") as model_span:
                model_span.set_attribute("gen_ai.system", "anthropic")
                model_span.set_attribute("gen_ai.request.model", "claude-sonnet-4-6")

                response = client.messages.create(
                    model="claude-sonnet-4-6",
                    max_tokens=1024,
                    messages=build_messages(user_query, iteration),
                )

                model_span.set_attribute("gen_ai.usage.input_tokens", response.usage.input_tokens)
                model_span.set_attribute("gen_ai.usage.output_tokens", response.usage.output_tokens)
                total_input_tokens += response.usage.input_tokens
                total_output_tokens += response.usage.output_tokens

            # Instrument each tool call as a child span
            for tool_call in extract_tool_calls(response):
                with tracer.start_as_current_span("gen_ai.tool_call") as tool_span:
                    tool_span.set_attribute("gen_ai.tool.name", tool_call["name"])
                    tool_span.set_attribute("gen_ai.tool.call.id", tool_call["id"])

                    result = execute_tool(tool_call)
                    tool_span.set_attribute("gen_ai.tool.result_length", len(str(result)))

            if response.stop_reason == "end_turn":
                break

        # Capture cumulative cost on the parent span
        agent_span.set_attribute("agent.total_input_tokens", total_input_tokens)
        agent_span.set_attribute("agent.total_output_tokens", total_output_tokens)

        return response.content[0].text

The non-obvious detail: Always capture agent.total_input_tokens on the parent agent.run span, not only on individual model call children. When debugging a cost spike, you want to filter by agent-level cost — not sum across dozens of child spans in your query language. This one attribute turns cost queries from O(n) span aggregations to O(1) attribute lookups.

If you're on LangChain or LangGraph, OpenLLMetry instruments the full span hierarchy automatically with a single call:

python
from traceloop.sdk import Traceloop
Traceloop.init(app_name="my-agent")
# All LangChain/LangGraph calls now auto-emit gen_ai.* spans

As of Q2 2026, OpenLLMetry supports LangChain, LangGraph, LlamaIndex, and raw OpenAI/Anthropic SDKs (Traceloop OpenLLMetry, 2026).