lumo

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

  1. Describe a trace as one tree of spans for one task, and say what each span of an LLM feature should record.
  2. Turn on the anthropic SDK’s built-in OpenTelemetry spans, choose its content mode, and nest them inside your own task spans.
  3. Decide what a trace keeps (ids, versions and hashes instead of customer text), how long, and whether to sample it.
  4. Follow one bad output through its trace to the step that caused it, then to its log lines and its eval case.
  1. Calling a model API: request, response and cost15 min
  2. Debugging RAG: the common failure modes17 min

Video 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:

text
2026-10-28 15:42:09 INFO draft_job done ticket=tkt_58812 shop=204 duration=9.1s

He 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.

One draft job is one trace. Deskora creates the step spans; the anthropic SDK adds a span for each model call inside them.
Check yourselfAnswer in your head first, then reveal
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 answerHide answer
No. The input classifier runs at the same time as the rewrite and the search, so their spans overlap on the timeline. You read a trace by where each span sits in time, not by adding durations. Not “the clocks disagree”: overlap is how steps that run in parallel look.

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 traceparent header 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_count says how many times the request was sent again.
  • Errors mark the span with status error and an error.type, such as overloaded_error or client_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.

python
# 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 exits

The 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):

json
{
  "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.

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.

Each kind of data goes to the place built for it. The trace links them all by id but holds no customer text.

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.

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.

Bruno’s draft job. The reranker timed out, the first draft promised a refund, and writing it again added 3.7 seconds.

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:

python
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:

Pick by where the data lives, whether it reads the gen_ai attributes, and what your volume costs there.

Where this shows up

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

Starting a span without making it current

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.

Pricing gen_ai.usage.input_tokens as normal input

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.

Putting customer text in your own attributes

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 traceparent header in a test run 1. OpenTelemetry Python’s default propagators also send baggage. Set "propagation": False in open_telemetry if 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_id in open_telemetry sets gen_ai.conversation.id without sending it to the API; set it per conversation with client.with_options(open_telemetry={**client.open_telemetry, "conversation_id": ...}) 1.

Check your understanding

Check yourselfAnswer in your head first, then reveal
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 answerHide answer
The SDK sent the request three times, and the span covers all three attempts plus the waits between them. Look for the error before each retry in the log lines with the same trace id. Not a slow model: one span means one SDK call, not one attempt.
Check yourselfAnswer in your head first, then reveal
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 answerHide answer
The rebuilt request differs from what the model really read, perhaps because an article was edited or a message was added since. A replay of it can’t prove the cause, so he needs the masked capture, if capture was on, or a version of the sources from that time.
Check yourselfAnswer in your head first, then reveal
To save storage, a teammate proposes keeping only traces with an error status. Which of Priya’s flags would still have a trace?Show answerHide answer
Only those where some span failed, like the rerank timeout here. A draft that promises a refund with clean retrieval has status OK on every span, so it would be dropped. Quality failures are quiet; the person reading finds them later.
Check yourselfAnswer in your head first, then reveal
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 answerHide answer
Not from Deskora’s client, because it is created with the open_telemetry option, so it reads none of those variables. A client created without the option would record every prompt and reply, for every shop. Use the per-shop debug store instead.

Summary

  1. 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.
  2. anthropic 2.3.0 creates anthropic.messages.create spans once a tracer provider is registered; retries stay in one span. Set the content mode, metadata_only by default, in code.
  3. Hashes prove a rebuilt request is exact. Outputs can’t be rebuilt, so they go masked to a short-lived debug store.
  4. Keep every LLM task’s trace: bad outputs have status OK and are reported later, so sampling loses them.
  5. Put the trace id on every log line and eval case.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.