AI Monitoring and Observability

Building Tracing Pipelines for LLM Apps


A retrieval-augmented chatbot has a latency distribution that makes no sense. The p50 is 900 ms. The p99 is 12.4 seconds. It is not one endpoint being slow and another fast — it is the same endpoint, the same model, the same customers. The logs record one line per request with a total duration and nothing else, so the investigation consists of staring at a histogram with two humps and guessing.

Someone adds tracing. The first slow request that gets captured looks like this:

Text
POST /chat                                       12,412 ms  classify_intent                                    210 ms  agent.loop  iterations=4                        11,940 ms    tool.search   "refund policy"                  1,690 ms    llm.call      attempt=1                        1,240 ms    tool.search   "refund policy 2024"             1,710 ms    llm.call      attempt=2                        1,180 ms    tool.search   "refund policy exceptions"       1,720 ms    llm.call      attempt=3                        1,260 ms    tool.search   "how do refunds work"            1,680 ms    llm.call      attempt=4                        1,210 ms  format_response                                     52 ms

The model is not slow. The retrieval is not slow. The agent is looping — issuing near-identical searches, getting near-identical documents back, deciding it still lacks the answer, and going round again. Four search-plus-reason cycles at roughly 2.9 seconds each. The p99 hump is not a tail of the p50 distribution at all; it is a completely different behaviour hiding inside the same average.

No metric could have found that. A metric records that a request took 12.4 seconds. Only a trace records that it took 12.4 seconds because of four iterations of a loop. That causal structure — who called whom, in what order, for how long — is the entire product of a tracing pipeline, and getting one that actually works involves half a dozen decisions where the default is wrong.

One trace, and where the 12 seconds actually wentPOST /chat — 12.4 sclassify — 0.2 sagent loop — 11.9 s4 searches — 6.8 s4 LLM calls — 4.9 s
A single duration per request could never show that an agent looping four times, not a slow model, owns the whole p99.

The data model, precisely

Five concepts, and they are worth pinning down exactly because most tracing bugs are really misunderstandings of one of them.

ConceptWhat it isPractical consequence
SpanOne timed operation: name, start, duration, status, attributes, eventsThe unit of storage and cost. Span count is your bill
TraceAll spans sharing a 16-byte trace IDThe join key across services, logs, and metric exemplars
ContextThe currently active span, held in thread-local or async-local storageIf context is lost, new spans become orphan roots — the single most common bug
PropagatorSerialises context across a boundary, usually the W3C traceparent headerMiss it on one hop and the trace splits into two disconnected trees
ResourceAttributes describing the emitter, not the operation: service name, version, host, environmentSet once at startup. Without service.version you cannot attribute a regression to a deploy

A traceparent header looks like this, and reading it is occasionally the fastest way to debug a broken trace:

Text
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01             ^^ ^------------- trace id --------------^ ^-- span --^ ^^             |                                                        |          version                                             flags (01 = sampled)

That last byte matters more than it looks. 01 means an upstream service already decided this trace is being recorded. Downstream services must respect that decision, or you get traces where the front end is present and the back end is missing.

The pipeline, and the queue that silently drops your spans

Data flows: your code creates spans → a span processor buffers them → an exporter serialises and ships them → usually an OpenTelemetry Collector receives, processes, and forwards → a backend stores them.

