AnyLearn
All lessons
AIintermediate

LLM Observability with OpenTelemetry: GenAI Semantic Conventions

Master the OTel GenAI semantic conventions — gen_ai.* attributes, span structure for prompts/completions/tools, sampling strategies, and cost attribution — and understand why standardizing across LangSmith, Phoenix, Datadog, and Grafana matters for production AI systems.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 13

Why LLM Observability Is Hard (and Different)

Traditional observability answers three questions: did the request succeed, how long did it take, and what failed? For LLMs, those questions are necessary but not sufficient. A GPT-4o call can return HTTP 200, finish in 2.3 seconds, and still be completely wrong — hallucinated, off-topic, or financially ruinous.

LLM-specific questions you also need to answer:

  • What prompt was sent? (Was it the version you think it was?)
  • How many tokens were consumed — prompt vs. completion?
  • What did the model return, and did it call a tool?
  • What did this cost, per request, per user, per day?
  • Which model version ran — did something silently change?

Without a shared vocabulary for these questions, every vendor invents its own attribute names (llm.prompt_tokens vs llm.usage.prompt_tokens vs usage.inputTokens). That's where OTel's GenAI semantic conventions come in.

Full lesson text

All 13 steps on one page — for reading, reference, and search.

Show

1. Why LLM Observability Is Hard (and Different)

Traditional observability answers three questions: did the request succeed, how long did it take, and what failed? For LLMs, those questions are necessary but not sufficient. A GPT-4o call can return HTTP 200, finish in 2.3 seconds, and still be completely wrong — hallucinated, off-topic, or financially ruinous.

LLM-specific questions you also need to answer:

  • What prompt was sent? (Was it the version you think it was?)
  • How many tokens were consumed — prompt vs. completion?
  • What did the model return, and did it call a tool?
  • What did this cost, per request, per user, per day?
  • Which model version ran — did something silently change?

Without a shared vocabulary for these questions, every vendor invents its own attribute names (llm.prompt_tokens vs llm.usage.prompt_tokens vs usage.inputTokens). That's where OTel's GenAI semantic conventions come in.

2. OpenTelemetry GenAI Semantic Conventions: The Basics

The OTel GenAI semantic conventions live in the opentelemetry-semantic-conventions spec under gen_ai.*. As of early 2025 they're in Experimental stability but are already widely adopted.

Core attribute groups:

GroupExample attributeWhat it captures
Systemgen_ai.systemopenai, anthropic, google_vertexai
Requestgen_ai.request.modelgpt-4o, claude-3-5-sonnet-20241022
Request paramsgen_ai.request.max_tokensUpper token limit sent
Responsegen_ai.response.modelActual model version that ran
Usagegen_ai.usage.input_tokensTokens billed for prompt
Usagegen_ai.usage.output_tokensTokens billed for completion
Finishgen_ai.response.finish_reasons["stop"], ["tool_calls"], ["length"]

gen_ai.system uses a controlled vocabulary — openai, anthropic, aws_bedrock, google_vertexai, ollama, etc. — so dashboards can group across providers without string-munging.

3. Span Structure for a Tool-Calling LLM Request

flowchart TD
  A["HTTP Request (user)"] --> B["chat span\ngen_ai.operation.name = chat\ngen_ai.system = openai\ngen_ai.request.model = gpt-4o"]
  B --> C["LLM response: tool_calls\ngen_ai.response.finish_reasons = [tool_calls]\ngen_ai.usage.input_tokens = 312\ngen_ai.usage.output_tokens = 45"]
  C --> D["tool span\ngen_ai.tool.name = get_weather\ngen_ai.tool.call.id = call_abc123"]
  D --> E["tool result returned"]
  E --> F["chat span (2nd turn)\ngen_ai.request.model = gpt-4o\ngen_ai.usage.input_tokens = 380\ngen_ai.usage.output_tokens = 120"]
  F --> G["Final response to user"]

4. Span Structure: Chat, Tool, and Embedding Operations

