Skip to content
intermediate Phase 16 · Observability, Tracing, and Cost Control

Tracing LLM Calls

Capture spans for each prompt, retrieval, and tool call with full request context.

55m
0 problems
Topic Progress 0%

Anatomy of a Trace: What One Request Looks Like

A user asks your support assistant a question and gets a confident, wrong answer. The ticket lands on your desk with no stack trace, no logs, and no way to know which prompt version ran, which documents were retrieved, or how long the model took. This is the debugging void that LLM tracing exists to fill.

A trace is a tree of spans. Each span is one unit of work with a name, a start time, a duration, and a set of attributes. The root span covers the whole user request; child spans cover each stage inside it.

user request (root span, 1840ms)
├── prompt assembly (12ms)         ← system prompt v14 + history + retrieved docs
├── retrieval (640ms)              ← vector search over 3 chunks
│   └── rerank (210ms)              ← cross-encoder rescoring
├── model call (1150ms)             ← claude-sonnet-4-6, 1420 in / 380 out tokens
└── post-processing (38ms)          ← citation validation, PII redaction

Read the waterfall top to bottom and the story is already visible: retrieval and reranking ate 850ms of the 1840ms total. Without spans, you would have optimized the model call — the wrong target.

Every span should carry the same core attributes so any stage can be compared against any other:

Attribute Why it exists
prompt_version Pin the exact system prompt that ran; a bad answer is often a bad prompt
model Model behavior changes across versions; you need to know which one answered
input_tokens / output_tokens Cost and context-window pressure both live here
duration_ms Latency budgets are enforced per stage
retrieved_passages The documents the answer was grounded in; essential for incident replay
tool_arguments What the model asked a tool to do, and what the tool returned

Notice what is missing: the full user message and full model output. Those are large, often sensitive, and belong in a separate payload store linked by ID — not jammed into span attributes where they bloat every query. Traces answer what happened and where; payloads answer exactly what was said.

Instrumenting a Call: Spans, Attributes, and Context

The naive way to add tracing is to sprinkle print() calls around your LLM client. It works until the second service, the second model, or the second concurrent request — then the output is an interleaved mess with no shared identity. Structured tracing fixes this by giving every operation a span with a shared trace ID.

import time, uuid, json

class Span:
    def __init__(self, name, trace_id, parent_id=None):
        self.name = name
        self.trace_id = trace_id          # shared across the whole request
        self.span_id = uuid.uuid4().hex[:8]
        self.parent_id = parent_id        # links child to parent
        self.start = time.perf_counter()
        self.attributes = {}

    def set(self, key, value):
        self.attributes[key] = value

    def finish(self):
        return {
            'name': self.name,
            'trace_id': self.trace_id,
            'span_id': self.span_id,
            'parent_id': self.parent_id,
            'duration_ms': round((time.perf_counter() - self.start) * 1000, 1),
            'attributes': self.attributes,
        }

class Tracer:
    def __init__(self):
        self.spans = []

    def span(self, name, parent=None):
        trace_id = parent.trace_id if parent else uuid.uuid4().hex[:16]
        s = Span(name, trace_id, parent.span_id if parent else None)
        self.spans.append(s)
        return s

The non-obvious line is trace_id = parent.trace_id if parent else .... A child span must inherit the parent's trace ID, not generate a new one — otherwise every stage becomes an isolated trace and the waterfall collapses. The parent_id is what rebuilds the tree.

Instrument a real call:

tracer = Tracer()
root = tracer.span('chat_request')
root.set('prompt_version', 'support-v14')
root.set('model', 'claude-sonnet-4-6')

retrieval = tracer.span('retrieval', parent=root)
retrieval.set('retrieved_passages', ['doc-102', 'doc-87', 'doc-41'])
time.sleep(0.05)  # simulated vector search
retrieval.finish()