Python
from opentelemetry import tracefrom opentelemetry.sdk.resources import Resourcefrom opentelemetry.sdk.trace import TracerProviderfrom opentelemetry.sdk.trace.export import BatchSpanProcessorfrom opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporterresource = Resource.create({    "service.name": "chat-api",    "service.version": "4.2.1",         # without this, no deploy attribution    "deployment.environment.name": "production",})provider = TracerProvider(resource=resource)provider.add_span_processor(BatchSpanProcessor(    OTLPSpanExporter(endpoint="http://otel-collector:4317", insecure=True),    max_queue_size=8192,                # default 2048 - too small, see below    schedule_delay_millis=1000,         # default 5000 - too slow, see below    max_export_batch_size=1024,         # default 512))trace.set_tracer_provider(provider)tracer = trace.get_tracer("chat-api")

Those three overridden numbers are not cosmetic, but the reason is often misunderstood. The default BatchSpanProcessor holds up to 2,048 spans in a queue. It does not simply wait 5 seconds between batches: as soon as 512 spans are queued it wakes up and exports batch after batch until the queue drains. The 5-second delay only matters in quiet periods. What limits throughput is how long each export takes, and the queue is what absorbs the gap while an export is in flight.

Now take a service handling 500,000 requests a day at 8 spans each. The daily average is modest:

Text
500,000 / 86,400 = 5.8 requests/sec  x 8 spans = 46 spans/sec

A busy hour running ten times the daily mean gives 463 spans per second. While the Collector answers in a few milliseconds, that is easy. But suppose the Collector stalls — it restarts during a deploy, or the network drops packets. The OTLP exporter waits up to its timeout (10 seconds by default in the Python SDK) before giving up on a batch, and nothing leaves the queue while it waits:

Text
2,048 / 463 = 4.4 seconds until the queue is fullafter that, every new span is dropped until the export returns

The symptom is maddening: traces are complete at 3 a.m. and full of holes at peak, which is exactly when you need them. Your requests see no error — the SDK drops spans rather than blocking your request path, and the only evidence is a "Queue full, dropping" warning in the application log. With max_queue_size=8192 the same stall takes 8,192 / 463 = 17.7 seconds to fill the queue, enough to ride out a short Collector restart. The shorter schedule_delay_millis gets quiet-period spans out within a second instead of five, and the larger batch halves the number of export calls at peak.

Check the dropped-span counter your SDK exposes. A tracing pipeline that quietly discards spans under load is worse than no tracing, because you will draw conclusions from data that is missing exactly the requests you care about.

Two related traps. SimpleSpanProcessor exports synchronously on every span end — fine in tests, catastrophic in production, where it adds a network round trip to every operation. And in serverless or short-lived processes you must call provider.shutdown() (or force_flush()) before exit, or the final batch dies with the process.

Naming things so that aggregation works

A trace you read by hand tolerates any naming. Aggregating across a million traces does not. The rule is that span names must be low cardinality — they are effectively a group-by key.

Bad span nameWhyGood
GET /users/8814/orders/2291Unique per request; grouping is impossibleGET /users/{id}/orders/{id}, with the IDs as attributes
search for refund policyContains user inputtool.search, with the query as an attribute
step_3Means nothing in a list of a thousandretrieval.rerank
processEvery service has one; collides in aggregate viewschat.answer

Attributes are where the high-cardinality detail goes, and OpenTelemetry publishes semantic conventions so that different libraries agree on key names. The generative-AI conventions now live in their own repository (open-telemetry/semantic-conventions-genai) and are still at Development status as of September 2026, so names can change: pin the version you target. They recommend naming an inference span {gen_ai.operation.name} {gen_ai.request.model}, for example chat claude-sonnet-5, and they cover the important attributes:

AttributeExampleWhat it unlocks
gen_ai.operation.namechatRequired; separates chat, embeddings and tool calls
gen_ai.provider.nameanthropicRequired; compare providers side by side (it replaced the older gen_ai.system)
gen_ai.request.modelgpt-4oLatency and cost per model
gen_ai.response.modelgpt-4o-2024-08-06Detect a provider resolving an alias to a new snapshot
gen_ai.usage.input_tokens1180Cost attribution per code path; by convention it includes cached tokens
gen_ai.usage.cache_read.input_tokens900See whether prompt caching is actually working
gen_ai.usage.output_tokens402The dominant latency driver
gen_ai.response.finish_reasons["length"]Find truncated answers, which look successful
gen_ai.request.max_tokens1024Tell a too-low cap apart from a model that stopped on its own

Following the convention rather than inventing my_model_name means vendor dashboards, the Collector's processors, and other people's instrumentation libraries all understand your spans without configuration.

Tracing an LLM call so the span is worth having

Python
import timefrom opentelemetry.trace import Status, StatusCodedef call_llm(prompt: str, model: str = "claude-sonnet-5", max_attempts: int = 3):    with tracer.start_as_current_span(f"chat {model}") as span:        span.set_attribute("gen_ai.provider.name", "anthropic")        span.set_attribute("gen_ai.operation.name", "chat")        span.set_attribute("gen_ai.request.model", model)        span.set_attribute("gen_ai.request.max_tokens", 1024)        span.set_attribute("prompt.chars", len(prompt))        span.set_attribute("prompt.sha256", sha256(prompt)[:16])   # not the text        for attempt in range(1, max_attempts + 1):            started = time.perf_counter()            first_token_at = None            chunks = []            try:                stream = client.messages.stream(                    model=model, max_tokens=1024,                    messages=[{"role": "user", "content": prompt}],                )                with stream as s:                    for text in s.text_stream:                        if first_token_at is None:                            first_token_at = time.perf_counter()                            span.add_event("first_token", attributes={                                "ttft_ms": round((first_token_at - started) * 1000, 1)})                        chunks.append(text)                    final = s.get_final_message()                total_ms = (time.perf_counter() - started) * 1000                gen_ms = total_ms - (first_token_at - started) * 1000                u = final.usage                out_tok = u.output_tokens                cached = (u.cache_read_input_tokens or 0) + (u.cache_creation_input_tokens or 0)                # Anthropic's input_tokens excludes cache; the convention's total includes it                span.set_attribute("gen_ai.usage.input_tokens", u.input_tokens + cached)                span.set_attribute("gen_ai.usage.cache_read.input_tokens",                                   u.cache_read_input_tokens or 0)                span.set_attribute("gen_ai.usage.output_tokens", out_tok)                span.set_attribute("gen_ai.response.model", final.model)                span.set_attribute("gen_ai.response.finish_reasons", [final.stop_reason])                span.set_attribute("llm.ttft_ms", round((first_token_at - started) * 1000, 1))                span.set_attribute("llm.output_tokens_per_sec",                                   round(out_tok / (gen_ms / 1000), 1) if gen_ms > 0 else 0)                span.set_attribute("llm.attempts_used", attempt)                return "".join(chunks)            except (RateLimitError, APITimeoutError) as exc:                span.add_event("retry", attributes={"attempt": attempt,                                                    "error": type(exc).__name__})                if attempt == max_attempts:                    span.set_status(Status(StatusCode.ERROR, str(exc)))                    span.record_exception(exc)                    raise                time.sleep(2 ** attempt * 0.5)

Three choices in there change what the span can tell you later.

Retries are events on one span, not sibling spans. Both designs are defensible, and this is the one the GenAI conventions recommend for automatic retries: one span for the whole logical call. It keeps llm.attempts_used on a single span, so the query "show me traces where the model needed a retry" is a simple attribute filter rather than a span-count aggregation. It also stops retries from inflating your span count and your bill.

Time to first token is separated from total duration. For streaming responses these are wildly different experiences. Suppose a call reports TTFT 380 ms and total 3,780 ms for 402 output tokens:

Text
generation window = 3,780 - 380 = 3,400 ms = 3.4 sthroughput        = 402 / 3.4 = 118 output tokens/sec

Now those two numbers move independently and each means something specific. TTFT climbing while throughput holds means queueing or a longer prompt to process. Throughput collapsing from 118 to 45 tokens per second while TTFT is unchanged means provider-side capacity pressure. Total duration alone conflates them, and the user experience of "waited 380 ms then read along" is nothing like "waited 3.8 seconds then everything appeared".

The prompt hash, not the prompt. You get to answer "were these two requests identical?" and "how many distinct prompts hit this path?" without shipping user text to a trace backend.

Tracing retrieval and database work

Auto-instrumentation covers most of this — one line hooks your database driver, HTTP client, and web framework:

Python
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentorfrom opentelemetry.instrumentation.requests import RequestsInstrumentorfrom opentelemetry.instrumentation.fastapi import FastAPIInstrumentorPsycopg2Instrumentor().instrument()RequestsInstrumentor().instrument()FastAPIInstrumentor().instrument_app(app)

What it buys you is best shown by the failure it exposes. A trace of a "fast" endpoint:

Text
GET /conversations/{id}                              441 ms  db.query  SELECT ... FROM conversations WHERE id=?    9 ms  db.query  SELECT ... FROM messages WHERE conv_id=?   14 ms  db.query  SELECT ... FROM users WHERE id=?            8 ms  db.query  SELECT ... FROM users WHERE id=?            8 ms  ... 45 more identical-shape queries ...  serialize                                            21 ms

Forty-seven single-row lookups at roughly 8 ms each:

Text
47 x 8 ms = 376 ms of the 441 ms total = 85%replaced by one batched query at ~22 ms  ->  saves 354 ms, an 80% reduction

This is the classic N+1 query, and it is essentially invisible without tracing: each individual query is fast, the endpoint's average is unremarkable, and nothing errors. The trace makes it obvious in one glance because the shape of the problem — a long run of identical sibling spans — is visually distinctive.

For vector search specifically, add attributes that let you correlate latency with retrieval quality: retrieval.k, retrieval.hits, retrieval.top_score, retrieval.index_version. When answer quality drops, "top score fell from 0.81 to 0.52 the day the index was rebuilt" is the sort of finding you can only make if the numbers were on the spans.

Tracing chains and agents

Framework code makes many nested calls per request, which is precisely where a flat log becomes useless. LangChain exposes a callback interface, and OpenTelemetry instrumentation for it (the opentelemetry-instrumentation-langchain package) maps each chain, tool, and model call onto a span:

Python
from opentelemetry.instrumentation.langchain import LangchainInstrumentorLangchainInstrumentor().instrument()# agent = langchain.agents.create_agent(model, tools=[search, ...])# Anything you write yourself just nests inside the current span:def answer(question: str):    with tracer.start_as_current_span("chat.answer") as root:        root.set_attribute("input.chars", len(question))        result = agent.invoke({"messages": [{"role": "user", "content": question}]})        # counters that only make sense at the root        turns = [m for m in result["messages"] if getattr(m, "tool_calls", None)]        root.set_attribute("agent.iterations", len(turns))        root.set_attribute("agent.tools_used",                           sorted({tc["name"] for m in turns for tc in m.tool_calls}))        return result["messages"][-1].content

agent.iterations on the root span is the attribute that would have solved the opening mystery on day one. With it, "p99 latency by iteration count" is a single query, and the answer — that traces with 4 iterations average 12 seconds while traces with 1 iteration average 900 ms — reframes the problem from "the model is slow" to "the agent does not know when to stop". Those have completely different fixes: the second one is a prompt change, a similarity check against previous queries, or a hard iteration cap.

Context propagation: where traces break

Context lives in thread-local or async-local storage. Anything that moves work to a different execution context loses it unless you carry it deliberately.

SituationSymptomFix
Work handed to a ThreadPoolExecutorChild spans appear as separate root tracesCapture context.get_current() before submitting; context.attach() inside the worker
Job pushed to Celery / SQS / KafkaProducer and consumer traces are unlinkedInject traceparent into the message headers; extract on consume
Manual HTTP call with a hand-built clientDownstream service starts a new traceUse an instrumented client, or inject the propagator into headers yourself
Fire-and-forget asyncio.create_taskSpan ends before the child does; child is orphaned or droppedKeep the parent span open, or start an independent span with a span link back
Batch job processing 500 messagesOne absurd trace with 4,000 spansOne trace per message, with span links to the batch span
Python
from opentelemetry import context, propagate# Producer: put the context into the messageheaders = {}propagate.inject(headers)queue.publish(payload, headers=headers)# Consumer: pull it back out and make it the parentctx = propagate.extract(message.headers)with tracer.start_as_current_span("job.process", context=ctx) as span:    span.set_attribute("messaging.message.id", message.id)    handle(message)# Thread pool: capture and reattachcurrent = context.get_current()def wrapped():    token = context.attach(current)    try:        return work()    finally:        context.detach(token)executor.submit(wrapped)

Sampling decisions must be consistent across services

If each service samples independently, traces come apart arithmetically. Say the front end samples at 10% and a downstream service at 1%, deciding separately:

Text
both keep their spans   = 0.10 x 0.01 = 0.001  ->  0.1% of traces are completefront end only          = 0.10 x 0.99 = 0.099  ->  9.9% are partial and misleading

Ninety-nine out of every hundred sampled traces would show a request vanishing into a service that appears to do nothing. The fix is ParentBased sampling: the root makes one decision, encodes it in the traceparent flags, and every downstream service honours it. Set the rate at the entry point only.

Python
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBasedprovider = TracerProvider(resource=resource,                          sampler=ParentBased(root=TraceIdRatioBased(0.1)))

Better still, sample at 100% in the SDK and let a Collector make the decision after the fact using what actually happened — keeping every error and every slow trace, and a small percentage of the rest.

From one trace to an answer

Reading individual traces is how you debug. Aggregating over span attributes is how you find things worth debugging. Both start with latency attribution.

Text
POST /chat                              4,200 ms   root  embed_query                              40 ms    1.0%  vector_search                           180 ms    4.3%  rerank                                   95 ms    2.3%  llm.call (402 output tokens)           3,780 ms   90.0%  format_response                          25 ms    0.6%                        sum of children = 4,120 ms                        unaccounted     =    80 ms  1.9%

Two readings come out of this immediately.

The unaccounted 80 ms is the gap between the root duration and the sum of its children. Some of it is always framework overhead, but a large gap means there is real work happening in an uninstrumented region. If that number is 40% rather than 2%, stop optimising and go find the missing span.

The attribution decides what to work on, and it usually contradicts intuition. Retrieval feels like the complicated part, so it attracts the optimisation effort. Compare the two available moves:

ChangeNew totalImprovement
Halve vector search: 180 → 90 ms4,110 ms2.1%
Cut retrieved chunks 5 → 3 (saves ~350 input tokens)~4,150 ms1.2% latency, but real cost savings
Cut output tokens 402 → 250 via a "be concise" instruction2,771 ms34.0%

Generation time is roughly linear in output tokens, so 3,780 × (250/402) = 2,351 ms, saving 1,429 ms of a 4,200 ms request. Two weeks of work on the vector index would have bought 2%. A sentence in the prompt buys a third of the latency. You cannot make that comparison without span-level attribution, and teams routinely spend the two weeks.

Once traces are flowing, the queries worth building are aggregate ones over span attributes:

Aggregate queryFinds
p95 of llm.call duration grouped by gen_ai.response.modelA provider silently routing you to a different model version
Count of spans per trace, p99Runaway loops and N+1 patterns
Share of traces with llm.attempts_used > 1Provider instability hidden behind successful retries
Mean gen_ai.usage.input_tokens by service.versionA prompt change that quietly doubled cost
retrieval.top_score p50 over timeIndex degradation before users complain
Traces where finish_reasons contains lengthTruncated answers being counted as successes

A trace answers "where did the time go" for one request. Aggregating span attributes answers "which requests are like this, and how many", which is the question that justifies engineering time.

What this means when you build one

A tracing pipeline that helps during an incident differs from one that merely exists in a handful of specific ways, and none of them can be added later without re-instrumenting.

Put the trace ID on your log lines. This is the highest-return single change in the whole pipeline. A trace tells you which span is fat; the logs for that trace ID tell you why. Without the shared key, you have two disconnected systems and every incident starts with guesswork.

Decide what never leaves your network, and enforce it in the Collector. Prompts and completions are user-submitted personal data. Put an attribute-deletion processor in the Collector so that redaction happens once, centrally, before export — not in each service, where one team will forget.

Put the aggregate counters on the root span. agent.iterations, llm.attempts_used, retrieval.k, tools_used, total tokens. These turn "find the pathological traces" from a scan over child spans into a single attribute filter, and they are what makes trace data queryable at scale rather than only browsable.

Verify the pipeline under load, not at your desk. Run a load test and compare the number of traces the backend received against the number of requests you sent. If they do not match, your span processor is dropping and every conclusion you draw from that data is filtered through an invisible bias towards quiet periods.

Sample by keeping what is interesting. A tail sampler that retains 100% of errors, 100% of traces slower than your p99, and 1% of the remainder gives roughly 2.5% of the raw storage volume while never discarding a failure. Head sampling at 1% keeps 25 of your 2,500 daily errors, which is not a debugging loop.

The team with the looping agent had all their metrics in place before they added tracing. What they lacked was one attribute — the number of iterations — on one span. That single number turned an unexplained bimodal latency histogram into a fifteen-minute prompt fix.