OpenTelemetry for LLMs
Traces, spans, and GenAI semantic conventions — debugging agents, RAG, and cost with OpenTelemetry.
What OpenTelemetry is
OpenTelemetry (OTel) is the open standard for traces, metrics, and logs. For LLM products it is how you stitch gateway → retrieve → model → tools into one timeline so “the agent failed” becomes a debuggable trajectory with cost and latency attached.
Vendor UIs (Datadog, Honeycomb, Grafana Tempo, Jaeger, LangSmith-style) come and go; OTLP + semantic conventions are the portable layer.
flowchart TB
subgraph trace [Trace: chat.turn]
S1[span: http.server]
S2[span: rag.retrieve]
S3[span: gen_ai.chat]
S4[span: tool.search]
S1 --> S2
S1 --> S3
S3 --> S4
end
Interview cue: Draw a
chat.turnroot with retrieve + LLM + tool children, and name the GenAI attributes you’d attach for cost and repro.
The engineering problem
An agent failure is rarely “the model is dumb.” It is usually wrong tool args, retrieval miss, timeout, prompt regression, or a budget trip. Without traces you cannot:
| Blind spot | What OTel unlocks |
|---|---|
| Which step failed | Parent/child spans |
| How much it cost | Token usage attributes |
| Was it cache / retrieval? | cache.hit, retrieve latency |
| Which prompt shipped? | prompt_version on the span |
| Offline vs online parity | Same trace_id in eval fixtures |
| Which engine version? | engine_version / model revision |
Logs alone are not enough once you have multi-hop agents (agents and ReAct).
Architecture: signals you need
| Signal | Use in LLM systems |
|---|---|
| Traces | Request → retrieve → LLM → tools |
| Metrics | TTFT, tokens/sec, error rate, cost/task |
| Logs | Rare high-cardinality debug (redacted) |
| Baggage / context | Propagate tenant_id, request_id across services |
flowchart LR
App[App / agent] --> SDK[OTel SDK]
SDK --> Coll[Collector]
Coll --> Tempo[Traces backend]
Coll --> Prom[Metrics]
Coll --> Logs[Log store]
Export via OTLP to a collector; don’t hard-bind your app to one SaaS SDK forever.
Propagation across boundaries
| Boundary | What to carry |
|---|---|
| HTTP | W3C traceparent |
| Kafka / queues | trace_id + span_id in headers/payload |
| Worker jobs | Same ids → child spans under a link or continued context |
| Eval fixtures | Store trace_id with the sample |
Broken propagation is the #1 reason “we have tracing” still feels useless for agents.
GenAI semantic conventions (shape)
Emerging OTel GenAI attributes (names evolve — pin a version in your repo):
| Attribute | Example | Why |
|---|---|---|
gen_ai.system |
openai / vllm |
Provider |
gen_ai.request.model |
gpt-4.1-mini |
Repro |
gen_ai.usage.input_tokens |
1200 |
Cost |
gen_ai.usage.output_tokens |
400 |
Cost |
gen_ai.operation.name |
chat |
Filter |
gen_ai.response.finish_reasons |
stop / length |
Truncation bugs |
Also record your attributes: tenant_id, prompt_version, retriever_id, eval_suite_id, cache_hit, agent_step, tool_name, engine_version.
# Shape — wrap one LLM call
from opentelemetry import trace
tracer = trace.get_tracer("shipai.agent")
def chat(messages: list[dict], model: str) -> dict:
with tracer.start_as_current_span("gen_ai.chat") as span:
span.set_attribute("gen_ai.request.model", model)
span.set_attribute("prompt_version", "support-v12")
resp = client.chat(messages=messages, model=model)
span.set_attribute("gen_ai.usage.input_tokens", resp["usage"]["input"])
span.set_attribute("gen_ai.usage.output_tokens", resp["usage"]["output"])
return respSpan hierarchy that works
- Root —
chat.turn(user request id) - Children —
rag.retrieve,rag.rerank,gen_ai.chat, eachtool.* - Events — truncated prompts / outputs (PII policy first)
- Links — optional link from online sample → offline eval run id
flowchart TD
Root[chat.turn] --> Ret[rag.retrieve]
Root --> LLM[gen_ai.chat]
LLM --> T1[tool.search]
LLM --> T2[tool.ticket.create]
Ret --> Pack[context.pack]
Pack --> LLM
Never log raw secrets. Hash or redact API keys, emails, and regulated fields. Prefer storing large payloads in object storage with a span attribute pointer, not a 50KB attribute blob.
What not to put on spans
| Anti-pattern | Why it hurts |
|---|---|
| Full prompts as metric labels | Cardinality explosion |
| Raw PII / secrets | Compliance incident |
| Unbounded tool dumps | Backend cost + noise |
| Different attribute names per service | Cannot join dashboards |
Metrics alongside traces
| Metric | Why |
|---|---|
| TTFT / e2e latency | UX SLOs |
| Tokens/sec, queue time | Serving health (vLLM) |
| Error rate by model + tool | Fast incident triage |
| Cache hit rate | Redis value |
| Cost per successful task | Not per raw call — agents inflate call count |
| Consumer lag (if evented) | Kafka pipelines |
| Guardrail block rate | Safety regressions |
Ship rule: optimize and alert on cost per successful task and p95 TTFT, not only tokens/sec on an empty cluster.
How it fits the product stack
| Layer | Instrumentation |
|---|---|
| Gateway | Root span, auth, quotas |
| RAG | Retrieve / rerank spans + doc ids |
| Agent graph | Per-node spans (LangGraph) |
| Tools / MCP | tool.* with latency + error type (MCP) |
| Serving | Engine queue time as attributes |
Framework “tracing UIs” are fine for demos; production teams usually standardize on OTel so gateway and LLM share one trace.
Evals ↔ traces
When an offline eval fails, open the stored trace id for that fixture. Production online evals should sample traces into a review queue — same IDs in both worlds. Pair with evals fundamentals and MLflow for LLMOps / Weights & Biases for experiment lineage.
flowchart LR
Offline[Offline eval fail] --> Tid[trace_id]
Tid --> Tempo[Trace UI]
Online[Online sample] --> Review[Human review queue]
Review --> Track[MLflow / W&B run]
Sampling strategy
| Traffic | Approach |
|---|---|
| Errors / high latency | Always keep |
| Tool failures | Always keep |
| Happy-path chat | Probabilistic sample (e.g. 1–10%) |
| VIP / paying tenants | Higher sample rate |
| Batch embed jobs | Sample by job + lag spikes |
Sampling too aggressively means you never see rare tool failures; sampling everything bankrupts the observability bill.
Failure modes
- PII in spans — treat attributes as production data
- High-cardinality explosion — don’t put raw prompts as metric labels
- Missing propagation — broken parent/child across HTTP/Kafka
- Sampling too aggressive — rare tool failures invisible
- Token math wrong — streaming / tool rounds under-count cost
- Framework auto-instrument only — still add
prompt_versionyourself - Clock skew across workers — weird span timings; NTP matters
Production checklist
- Collector + OTLP from every service that touches a turn.
- GenAI attributes +
prompt_version+engine_versionpinned. - Redaction policy reviewed like a data store.
- Propagation tested across HTTP and Kafka.
- Dashboards: TTFT, cost/task, tool error rate, cache hit.
- Eval fixtures store
trace_id. - Runbook: “open this trace when judge fails.”
Alternatives and complements
| Tooling | Role |
|---|---|
| LangSmith / Helicone / etc. | Faster LLM-specific UX; still export or dual-write when possible |
| Vendor APM only | OK if OTLP underneath |
| Printf debugging | Fine for solo labs; fails multi-service agents |
| W&B / MLflow tables | Experiment compare — not a substitute for prod traces |
Micro-project
- Add a parent span per chat turn with children for retrieve + generate + one tool.
- Attach
prompt_version+ token usage; verify in your backend. - Propagate
trace_idonto a Kafka embed job message. - Guided Tracing tool/LLM spans.
Related
Guided Deploy, cost, latency, observability. Key Tech: Weights & Biases. Advanced: Kafka for evented AI, MLflow for LLMOps. Inference: vLLM.