Each OTel GenAI span gets gen_ai.operation.name to distinguish what kind of work happened:

  • chat — a prompt/completion round trip. This is the main span for most LLM calls.
  • text_completion — legacy completions API (GPT-3 style).
  • embeddings — a call to text-embedding-3-small etc. Records gen_ai.usage.input_tokens but no output tokens.
  • create_agent / invoke_agent — for managed agent APIs (Bedrock Agents, Vertex AI Agent Builder).

For tool calls, the spec adds gen_ai.tool.name (the function name) and gen_ai.tool.call.id (the ID the model assigned). The tool execution should be a child span of the chat span that triggered it, which gives you a clean parent-child trace: chat → tool → chat (follow-up turn).

Heads up: prompt and completion content (the actual text) is not in attributes by default — it's captured as span events named gen_ai.content.prompt and gen_ai.content.completion. This is intentional: content can be large and sensitive, so you opt-in per exporter.

5. Instrumenting in Python with opentelemetry-instrumentation-openai

The fastest path to OTel traces is the community opentelemetry-instrumentation-openai package (maintained by Traceloop, used by Arize Phoenix and others):

from opentelemetry import trace
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.instrumentation.openai import OpenAIInstrumentor

# Wire up the provider once at startup
provider = TracerProvider()
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317"))
)
trace.set_tracer_provider(provider)

# Auto-instrument — patches openai.OpenAI and openai.AsyncOpenAI
OpenAIInstrumentor().instrument(
    enrich_token_usage=True,   # adds gen_ai.usage.* from response headers
    capture_content=True,      # adds gen_ai.content.* events (opt-in!)
)

After this, every client.chat.completions.create() call automatically emits a span with the correct gen_ai.* attributes. No manual span creation needed.

6. Adding Custom Attributes: User, Session, and Cost

Auto-instrumentation gives you the model telemetry; you need to add business context yourself. The OTel spec has reserved attribute names for this:

from opentelemetry import trace

tracer = trace.get_tracer("myapp")

with tracer.start_as_current_span("handle_user_request") as span:
    span.set_attribute("user.id", user_id)          # who triggered it
    span.set_attribute("gen_ai.request.model", "gpt-4o")  # in case you override
    span.set_attribute("session.id", session_id)    # group multi-turn convos

    response = client.chat.completions.create(...)

    # Cost attribution — compute from token counts + pricing table
    input_cost  = response.usage.prompt_tokens     * 0.0000025
    output_cost = response.usage.completion_tokens * 0.0000100
    span.set_attribute("gen_ai.usage.cost_usd", input_cost + output_cost)
    span.set_attribute("gen_ai.usage.input_cost_usd", input_cost)
    span.set_attribute("gen_ai.usage.output_cost_usd", output_cost)

gen_ai.usage.cost_usd isn't in the official spec yet, but it's the de facto convention used by Arize, Langfuse, and Grafana dashboards. Attach it early and your cost dashboards are free.

7. Sampling Strategies for High-Volume LLM Traffic

At 1,000 LLM calls per minute, recording every span is expensive — both in storage and in latency from the exporter. Three sampling approaches:

Head-based sampling (ParentBased + TraceIdRatio): Decide at trace start whether to record. Simple, but you'll miss rare errors if your sample rate is low.

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

sampler = ParentBased(root=TraceIdRatioBased(0.10))  # record 10 % of traces
provider = TracerProvider(sampler=sampler)

Tail-based sampling: Buffer spans, then decide after the trace completes. Keeps 100% of error traces. Requires a collector like the OTel Collector with the tailsampling processor. Higher infra cost, much better signal.

Always-on for high-value paths: Sample 10% overall, but force-sample traces where gen_ai.usage.cost_usd > 0.05 or http.route == "/api/premium" using a custom sampler that inspects baggage.

For most teams: start with 10-20% head-based, add tail sampling for errors once volume justifies it.

8. The Collector Pipeline: Routing to Multiple Backends

The OTel Collector is the linchpin. Your app sends OTLP to the Collector; the Collector fans out to whichever backends you use:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }

processors:
  batch: {}
  filter/llm_only:
    spans:
      include:
        match_type: regexp
        attributes:
          - { key: gen_ai.system, value: ".+" }  # only spans with gen_ai.system

