LLM Observability with OpenTelemetry: Instrumenting AI Workloads the Cloud-Native Way
If you already run OpenTelemetry for your services, you don't need a new telemetry stack for LLMs. You need the same tracer, the same collector, the same Grafana dashboard — pointed at a new model of work. The recent GenAI semantic conventions let spans, metrics, and logs describe what an LLM actually did: which model, which prompt, how many tokens, how long, what it cost, and which tool it called.
Why OpenTelemetry is the right substrate for LLM telemetry¶
LLM workloads share the same cloud-native shape as everything else: a request comes in, it fans out to tools and providers, it returns a streaming response, and you need to know when it broke. The plumbing that already collects traces from your HTTP services, queues, and databases should collect traces from your LLM calls. That gives you three things for free:
- Correlated traces across the stack. An inbound HTTP span can be the parent of a
chat gpt-4ospan, which can be the parent of avector_searchspan. The whole request timeline is one trace. - Vendor neutrality. Span data is OTLP. Span data you can send to Honeycomb, Grafana Tempo, Datadog, New Relic, SigNoz, or any other backend. You don't lose portability by going deeper.
- Existing evaluation pipelines. Service-level objectives, error budgets, and on-call rotations already work on top of OTel data. An LLM trace is just a span with extra attributes.
The cost of ignoring this is the trap most teams fall into: a separate Langfuse or Phoenix tenant for AI testing, a separate Datadog view for production, and a CSV export that gets reconciled in a notebook every Monday. That breaks the moment a multi-step agent does a tool call that hits your existing microservice.
The GenAI semantic conventions in 2026¶
The GenAI semantic conventions used to live inside open-telemetry/semantic-conventions. They moved to a dedicated repo — open-telemetry/semantic-conventions-genai — and the old registry entries now carry a deprecation banner pointing to the new location.5
OpenTelemetry Python 1.44.0 released on 2026-07-16, and the OpenTelemetry Collector v0.157.0 shipped on 2026-07-21. Semantic conventions are at v1.43.0 (2026-07-03).123
The actual vocabulary is small and focused. The OpenAI span spec calls out these required attributes.4
| Attribute | Level | Example |
|---|---|---|
gen_ai.operation.name | Required | chat, generate_content, text_completion |
gen_ai.request.model | Required | gpt-4o, Qwen/Qwen3-8B |
server.address | Recommended | api.openai.com |
gen_ai.usage.input_tokens | Recommended | 128 |
gen_ai.usage.output_tokens | Recommended | 287 |
gen_ai.usage.cache_read.input_tokens | Recommended | 1,024 |
gen_ai.usage.cache_creation.input_tokens | Recommended | 256 |
gen_ai.usage.reasoning.output_tokens | Recommended | 512 |
gen_ai.response.time_to_first_chunk | Recommended for streaming | 0.34 |
gen_ai.response.finish_reasons | Recommended | ["stop"] |
gen_ai.response.model | Recommended | gpt-4o-2024-08-06 |
gen_ai.system_instructions | Opt-In | (full prompt content) |
gen_ai.input.messages | Opt-In | (full chat history) |
gen_ai.output.messages | Opt-In | (full assistant reply) |
gen_ai.tool.definitions | Opt-In | (function definitions) |
Two things to notice:
- Costs are not attribute-shaped. The spec records token counts; you compute cost in your backend. Stable token counts let you change pricing or vendor without changing instrumentation.
- PII is opt-in. The full prompt and response go behind
gen_ai.input.messagesandgen_ai.output.messages, which require explicit enablement. Put privacy policy at the same place as the decision to record structured logs.
The deprecation table on the old gen-ai.md registry page is also worth a look. gen_ai.usage.prompt_tokens and gen_ai.usage.completion_tokens are gone. Modern instrumentations emit input_tokens and output_tokens. If you're staring at a dashboard full of zeros, you're probably still on the old attribute names.
What every LLM span should look like¶
Here's a minimal but spec-compliant span for a non-streaming chat call.
from opentelemetry import trace
from opentelemetry.semconv.trace import SpanAttributes
tracer = trace.get_tracer("genai.chat")
with tracer.start_as_current_span("chat gpt-4o") as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", "openai")
span.set_attribute("gen_ai.request.model", "gpt-4o")
span.set_attribute("gen_ai.request.temperature", 0.2)
span.set_attribute("gen_ai.request.max_tokens", 512)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
max_tokens=512,
)
span.set_attribute("gen_ai.response.id", response.id)
span.set_attribute("gen_ai.response.model", response.model)
span.set_attribute("gen_ai.response.finish_reasons", [response.choices[0].finish_reason])
span.set_attribute("gen_ai.usage.input_tokens", response.usage.prompt_tokens)
span.set_attribute("gen_ai.usage.output_tokens", response.usage.completion_tokens)
span.set_attribute("gen_ai.usage.cache_read.input_tokens", getattr(response.usage, "cached_tokens", 0) or 0)
Two patterns that matter even before instrumentation:
- Span name is
{operation} {model}. This is what the spec calls out, and it makes a Tempo or Honeycomb search trivially useful:chat gpt-4oversuschat Qwen3-8B. - Always set
gen_ai.response.model. The model you requested and the model that actually served the request can differ. GPT-4o requests often land ongpt-4o-2024-08-06or a newer snapshot. Costs and behavior differ with that suffix.
Streaming responses: the time-to-first-chunk metric¶
Streaming is the default for chat UIs. The slowest signal you need is the time between sending the request and the first byte coming back. The spec reserves gen_ai.response.time_to_first_chunk for that.4
import time
start = time.perf_counter()
first_chunk_at = None
final = None
with tracer.start_as_current_span("chat gpt-4o-mini") as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.request.model", "gpt-4o-mini")
span.set_attribute("gen_ai.request.stream", True)
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
stream=True,
)
for chunk in stream:
if first_chunk_at is None:
first_chunk_at = time.perf_counter()
span.set_attribute(
"gen_ai.response.time_to_first_chunk",
round(first_chunk_at - start, 3),
)
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
final = chunk
span.set_attribute("gen_ai.usage.output_tokens", getattr(final.usage, "completion_tokens", 0) if final else 0)
If you only instrument one thing for a chat UI, capture TTFT. It tells you whether the user is waiting on your inference stack, the network, or the model. It is also the only number that lets you tell a slow generation from a slow UX.
Auto-instrumentation for the boring 80%¶
Don't write the span above for every provider. Auto-instrumentation packages offload this work to the OpenTelemetry SDK. The opentelemetry-instrumentation contrib repository ships packages for OpenAI, Anthropic, Bedrock, Vertex, and the major vector databases. The community wrapper traceloop-sdk bundles them and adds extras; its 0.62.1 release shipped 2026-06-28.6
Two lines gets you most of the way:
After that, every call through openai, anthropic, boto3 (Bedrock), langchain, llama-index, and the supported vector DB clients is wrapped in spans with the GenAI attribute names. The export target is set with environment variables that OTel collectors already understand.
export OTEL_EXPORTER_OTLP_ENDPOINT="https://otel-collector.observability.svc:4317"
export OTEL_SERVICE_NAME="chat-api"
export TRACELOOP_BASE_URL="https://otel-collector.observability.svc:4317"
These environment variables are the same ones your existing services already use. The Traceloop wrapper does not pull in a separate backend.
The collector pipeline for LLM traffic¶
The OpenTelemetry Collector v0.157.0 has GenAI-aware processors and exporters out of the box.2 A practical pipeline for an LLM service looks like this.
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# Redact prompt/response bodies by default.
transform/redact:
trace_statements:
- context: span
statements:
- replace_all_patterns(attributes["gen_ai.input.messages"], "/.*/", "[REDACTED]")
- replace_all_patterns(attributes["gen_ai.output.messages"], "/.*/", "[REDACTED]")
- replace_all_patterns(attributes["gen_ai.system_instructions"], "/.*/", "[REDACTED]")
# Roll up token counters into a metric stream.
metrics/genai:
metrics:
gen_ai.client.token.usage:
enabled: true
attributes:
- gen_ai.request.model
- gen_ai.provider.name
- gen_ai.token.type
batch:
timeout: 5s
send_batch_size: 1024
exporters:
otlp/tempo:
endpoint: tempo-distributor.observability.svc:4317
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
service:
pipelines:
traces:
receivers: [otlp]
processors: [transform/redact, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [metrics/genai, batch]
exporters: [prometheus]
The transform/redact block is the part most teams miss. The semantic conventions mark prompt content as opt-in, but most libraries default to recording it. Add a redaction stage at the edge of the collector so production spans never carry user input unless you've made a deliberate policy decision.
Compute cost from telemetry, not from invoices¶
The OTel attributes don't include a dollar amount. That's the right design. Pricing changes. Vendor pricing differs across SKUs. Token counts are stable.
The cheapest way to convert a token stream into a cost stream is a Prometheus recording rule that combines your token counter with a price map.
# prometheus rules
groups:
- name: genai.cost
interval: 30s
rules:
- record: genai:cost_usd_per_minute
expr: |
sum by (gen_ai_request_model, gen_ai_provider_name) (
rate(gen_ai_client_token_usage_sum{gen_ai_token_type="input"}[5m]) * on(gen_ai_request_model) group_left(input_price_per_million) genai_pricing
+
rate(gen_ai_client_token_usage_sum{gen_ai_token_type="output"}[5m]) * on(gen_ai_request_model) group_left(output_price_per_million) genai_pricing
)
genai_pricing is a small file-provider metrics target that you own. Update it when Anthropic ships a new model. The math stays in one place.
What to alert on¶
LLM observability is not alert fatigue. The signals that matter are the same ones that matter for any service.
- P99 latency per model, per
gen_ai.operation.name - Error rate with
error.typebreakdown (rate limit, timeout, content filter) - Token cost per request outliers — these catch runaway prompts and stuck loops
- Cache hit rate for
cache_read.input_tokens— drops mean you're paying for redundant context - Time to first chunk P95 — UX-facing, tied to the SLA your frontend team committed to
- Tool call rate for agentic workloads — spikes usually mean a prompt regression
Span data also gives you a debug surface you couldn't build with metrics alone. A user reports that the chat was slow yesterday at 14:23, you filter service.name = chat-api and gen_ai.request.model = "gpt-4o" and http.status_code = 200, you pull the trace, and you see the agent spent 11 seconds inside a retrieve_documents tool call. That story is hard to tell from a counter.
What to evaluate, not just observe¶
Tracing tells you what happened. Evaluation tells you whether it was good. OpenTelemetry carries both. The gen_ai.evaluation event is a spec-defined event you can attach to a span to record a judge score, a human rating, or a heuristic check.7
from opentelemetry import trace
with tracer.start_as_current_span("chat gpt-4o") as span:
response = call_model(messages)
span.add_event(
"gen_ai.evaluation",
attributes={
"gen_ai.evaluation.name": "answer_faithfulness",
"gen_ai.evaluation.score.value": 0.92,
"gen_ai.evaluation.score.label": "good",
"gen_ai.evaluation.explanation": "Answer grounded in retrieved context.",
},
)
Now your backend can graph faithfulness over time, group by prompt version, and trigger a rollback when a release drops the score.
A practical first pass¶
If you have nothing, this is the order I'd add things.
- Deploy or point at an existing OTel Collector and a trace backend.
- Add
traceloop-sdkto one LLM service. Initialize before any imports of the LLM client. - Wire
OTEL_EXPORTER_OTLP_ENDPOINTandOTEL_SERVICE_NAMEenv vars. - Add the
transform/redactprocessor to the collector. - Build one dashboard: latency P50/P95/P99 by model, tokens per minute by model, error rate by
error.type. - Set one alert: latency P95 > your SLA for 5 minutes.
- Add
gen_ai.evaluationevents to your offline eval pipeline.
Skip the AI-specific observability vendor for now. The OTel stack you already have is enough.
Summary¶
- OpenTelemetry's GenAI semantic conventions now expose model, token, latency, and tool attributes on standard spans.
- Auto-instrumentation covers OpenAI, Anthropic, Bedrock, Vertex, and the major vector DBs out of the box.
- The same collector, the same trace backend, the same dashboards — no separate SaaS.
- Treat prompt content as opt-in and redact at the collector edge.
- Compute cost from token counters, not from vendor invoices.
- Latency, error rate, TTFT, cache hit rate, and tool-call rate are the alert tiers that actually matter.
- Trace into your existing microservices, not away from them.
The size of the LLM doesn't change the rules of telemetry. It changes the attributes.
Questions or discussion? Connect on LinkedIn, X, or email.
-
OpenTelemetry Python, Releases — v1.44.0 published 2026-07-16. ↩
-
OpenTelemetry Collector, Releases — v0.157.0 published 2026-07-21. ↩↩
-
OpenTelemetry Semantic Conventions, Releases — v1.43.0 published 2026-07-03. ↩
-
OpenTelemetry GenAI Semantic Conventions, OpenAI spans — Inference span attributes as of v1.43.0. ↩↩
-
OpenTelemetry, Generative AI semantic conventions — GenAI conventions moved to the dedicated
open-telemetry/semantic-conventions-genairepository; the old registry entries are deprecated. ↩ -
Traceloop, OpenLLMetry — open-source OpenTelemetry extensions for LLM applications; v0.62.1 released 2026-06-28. ↩
-
OpenTelemetry Semantic Conventions, Events — span events including
gen_ai.evaluation. ↩
Discussion
Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.