Module 11 · Lesson 2 of 9Free
Tracing LLM calls: seeing every step
- Level
- Intermediate
- Topic
- Observability and operations
- Reading time
- 17 min
When you finish this page you can
- Describe a trace as one tree of spans for one task, and say what each span of an LLM feature should record.
- Turn on the anthropic SDK’s built-in OpenTelemetry spans, choose its content mode, and nest them inside your own task spans.
- Decide what a trace keeps (ids, versions and hashes instead of customer text), how long, and whether to sample it.
- Follow one bad output through its trace to the step that caused it, then to its log lines and its eval case.
Builds on 2 lessons
Start with Module 01, lesson 315 minVideo lesson · coming soon
A strange draft, and one log line
Deskora is a help desk app. A shop’s support team uses it to answer customer emails from one shared inbox. Each customer problem becomes a ticket: one customer’s conversation with the support team. For each ticket, Deskora’s drafting assistant writes a reply from the shop’s help-center articles and similar past tickets, and a support agent edits and sends it.
Priya is a support agent at a kitchenware shop on Deskora. Yesterday afternoon a customer, Ines, wrote that her casserole dish arrived with a cracked lid. The draft said: “Thank you for the photos of the cracked lid. We’re sorry it arrived like this.” Then it stopped, with nothing about the shop’s rule: damaged items must be reported within 14 days, and then Priya decides what to offer.
Priya flags it. Tomás, the Deskora engineer who builds the AI features, opens the draft worker’s log and finds one line:
2026-10-28 15:42:09 INFO draft_job done ticket=tkt_58812 shop=204 duration=9.1sHe can see that the draft exists. He can’t see which articles were retrieved, which prompt version ran, or what the model wrote before a guardrail (a check on a model’s input or output) changed it. Nor why the job took 9 seconds, when a short ticket usually takes about 5; if Priya opens a ticket before its draft is ready, she waits that long.
This lesson answers one question: how can Tomás see every step of one draft, with its inputs, outputs, time and cost, without copying every customer’s email into another system?
The tickets, timings, token counts and sizes in this lesson are estimates for this scenario. Prices are for Claude Haiku 5.5 as of October 2026.
A trace is one tree of spans per task
A trace is the record of one task, here one draft job, made of . A span is one timed step with a name, start and end times, a status (OK or error) and attributes: key–value pairs such as deskora.ticket.id = tkt_58812. Every span but the first has a parent, so they form a tree sharing one trace id.
Diagram not drawn yet: llm-trace-tree.svg
A tree for one draft job. The root span draft_job, labelled ticket tkt_58812, shop 204, prompt draft-v7. Its children, top to bottom: input_classifier, rewrite_query, search and rerank, fetch_sources, write_draft, and output checks and classifier. Four of them, input_classifier, rewrite_query, write_draft and the output classifier, each hold a smaller child span named anthropic.messages.create, drawn in a different style and labelled from the SDK. Beside each step, what it records: candidate ids and scores; source ids and versions; request and draft hashes; the verdict. Beside the SDK spans: model, tokens, stop reason, request id.
In one trace, the draft_job span lasts 9.1 s, but its child spans add up to 10.75 s. Is the trace broken?Show answer
Each step records what it needs to explain itself later:
| Span | What it records |
|---|---|
draft_job, the task |
Ticket id, shop id, prompt version, what started it, total cost |
hybrid_search, rerank |
Candidate ids with ranks and scores, which fallback fired |
fetch_sources |
Ids and versions of the sources sent |
write_draft |
A hash of the exact request and a hash of the draft |
output_checks, output_classifier |
Which check ran and its verdict |
anthropic.messages.create |
Model, tokens in and out, thinking tokens, stop reason, request id |
Inputs and outputs appear as ids and hashes, not text; why comes later.
Tomás could write these records himself. Here is why he uses a standard.
OpenTelemetry: one format every tool reads
OpenTelemetry is an open standard, with libraries for most languages, for producing traces, metrics and logs. In Python, code calls the API package, opentelemetry-api, to create spans, and the SDK package, opentelemetry-sdk, decides which to keep and exports them. Spans leave the process over OTLP, OpenTelemetry’s protocol, to a tracing backend that stores them and draws each trace as a timeline.
A hand-written record per draft, like the one Debugging RAG designed for retrieval, has no timeline and misses any step nobody remembered to log. A standard adds three things:
- Libraries already speak it. The anthropic SDK and instrumentation for web frameworks and databases create spans that join your tree.
- One trace crosses services. The trace context travels in a
traceparentheader or a queue message, so the app server that queues a job and the worker that runs it share a trace (Observability: logs, metrics and traces covers this). - The backend can change without touching the code that creates spans.
The SDK records a span for every call
The anthropic Python SDK, version 2.3.0, depends on opentelemetry-api and creates spans itself 1. Until the application registers a tracer provider, those spans record nothing. Once one is registered, every SDK call gets one span: for client.messages.create, a client span named anthropic.messages.create. Its attributes use OpenTelemetry’s generative AI names:
| Attribute | What it holds |
|---|---|
gen_ai.request.model, gen_ai.request.max_tokens |
What was asked for |
gen_ai.request.reasoning.level |
The effort, when set |
gen_ai.usage.input_tokens |
Input tokens, cache reads and writes included |
gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_write.input_tokens |
The cached parts |
gen_ai.usage.output_tokens, gen_ai.usage.reasoning.output_tokens |
Output tokens, and how many were thinking |
anthropic.message.stop_reason |
end_turn, max_tokens, refusal and so on |
anthropic.request.id |
The provider’s id for the request |
Three behaviours matter when you read one 1:
- Retries stay inside one span. There is no span per attempt. The span covers every attempt and the waits between them, and
http.request.resend_countsays how many times the request was sent again. - Errors mark the span with status error and an
error.type, such asoverloaded_errororclient_timeout_error. - A streamed reply’s span ends when your code finishes reading the stream, and records
gen_ai.response.time_to_first_chunk.
The SDK’s tool runner adds its own spans too. Lab: adding tracing to an agent traces a hand-written agent loop instead, and adds a span for each tool call itself.
Metadata or content: one choice on the client
The client option open_telemetry sets what the spans hold. traces.content_mode is "metadata_only" by default: the attributes above, no text 1. With "content", each span also gets the system prompt, every input message and the reply, thinking included, as gen_ai.system_instructions, gen_ai.input.messages and gen_ai.output.messages, up to 750,000 bytes per span by default 1.
The variable ANTHROPIC_OPEN_TELEMETRY_TRACES_CONTENT_MODE=content turns content on only for a client created without the option 1. So set the mode in code, and no environment variable can switch customer text on.
Deskora’s spans wrap the SDK’s
The SDK starts its span from whatever span is current. start_as_current_span opens a span and makes it current until its with block ends, so a model call inside write_draft gets a child span of write_draft.
# pip install anthropic==2.3.0 opentelemetry-sdk==1.45.1
import hashlib
import json
from typing import Any
import anthropic
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
provider = TracerProvider(resource=Resource.create({"service.name": "draft-worker"}))
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider) # from here on, every SDK call records a span
tracer = trace.get_tracer("deskora.drafting")
client = anthropic.Anthropic(open_telemetry={"traces": {"content_mode": "metadata_only"}})
def sha256(value: Any) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
def draft_job(ticket_id: str, shop_id: str, thread: str) -> str:
with tracer.start_as_current_span("draft_job") as job:
job.set_attributes({"deskora.ticket.id": ticket_id, "deskora.shop.id": shop_id,
"deskora.prompt.version": "draft-v7"})
with tracer.start_as_current_span("retrieve") as span:
sources = [("damaged#1@v4", "Report damaged items within 14 days.")] # stand-in
span.set_attribute("deskora.source.ids", [source_id for source_id, _ in sources])
context = "\n".join(text for _, text in sources)
request: dict[str, Any] = {"model": "claude-haiku-5-5", "max_tokens": 1024, "messages": [
{"role": "user", "content": f"Help center:\n{context}\n\nTicket:\n{thread}"}]}
with tracer.start_as_current_span("write_draft") as span:
reply = client.messages.create(**request) # the SDK's span opens inside this one
draft = "".join(b.text for b in reply.content if b.type == "text")
span.set_attributes({"deskora.request.sha256": sha256(request),
"deskora.draft.sha256": sha256(draft)})
with tracer.start_as_current_span("output_check") as span:
promise = any(word in draft.lower() for word in ("refund", "replacement")) # stand-in
span.set_attribute("deskora.check.promise", promise)
cost = (reply.usage.input_tokens * 0.10 + reply.usage.output_tokens * 0.50) / 1_000_000
job.set_attribute("deskora.cost_usd", cost) # Claude Haiku 5.5 prices, October 2026
return draft
print(draft_job("tkt_58812", "204", "Customer: The lid of my casserole dish arrived cracked."))
provider.shutdown() # sends the last spans before the program exitsThe search and the check are stand-ins. The cost uses Claude Haiku 5.5’s prices as of October 2026: $0.10 per million input tokens and $0.50 per million output tokens, for prompts up to 100,000 tokens 3. One run is about 60 input tokens and a few hundred output tokens: well under $0.001.
The exporter prints each span as JSON. Trimmed, the SDK’s span from one run (against a test stand-in, so the numbers are examples):
{
"name": "anthropic.messages.create",
"kind": "SpanKind.CLIENT",
"parent_id": "0xda2e15e6318b08a2",
"status": {"status_code": "OK"},
"attributes": {
"gen_ai.provider.name": "anthropic",
"gen_ai.request.model": "claude-haiku-5-5",
"anthropic.request.id": "req_011CExample",
"anthropic.message.stop_reason": "end_turn",
"gen_ai.usage.input_tokens": 52,
"gen_ai.usage.output_tokens": 210,
"gen_ai.usage.reasoning.output_tokens": 160
}
}parent_id is write_draft’s span id. In production, Deskora swaps ConsoleSpanExporter for OTLPSpanExporter from opentelemetry-exporter-otlp-proto-http, pointed at its backend with OTEL_EXPORTER_OTLP_TRACES_ENDPOINT. BatchSpanProcessor exports from a background thread, so a draft never waits for the backend.
The code records hashes where it could have recorded text. That is the first real decision.
Ids and hashes, not customer text
A span that holds the prompt holds the customer’s email. At Deskora’s 75,000 drafts made a day:
Metadata only, per draft
about 14 spans × about 500 bytes = about 7 KB
Ten step spans plus four SDK spans. 500 bytes a span is an assumption.
Metadata only, per day
75,000 drafts × 7 KB ≈ 525 MB
About 16 GB kept at 30 days.
Content mode adds the text sent
(7,800 + 4,000 + 650 + 600) tokens × about 4 bytes ≈ 52 KB per draft; × 75,000 ≈ 3.9 GB a day
The draft call, the rewrite and two classifiers on a typical ticket, at about 4 bytes a token of English.
Size is the smaller problem: every one of those records would hold a customer’s name, email and address.
What should Deskora’s traces hold about each model call’s input and output?
Content mode on every span.
Easy to debug: every prompt and reply is one click away. The tracing backend becomes the debug log from Data leakage again, about 3.9 GB of customer emails a day, readable by every engineer with access and by the vendor, kept as long as traces are.
Metadata only, and nothing more.
Nothing personal leaves Deskora’s databases. But Tomás can’t prove what the model read or see what it wrote, so a strange draft is still a guess.
Metadata only, plus ids, versions and SHA-256 hashes of each request and output; masked text in a separate debug store.
Traces stay small and hold no customer text. The text sits in one store with tight access and a short expiry, and the hashes prove which text belongs to which span.
Why this choice
Inputs can be rebuilt; the hash proves it. The trace keeps the ticket’s message ids, each source’s id and version, and the prompt version. Tomás rebuilds the request from them and hashes it. If the hash matches deskora.request.sha256, the rebuild is exact; if not, something changed since.
Outputs can’t be rebuilt, because the model doesn’t return the same text twice. So the raw draft goes, masked, to the debug store from Data leakage: its own access list, a 7-day expiry, on for one shop at a time. The span carries the capture id and the draft’s hash.
When the choice changes. If the backend runs inside Deskora, with the tickets’ access rules and deletion, content mode on a small sample becomes reasonable. For an internal tool with no customer data, content mode is fine.
A hash of a short, guessable value, such as an email address, can be reversed by hashing guesses, so hash whole requests and outputs only.
Each copy also needs a rule. Deskora keeps traces 30 days, long enough for most flags to arrive (assumed), and the debug store 7.
Diagram not drawn yet: llm-trace-data-paths.svg
Left to right. The draft worker in the centre-left. Arrow 1, solid, to the Claude Messages API: the full request. Arrow 2, dashed, over OTLP to the tracing backend: ids, versions, hashes, tokens, timings, no text, labelled 30 days, about 525 MB a day. Arrow 3, through a mask box, to the debug store: masked request and draft text, labelled 7-day expiry, on-call group only, on for one shop at a time. Arrow 4, dashed, to the log store: log lines carrying the trace id. A fifth box, the eval set, receives a scrubbed copy of a flagged case with its trace id, through the same mask box. The tracing backend’s span points to the debug store by capture id, drawn as a thin link.
Keep every draft’s trace
Sampling cuts storage by keeping some traces and dropping the rest. The OpenTelemetry Python SDK’s default sampler, parentbased_always_on, keeps everything 2. The System Design observability lesson compares deciding at the start of a request with deciding after it ends; an LLM feature changes that comparison.
Should Deskora sample its draft traces?
Keep 10%, decided when the job starts (ParentBasedTraceIdRatio(0.1), or OTEL_TRACES_SAMPLER=parentbased_traceidratio with OTEL_TRACES_SAMPLER_ARG=0.1).
Storage falls 90%. Any one flagged draft has only a 10% chance of a trace, so 9 of Priya’s 10 flags are guesses again.
Keep traces with an error or a slow job, decided seconds after each one ends.
Keeps errors and slow jobs. But a bad draft usually has status OK, and Priya’s flag arrives hours after the decision was made.
Keep every draft job’s trace, metadata only.
About 525 MB a day in metadata mode, so every flag has its trace. Sampling still applies to high-volume, low-value traffic such as ticket list page loads.
Why this choice
A web request fails loudly, with a 500 or a timeout. An LLM output fails quietly: a draft that promises a refund ends with status OK, and the only signal is a person reading it the next day. A rule that decides from the trace’s own outcome can’t see that. And at 7 KB a draft, keeping every trace is cheap.
When the choice changes. At 100 times the volume, about 52 GB a day, drop low-value detail such as per-candidate spans before dropping whole traces. If you must sample, decide once at the root, so a kept trace never misses its children.
Reading Priya’s trace, step by step
Tomás ships tracing that evening and switches on masked capture for Priya’s shop. The next afternoon Priya flags a second draft like Ines’s: Bruno’s kettle arrived dented, and the draft thanks him for the photos and stops, after 9.1 seconds. Ines’s draft very likely failed the same way, but nothing recorded can prove it: evidence must exist before the incident. Bruno’s draft has a trace.
Diagram not drawn yet: llm-trace-waterfall-priya.svg
A timeline from 0 to 9.1 seconds for one draft job on ticket tkt_58931. Rows from top: draft_job, 0 to 9.1 s. input_classifier, 0 to 1.65 s, overlapping the next rows. rewrite_query, 0 to 0.7 s. hybrid_search, 0.7 to 0.8 s. rerank, 0.8 to 1.05 s, in red, labelled timeout, hybrid order used. fetch_sources, 1.05 to 1.1 s, labelled no damaged-items article. write_draft 1, 1.1 to 4.1 s, labelled capture id. output_classifier, 4.11 to 5.21 s, in red, labelled promise true. write_draft 2, 5.21 to 7.81 s, highlighted, labelled regenerated. output_classifier, 7.82 to 8.92 s, in green, promise false. save_draft, 8.92 to 9.1 s. A bracket over the regenerate and second check reads plus 3.7 s.
Find the trace
search: deskora.ticket.id = tkt_58931 → 1 trace, 9.1 s, status OK
Nothing in its status says the draft was strange.
Rule out the prompt
deskora.prompt.version = draft-v7, the version every draft has used for 6 days
A prompt change didn’t cause this.
Find the 9 seconds
9.1 s − 2.6 s second draft − 1.1 s second check ≈ 5.4 s, a normal job
Two write_draft spans, and neither SDK span has a resend count, so no retry and no slow model. The guardrail asked for a second draft.
See what the model wrote first
output_classifier: deskora.check.promise = true → regenerate once
The first write_draft span’s capture id leads to masked text in the debug store: “I’ve arranged a full refund for the dented kettle.” Its hash matches deskora.draft.sha256.
Find why
rerank: status error, timeout at 250 ms → hybrid order; damaged#1@v4 ranked 7th, so not sent
fetch_sources shows a past ticket with an approved refund first, and no damaged-items article. With no rule to explain, the second draft could only thank Bruno and stop.
Prove it
rebuilt request hash = deskora.request.sha256 → exact rebuild
Replaying the rerank on the 50 candidate ids puts the damaged-items article first. With it added, 5 runs give 5 drafts that explain the 14-day rule and promise nothing (example results).
The fix is in retrieval, not the prompt: what happens when the reranker is slow, which Design: a support assistant over company docs set as a 250 ms timeout and a fallback to hybrid order.
The trace also gives the cost of the whole task, the unit that matters. Every call is Claude Haiku 5.5: input tokens × $0.10 per million + output tokens × $0.50 per million, thinking included 3:
| Call | Tokens in / out | Cost |
|---|---|---|
| Input classifier | 650 / 115 | $0.00012 |
| Rewrite | 1,500 / 20 | $0.00016 |
| First draft (150 thinking, assumed) | 5,000 / 250 | $0.00063 |
| First output classifier | 600 / 60 | $0.00009 |
| Second draft | 5,200 / 210 | $0.00063 |
| Second output classifier | 600 / 60 | $0.00009 |
| Whole job | about $0.0017 |
Without the regenerate it costs about $0.0010, so this failure made it about 70% dearer. Rerank and embedding calls aren’t counted; gen_ai.usage.reasoning.output_tokens gives the real thinking count.
Traces join the logs and the eval set
Log lines hold the detail each step wrote. A trace id on every line joins them to the trace. A logging filter reads it from the current span:
import logging
from opentelemetry import trace
class TraceIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
context = trace.get_current_span().get_span_context()
record.trace_id = format(context.trace_id, "032x") if context.is_valid else "-"
return True
handler = logging.StreamHandler()
handler.addFilter(TraceIdFilter()) # on the handler, so the SDK's own log lines get it too
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s trace_id=%(trace_id)s %(message)s"))
logging.basicConfig(level=logging.INFO, handlers=[handler])The SDK logs each retry, such as Retrying request to /v1/messages in 0.43 seconds, while its own span is current, so that line carries the job’s trace id too.
The eval set joins the same way. Debugging RAG turned each debugged flag into a labelled test case. Bruno’s ticket becomes one, scrubbed of personal data, tagged “rerank fallback, policy article missing”, with the trace id it came from. Eval runs call the same draft_job code with deskora.run = "eval" and the case id, so a failing case opens to a trace like a production one. Notes in Error analysis can cite trace ids.
Where the spans go: backends
Any backend that accepts OTLP can store these spans. Two examples, from their own documentation:
- Langfuse receives traces on its
/api/public/otelendpoint, with Basic auth from a public and a secret key, over HTTP (JSON or protobuf; not gRPC yet). It aims to follow OpenTelemetry’s generative AI conventions, and its docs cover self-hosting 4. - Arize Phoenix accepts traces over OTLP and isn’t tied to one model vendor or framework 5.
Pick by where the data lives, whether it reads the gen_ai attributes, and what your volume costs there.
Where this shows up
- Design: an AI customer support platform: one trace per ticket across drafting, the resolution agent and chat.
- Design: a voice ordering assistant: spans show where a spoken reply’s time goes.
- Design: scaling a chat assistant to a million users: what to keep per trace at that volume.
A trace explains one draft someone flagged. It can’t say how many went wrong that nobody flagged; Monitoring quality in production takes that on.
Common mistakes
tracer.start_span() creates a span but doesn’t make it current. The SDK’s span then has the wrong parent, or starts a trace of its own. Use start_as_current_span around anything that calls the model.
On the SDK’s spans this count includes cache reads and cache writes, which cost different amounts. Price gen_ai.usage.cache_read.input_tokens and gen_ai.usage.cache_write.input_tokens separately, and the rest at the normal input price.
Metadata mode covers only the SDK’s spans. A rewritten query or a customer name you set as an attribute reaches the backend all the same.
Going deeper
Propagation, agent loops and conversations (for experienced engineers)
- The trace id goes to the provider. By default the SDK writes the trace context into each request’s headers with the registered propagator, a
traceparentheader in a test run 1. OpenTelemetry Python’s default propagators also send baggage. Set"propagation": Falseinopen_telemetryif you don’t want that. - Content mode grows with agent loops. Each call’s span records the whole conversation it sent, so a 12-call agent run stores its first messages 12 times.
- One conversation, many calls.
conversation_idinopen_telemetrysetsgen_ai.conversation.idwithout sending it to the API; set it per conversation withclient.with_options(open_telemetry={**client.open_telemetry, "conversation_id": ...})1.
Check your understanding
A model call’s span lasts 4.2 s and has http.request.resend_count = 2. The model usually answers in 1 s. What happened?Show answer
Tomás rebuilds a flagged draft’s request from the ids in its trace, and the hash doesn’t match deskora.request.sha256. What does that tell him?Show answer
To save storage, a teammate proposes keeping only traces with an error status. Which of Priya’s flags would still have a trace?Show answer
A developer sets ANTHROPIC_OPEN_TELEMETRY_TRACES_CONTENT_MODE=content on the draft worker to debug one shop. Does customer text reach the tracing backend?Show answer
Summary
- Record one trace per draft job: Deskora’s step spans, with the SDK’s span for each model call inside, holding ids, versions, hashes, tokens, cost and timing, but no customer text.
- anthropic 2.3.0 creates
anthropic.messages.createspans once a tracer provider is registered; retries stay in one span. Set the content mode,metadata_onlyby default, in code. - Hashes prove a rebuilt request is exact. Outputs can’t be rebuilt, so they go masked to a short-lived debug store.
- Keep every LLM task’s trace: bad outputs have status OK and are reported later, so sampling loses them.
- Put the trace id on every log line and eval case.