exporters:
  otlphttp/phoenix:   { endpoint: "http://phoenix:6006/v1/traces" }
  datadog:            { api:
                          key: ${env:DD_API_KEY}
                          site: datadoghq.com }
  prometheusremotewrite:
    endpoint: "http://mimir:9009/api/v1/push"

service:
  pipelines:
    traces:
      receivers:  [otlp]
      processors: [batch]
      exporters:  [otlphttp/phoenix, datadog]

One instrumented app, three observability backends — no code changes required when you add or swap a backend.

9. OTel Collector Fan-Out to LLM Observability Backends

flowchart LR
  A["App (Python/Node)"] -- "OTLP gRPC" --> B["OTel Collector"]
  B --> C["Arize Phoenix\n(open-source LLM traces)"]
  B --> D["Datadog LLM Observability\n(managed APM + LLM)"]
  B --> E["Grafana / Tempo + Mimir\n(self-hosted)"]
  B --> F["LangSmith\n(OTLP endpoint beta)"]

10. How Each Backend Uses gen_ai.* Today

The four major LLM observability platforms each consume OTel differently:

Arize Phoenix — built from the ground up on OTel. Ingests OTLP natively, renders gen_ai.content.prompt events as a prompt/completion view, and aggregates gen_ai.usage.input_tokens + gen_ai.usage.output_tokens into a token dashboard out of the box.

Datadog LLM Observability — has its own ddtrace SDK with LLMObs wrapper, but also ingests OTLP and maps gen_ai.* to its internal model. Adds flamegraph views per model and alert presets for token spike detection.

Grafana (Tempo + Mimir) — generic tracing, but the community Grafana GenAI dashboard (dashboard ID 21693) reads gen_ai.* attributes directly. Pair with a prometheusremotewrite exporter to get per-model token metrics in Mimir.

LangSmith — primarily its own SDK, but as of late 2024 is piloting an OTLP ingestion endpoint. If your traces carry gen_ai.operation.name = chat and session.id, LangSmith reconstructs multi-turn runs automatically.

The standard wins here: if you write gen_ai.*, you can switch backends without re-instrumenting.

11. Cost Attribution Patterns in Practice

Cost attribution at scale requires more than summing token counts. Two patterns that work:

Pattern 1 — Attribute-based grouping in Grafana/Prometheus. Emit gen_ai.usage.cost_usd as a span attribute, then use the Collector's spanmetrics processor to turn it into a Prometheus histogram with labels gen_ai.system, gen_ai.request.model, and user.id:

processors:
  spanmetrics:
    metrics_exporter: prometheusremotewrite
    dimensions:
      - name: gen_ai.system
      - name: gen_ai.request.model
      - name: user.id
    histogram:
      explicit:
        buckets: [0.001, 0.01, 0.05, 0.10, 0.50, 1.00]

Now you can query sum(gen_ai_usage_cost_usd_sum) by (user_id) for a per-user cost report.

Pattern 2 — Baggage propagation. Set tenant_id and feature_flag in OTel Baggage at the edge, and they'll flow through every downstream span automatically — including your LLM spans. No need to pass them manually into every LLM call.

12. Common Gotchas and Anti-Patterns

Lessons from teams running this in production:

Gotcha 1 — Capturing content in prod. capture_content=True in the instrumentor logs raw prompts and completions. That's PII-in-logs unless you have a scrubbing processor. Use it in dev/staging; in prod, capture only tokens + finish reason, and add content selectively via evaluated samples.

Gotcha 2 — Double-counting tokens in multi-turn. Each turn of a conversation sends the full history as context. Your per-turn input_tokens includes all previous messages. Don't sum across turns to get "total input tokens" — it overcounts. Track cumulative cost separately via a session.total_cost_usd baggage value.

Gotcha 3 — Model version drift. gen_ai.request.model is what you asked for; gen_ai.response.model is what ran. OpenAI auto-upgrades gpt-4 to the latest snapshot. Compare them in your dashboard — divergence means your prompts are running on a model you haven't evaluated.

