LLM observability
Article.
Verified 2026-08Verified 2026-08. This area moves; re-check before quoting.
An LLM call produces a span with a duration and a status, like any HTTP call. That tells you nothing you need: not which prompt version, not which model, not the token count, not the tool-call chain, not what it cost.
For an agent it is worse. One user request becomes a tree of model calls, tool executions, retrievals and possibly sub-agents, and without a trace you cannot answer the only question that matters when it misbehaves — why did it do that?
OpenTelemetry GenAI conventions
The vendor-neutral standard: span names, attribute keys, metric instruments and event names for model invocations, tool executions, agent runs, retrieval and memory operations.
with tracer.start_as_current_span("chat gpt-model") as span:
span.set_attribute("gen_ai.system", "openai")
span.set_attribute("gen_ai.request.model", MODEL_ID)
span.set_attribute("gen_ai.request.temperature", 0.0)
response = client.chat.completions.create(...)
usage = response.usage
span.set_attribute("gen_ai.usage.input_tokens", usage.prompt_tokens)
span.set_attribute("gen_ai.usage.output_tokens", usage.completion_tokens)
span.set_attribute("gen_ai.response.finish_reasons", ["stop"])The status, precisely. As of 2026 the GenAI and MCP semantic conventions
are still experimental, with no public stabilisation date. In v1.42.0
(12 June 2026) all gen_ai.* attributes moved out of the main
semantic-conventions repository into a dedicated GenAI repo — an
organisational change giving fast-moving work its own release cadence,
not a graduation to stable.
The consequence: attribute names can still change between releases. Adopt them anyway — the alternative is proprietary lock-in — but pin your instrumentation version and expect to update. “Use the OTel GenAI conventions” without that caveat is the common half-answer.
The mechanism: how the trace stays connected
This is where agent tracing actually breaks, and it is nothing to do with attribute names.
OpenTelemetry tracks the current span in a context variable. A child span finds its parent by reading that variable, which works perfectly inside one coroutine and stops working the moment the work moves.
# Propagates. create_task copies the current context.
task = asyncio.create_task(call_tool(args))
# Propagates. to_thread copies the context into the worker thread.
result = await asyncio.to_thread(blocking_sdk_call, args)
# Does NOT propagate. submit() runs with whatever context the pool thread has.
future = pool.submit(blocking_sdk_call, args)The failure is not an error. You get a second, orphaned trace with one span in it, and the tool call simply vanishes from the agent’s tree — so the trace you open during an incident is missing exactly the step that failed.
Crossing a process boundary
Handing work to Celery, arq or a queue leaves the process entirely, so the context variable cannot follow. The context has to travel in the message:
# producer
carrier: dict[str, str] = {}
TraceContextTextMapPropagator().inject(carrier)
await queue.enqueue("summarise", doc_id, trace_ctx=carrier)
# consumer
ctx = TraceContextTextMapPropagator().extract(job["trace_ctx"])
with tracer.start_as_current_span("summarise", context=ctx):
...That traceparent string is the whole mechanism — a trace id, a span id and
flags. Anything that carries a header or a JSON field can carry it, which is
why HTTP propagation works automatically and queue propagation does not: nobody
wrote the injection for your message format.
If your agent traces look fragmented, this is why, and it is worth checking before blaming the tooling.
What to capture
| Level | Attributes |
|---|---|
| Request | trace id, tenant, feature, prompt version |
| Model call | model, tokens, finish reason, latency, TTFT, cost |
| Tool call | name, arguments, result size, latency, success |
| Retrieval | query, top-k, scores, chunk ids |
| Agent | step number, total steps, termination reason |
| Outcome | validation pass, groundedness, escalation |
Retrieved chunk ids are the underrated one. When an answer is wrong, knowing exactly which chunks were in the context window is the difference between debugging in minutes and guessing for a day.
Cost per span is the other. It lets you attribute spend to a feature, a tenant or a user, which is how you find the one workflow burning the budget — and cost is not a field any provider hands you, so compute it from tokens at span end.
Where this breaks in production
Streaming makes span duration lie
With a streamed response, the span ends when the stream closes. That duration is not what the user experienced — they saw the first token far earlier and started reading.
Record both, explicitly:
start = time.perf_counter()
first_token_at: float | None = None
async for chunk in stream:
if first_token_at is None:
first_token_at = time.perf_counter()
span.set_attribute("gen_ai.server.time_to_first_token", first_token_at - start)
yield chunk
span.set_attribute("gen_ai.server.request_duration", time.perf_counter() - start)TTFT drives perceived speed; total duration drives cost and capacity. A dashboard with only one of them will tell you the system is fine while users say it is slow, or the reverse.
The related trap: if the client disconnects mid-stream, the span may never end
and never export. Close it in a finally.
Sampling throws away the traces you need
Agent traces are large — dozens of spans, some with prompt-sized attributes. At volume you cannot keep all of them, and the obvious fix makes things worse.
Head-based sampling decides at the root, before anything has happened, so a 1% rate keeps 1% of traces chosen at random — and discards 99% of your errors. You are left sampling exactly the boring successful requests.
Tail-based sampling decides after the trace completes, so you can keep every trace that errored, exceeded a latency threshold, hit the step limit, or failed validation, and sample the rest at 1%. It costs a collector that buffers whole traces in memory, and that is the right trade.
Traces are a PII store
Full prompts and responses are simultaneously the most useful debugging data and the largest privacy liability you will create.
if should_sample(trace_id, rate=0.01) and not contains_pii(prompt):
span.set_attribute("gen_ai.prompt", redact(prompt))Metadata always, content on a small redacted sample with short retention. Two consequences teams discover late: an erasure request under GDPR covers your trace backend as well as your database, and most have never tried deleting a user’s data from it. And span attributes have size limits — a long prompt is silently truncated, so what you kept may not be what you needed.
See PII, privacy and the EU AI Act.
The tooling
| Tool | Character |
|---|---|
| LangSmith | deep LangChain/LangGraph tie-in, eval built in |
| Langfuse | open source, self-hostable, OTel-compatible |
| Logfire | Pydantic’s OTel platform; app and LLM in one view |
| Phoenix | open source, strong on eval and drift |
| Braintrust | evaluation-centric |
| Your APM + OTel | LLM spans beside the rest of the system |
Depth on instrumentation, self-hosting and cost is in Langfuse, LangSmith and Logfire.
Both is the normal answer. OTel keeps the model call in the same trace as the database and queue spans around it, which is what you need at 3am. A specialised tool gives you prompt playgrounds, conversation-tree UI and evaluation integration, which is what you need on a Tuesday afternoon.
The trade not taken
You could skip tracing and log structured events instead — cheaper, simpler, and adequate for a single model call behind an endpoint.
It stops being adequate the moment there is a tree. Logs give you records with no parent-child relationship, so reconstructing “this retrieval fed that model call which called that tool” means joining on ids you remembered to log consistently. That is a trace, rebuilt by hand, badly. The threshold is whether one request produces more than one model call — below it, logs; above it, spans.
Dashboards worth having
- Cost — spend by feature, tenant and model; tokens per request over time.
- Latency — p50/p95 end to end, and TTFT separately.
- Quality — schema-validation pass rate, refusal rate, sampled groundedness.
- Agent health — mean steps per run, step-limit exhaustion, tool error rate.
Step-limit exhaustion is the best leading indicator you get. A rising rate means agents are increasingly failing to finish, and it moves before task success falls and well before users complain.
Related
Interview angle 7
- “How do you trace an LLM application?” — OpenTelemetry with the GenAI
semantic conventions: model, tokens, cost, latency and finish reason per call,
plus tool calls, retrieval chunk ids and agent steps, in the same trace as the
HTTP and database spans around them.
- Follow-up: “Are those conventions stable?” — no. They are still experimental with no stabilisation date, and the v1.42.0 move to a dedicated repo was about release cadence, not graduation. Adopt them, pin the instrumentation version, expect attribute renames.
- “Your agent trace only shows the first two steps. What happened?” —
context propagation. OTel carries the current span in a context variable;
asyncio.create_taskandto_threadcopy it, a rawThreadPoolExecutor.submitdoes not, and nothing crosses a process boundary on its own.- Follow-up: “So how do you trace work handed to Celery?” — inject the
traceparentinto the message and extract it in the worker. HTTP does this automatically; your queue does it only if you wrote it.
- Follow-up: “So how do you trace work handed to Celery?” — inject the
- “You stream responses. What does the span duration measure?” — time until the stream closed, which is not what the user felt. Record time-to-first-token as its own attribute: TTFT drives perceived speed, total duration drives cost and capacity, and a dashboard with one of them misleads you.
- “You cannot afford to keep every trace. What do you sample?” — tail-based, not head-based. Deciding at the root keeps a random 1% and throws away 99% of your errors. Deciding after completion lets you keep everything that errored, was slow, or hit the step limit, and sample the rest.
- “Do you log prompts and responses?” — metadata always, content on a small redacted sample with short retention. Traces holding prompts are a PII store: an erasure request reaches your trace backend, and long prompts hit span attribute size limits and truncate silently.
- “Which metric warns you an agent is degrading before users complain?” — step-limit exhaustion rate, with mean steps per run trending up beside it. Both move before task success falls.
- “When is tracing overkill?” — one model call behind an endpoint; structured logs are enough. The threshold is whether a single request produces more than one model call, because logs cannot express a parent-child tree without you rebuilding one by hand.