Advanced Key Tech

OpenTelemetry for LLMs

Traces, spans, and GenAI semantic conventions — debugging agents, RAG, and cost with OpenTelemetry.

70 min

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.turn root 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 resp

Span hierarchy that works

  1. Rootchat.turn (user request id)
  2. Childrenrag.retrieve, rag.rerank, gen_ai.chat, each tool.*
  3. Events — truncated prompts / outputs (PII policy first)
  4. 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_version yourself
  • Clock skew across workers — weird span timings; NTP matters

Production checklist

  1. Collector + OTLP from every service that touches a turn.
  2. GenAI attributes + prompt_version + engine_version pinned.
  3. Redaction policy reviewed like a data store.
  4. Propagation tested across HTTP and Kafka.
  5. Dashboards: TTFT, cost/task, tool error rate, cache hit.
  6. Eval fixtures store trace_id.
  7. 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

  1. Add a parent span per chat turn with children for retrieve + generate + one tool.
  2. Attach prompt_version + token usage; verify in your backend.
  3. Propagate trace_id onto a Kafka embed job message.
  4. Guided Tracing tool/LLM spans.

Guided Deploy, cost, latency, observability. Key Tech: Weights & Biases. Advanced: Kafka for evented AI, MLflow for LLMOps. Inference: vLLM.

Project checklist0/3 done