Gotcha 4 — Missing parent span. If your LLM call isn't inside a parent span, each call is its own root trace. You lose the ability to see which user request caused the LLM call. Always wrap LLM calls inside a named span for the outer operation.

13. Why Standardization Wins Long-Term

Before OTel GenAI conventions, every vendor had its own attribute names, SDK, and export format. Switching from LangSmith to Phoenix meant re-instrumenting. Comparing token costs across two providers required custom ETL.

With gen_ai.*:

  • Vendor portability — instrument once, export anywhere via the Collector.
  • Shared tooling — community dashboards, alerting rules, and samplers work across stacks.
  • Multi-model comparisonsgen_ai.request.model and gen_ai.system let you A/B test GPT-4o vs. Claude 3.5 Sonnet in the same dashboard with zero extra code.
  • Compliance evidence — a standardized audit trail of what prompts were sent, by whom, and when, without building custom logging.

The spec is still evolving (Experimental stability as of 2025), so expect attribute names to stabilize over the next 12 months. Pin your semantic-conventions package version and watch the changelog — but the investment in OTel instrumentation today pays dividends regardless of which backend wins the market.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Which OTel attribute captures the model version that actually executed a request, as opposed to the model version requested?
    • gen_ai.request.model
    • gen_ai.response.model
    • gen_ai.system
    • gen_ai.operation.name
  2. When an LLM call triggers a tool/function call, how should the tool execution span relate to the chat span in the trace?
    • It should be a separate root trace to avoid polluting latency metrics
    • It should be a sibling span with the same parent as the chat span
    • It should be a child span of the chat span that triggered the tool call
    • It should be attached as a span event, not a separate span
  3. Why does the OTel GenAI spec place prompt and completion content in span events rather than span attributes?
    • Span attributes cannot store string values longer than 128 characters
    • Content can be large and sensitive, so it is opt-in per exporter rather than always recorded
    • Span events are indexed by all backends while attributes are not
    • Completion content is only available asynchronously and cannot be set before span end
  4. You want to keep 100% of traces where an error occurred but only 10% of successful traces. Which sampling approach handles this correctly?
    • TraceIdRatioBased sampler set to 0.10
    • ParentBased sampler wrapping TraceIdRatioBased
    • Tail-based sampling with a policy that always samples error spans
    • AlwaysOn sampler with a filter processor to drop successful spans afterward
  5. A developer sums gen_ai.usage.input_tokens across all spans in a multi-turn conversation to compute total input cost. What is wrong with this approach?
    • input_tokens is only available on the final span of a conversation
    • Each turn sends the full conversation history, so input_tokens includes all previous messages and summing across turns overcounts prompt tokens
    • The attribute records output tokens, not input tokens, so the label is misleading
    • OpenAI does not populate input_tokens when streaming is enabled

Related lessons

AI
intermediate

RAG Evaluation in Production: Metrics, Tools, and Cadence

Learn how to systematically evaluate Retrieval-Augmented Generation systems in production using RAGAS, TruLens, and Phoenix — covering golden sets, retrieval drift, embedding drift, and cost-aware eval scheduling.

12 steps·~18 min·audio
AI
advanced

Loop engineering: verification, orchestration, and anti-patterns

Make loops trustworthy. Adversarial verification panels, sub-agent orchestration, loop-until-dry for unbounded discovery, loop-until-budget for paid depth, multi-modal sweeps with diverse prompts, eval-driven cap selection, and the five anti-patterns — silent caps, infinite plans, drift, premature termination, thrashing — that ship to prod more than they should.

9 steps·~14 min
AI
intermediate

Loop engineering in production: state, errors, and budgets

Once an agent loop runs past a few iterations the failure surface shifts: state bloats, tool calls fail in three distinct ways, retries multiply if you stack them wrong, and unbounded budgets rack up four-figure bills overnight. The state, errors, and budgets you need to make the loop survive contact with reality.

8 steps·~12 min
AI
beginner

Loop engineering basics: the agent control loop

How LLM agents actually run: the iterative prompt-action-observation loop, the ReAct shape, the smallest tool-calling loop in twelve lines, why you always stack three termination layers, what the model sees on iteration N, and when a single prompt is the better answer.

8 steps·~12 min