model = tracer.span('model_call', parent=root)
model.set('input_tokens', 1420)
model.set('output_tokens', 380)
time.sleep(0.11)  # simulated generation
model.finish()

root.finish()
print(json.dumps(tracer.spans, indent=2))

Output (trimmed):

[
  {"name": "retrieval", "trace_id": "9f2c...", "parent_id": "a1b2...", "duration_ms": 50.2, "attributes": {"retrieved_passages": ["doc-102", "doc-87", "doc-41"]}},
  {"name": "model_call", "trace_id": "9f2c...", "parent_id": "a1b2...", "duration_ms": 110.4, "attributes": {"input_tokens": 1420, "output_tokens": 380}},
  {"name": "chat_request", "trace_id": "9f2c...", "parent_id": null, "duration_ms": 160.8, "attributes": {"prompt_version": "support-v14", "model": "claude-sonnet-4-6"}}
]

Every span shares trace_id 9f2c..., so a collector can rebuild the full request from any service that emitted a piece of it. In production you would export these spans to OpenTelemetry rather than printing them — the span model is identical, only the exporter changes.

Propagating Trace IDs Across Service Boundaries

Your chat service calls a retrieval service, which calls an embedding service, which calls the model provider. Each is a separate process, often a separate deploy. If each generates its own trace ID, you get four unrelated traces and no way to answer 'what happened to user 42's request?'

Context propagation solves this by passing the trace identity through the call. The caller injects its trace ID and span ID into the outgoing request; the callee extracts them and uses them as its own trace ID and parent span ID.

TRACE_ID_HEADER = 'x-trace-id'
SPAN_ID_HEADER = 'x-span-id'

def inject(headers, span):
    headers[TRACE_ID_HEADER] = span.trace_id
    headers[SPAN_ID_HEADER] = span.span_id
    return headers

def extract(headers, tracer, name):
    trace_id = headers.get(TRACE_ID_HEADER)
    parent_id = headers.get(SPAN_ID_HEADER)
    if not trace_id:
        return tracer.span(name)  # no context: start a fresh trace
    parent = SimpleNamespace(trace_id=trace_id, span_id=parent_id)
    return tracer.span(name, parent=parent)

The retrieval service receives the request, calls extract, and its spans automatically join the caller's trace. The embedding service does the same one hop further. The result is one waterfall across three services.

Two failure modes to design for:

  • Header loss through queues. If a request passes through a message queue, headers do not travel with the message body. Inject the trace ID into the message envelope instead, and extract it on the consumer.
  • Clock skew. Span start times come from different machines. Never compute parent-child duration from timestamps across services — use the parent-child relationship, not wall-clock subtraction.

Propagation is also what makes incident replay possible: with a trace ID from a customer ticket, you can pull every span across every service and reconstruct the exact request.

Sampling and Storage: Keeping Traces Affordable

Full-fidelity tracing on every request is the right default for a debug session and the wrong default for production. At 50,000 requests per day with ten spans each, you are storing half a million spans daily — and most of them are boring, successful, identical requests.

Sampling decides which traces to keep. The two strategies answer different questions:

Strategy How it decides Best for
Head-based Random decision at the root span, propagated to children Representative performance data; cheap
Tail-based Buffer spans, decide after the request completes Keeping every error and slow request; expensive

Head-based sampling is a coin flip at the root: if the root span is sampled, every child is sampled with it, so the trace stays whole. Tail-based sampling waits until the request finishes, then keeps the trace only if it errored or exceeded a latency threshold. Tail-based catches the traces you actually debug, but it requires buffering every span in memory until the decision — a real cost at high traffic.

A practical middle ground: sample 100% of requests that error or exceed your latency budget, and 1% of healthy requests. You keep full visibility into failures and a statistical sample of normal behavior.

What you record matters as much as what you keep. Record retrieved passages and tool arguments in spans — they are the difference between 'the model answered wrong' and 'the model answered wrong because it was given the wrong document.' But redact PII before it reaches a span attribute. A trace that leaks customer emails into your observability backend is a data exposure incident, not a debugging tool.

Storage cost is the reason this topic sits next to metering token usage: traces and token usage are the two production signals you cannot debug without, and both need sampling and retention policies to stay affordable.

Quiz

1. Why must a child span inherit the parent's trace ID instead of generating its own?

Question 1 options

2. What is the main advantage of tail-based sampling over head-based sampling?

Question 2 options

3. Why should retrieved passages be recorded as span attributes?

Question 3 options

4. What is the risk of putting full user messages and model outputs directly into span attributes?

Question 4 options

Flashcards

Question

What is a span in LLM tracing?

Answer

A single unit of work in a trace — one stage of a request such as retrieval or a model call — with a name, start time, duration, and attributes like prompt version, model, and token counts.

Question

Why must child spans inherit the parent's trace ID?

Answer

The trace ID is the shared identity of a request. Inheriting it keeps every stage in one reconstructable trace; generating a new one per span would fragment the request into isolated traces.

Question

Head-based vs tail-based sampling?

Answer

Head-based makes a random keep/drop decision at the root span and propagates it to children — cheap and representative. Tail-based buffers spans and decides after completion, keeping every error and slow request but costing more memory.

Question

What belongs in span attributes vs a separate payload store?

Answer

Attributes hold small structured fields: prompt version, model, token counts, duration, retrieved passage IDs, tool arguments. Full user messages and model outputs go in a payload store linked by ID to avoid bloat and PII leakage.

Question

Why propagate trace IDs through message queues via the envelope?

Answer

Headers do not travel with queue message bodies. Injecting the trace ID into the message envelope and extracting it on the consumer keeps the trace whole across asynchronous boundaries.

Revision Notes

Key Takeaways

  • 1. A trace is a tree of spans; the root span is the user request, child spans are the stages, and the trace ID shared across all of them is what makes a request reconstructable.
  • 2. Every span should carry prompt version, model, token counts, duration, retrieved passages, and tool arguments — the difference between 'the model answered wrong' and 'the model answered wrong because of the prompt it was given.'
  • 3. Context propagation via headers (or message envelopes) keeps the trace whole across service boundaries; without it, every service emits an isolated fragment.
  • 4. Sample debug traces at a higher rate than routine ones, and redact PII before it reaches span attributes.

Interview Tips

  • • Walk through a concrete incident where a trace identified the failing stage in minutes — name the stage, the span attribute that gave it away, and the fix.
  • • Explain head-based vs tail-based sampling and when each is the right default; interviewers probe whether you have operated traces at scale, not just read about them.
  • • Describe what you put in span attributes vs a payload store, and why — the PII answer signals production maturity.
  • • Connect tracing to metering and incident response: traces give request-level replay, metering gives aggregate spend, and both are needed to debug a cost or quality incident.

Cheat Sheet

Tracing LLM Calls — Cheat Sheet

Span model: one trace = one user request; child spans = stages (prompt assembly, retrieval, model call, post-processing).

Every span carries: prompt_version, model, input_tokens, output_tokens, duration_ms, retrieved_passages, tool_arguments.

Context propagation: caller injects x-trace-id / x-span-id headers; callee extracts and uses them as its trace ID and parent span ID. Through queues, put the trace ID in the message envelope.

Sampling:

  • Head-based: random keep/drop at root, propagated to children. Cheap, representative.
  • Tail-based: buffer, decide after completion. Keeps every error/slow trace. Costs memory.
  • Practical: 100% of errors + slow requests, 1% of healthy.

Payloads vs attributes: full user messages and model outputs go in a payload store linked by ID — never in span attributes (bloat + PII risk).

Redaction: scrub PII before it reaches a span attribute. A trace that leaks customer emails is an exposure incident.

Links: metering token usage · incident response

Continue Learning

Before You Start

Next Topics

Related Reading