ealeng

Episode 36 of 56 · Observability

Tracing: is every call on the receipt?

A trace is an itemized receipt for one request: every step, with its time and cost. Thirty test questions to a RAG assistant cost 1.63¢, and the traces held 22% of it: tracing only the main model call missed the judge, which was 78% of the bill. Tracing everything but running the judge in a worker thread recorded every call, in traces attached to no question. Passing the context fixed it, and two checks catch both gaps.

Lab 14 · Observability10 min read
Video transcript5:31

0:00Cold open

0:01

I sent thirty test questions to our AI assistant.

0:05

Every call was real. Priced by what OpenAI reported back for each call, those questions cost one point six cents. That’s the bill.

0:14

My code also keeps a record of every request, called a trace. Added up, my traces recorded just over a third of a cent.

0:23

My traces held twenty-two percent of the bill.

0:27

Where did the rest go?

0:29Intro

0:31

Hands-on AI engineering: observability, part one.

0:35

Today: traces, and how they can quietly miss most of the cost.

0:40Meet Nadia

0:41

I’m Nadia, an AI engineer at Quill Academy, a made-up online course site.

0:47

Learners ask our assistant questions about seven short articles on AI engineering.

0:54

Each question makes three calls to OpenAI. And each call is billed.

0:59

First, search. The question is turned into a list of numbers, called an embedding. That finds the three best passages.

1:07

Second, a small model, nano, writes the answer from those passages.

1:12

Third, a bigger model, mini, is the judge. It checks if the answer is supported by the passages: good or bad.

1:2101 · A trace

1:23

To see what happens inside one question, I trace it.

1:27

A trace is like an itemized receipt for one request.

1:31

A plain log just says the total: the whole request took one point eight seconds.

1:37

The receipt lists every step, with its time and its cost.

1:42

Each line is a span: one step, from its start to its end.

1:47

Steps nest. Search sits inside the assistant. And the embedding sits inside search.

1:53

Open a span, and you see what it recorded. Here, the answer: the model, the tokens it read and wrote, and what it cost. Tokens are pieces of words. Model calls are priced by the token.

2:06

Add up those costs over all thirty questions, and you get what my traces recorded.

2:1302 · The main call only

2:15

Back to the missing money. My first setup traced only the main model call: the one that writes the answer.

2:21

The search step was traced too. But not the embedding call inside it. And not the judge.

2:28

So two of the three calls were billed, but in no trace. Which one costs more?

2:35

The judge.

2:37

It uses the bigger model. Each of its tokens costs nearly four times as much.

2:43

The judge alone was seventy-eight percent of the bill. That’s the gap.

2:5003 · The judge on the side

2:51

So in my second setup, I traced every call.

2:54

And I moved the judge to the side, into a worker thread. Think of it as a helper, working in another room.

3:02

In a real app, that means the learner doesn’t wait for the grade.

3:06

Every call of every question was recorded.

3:10

But look at the receipts. Each question’s receipt has its search and its answer. Its judge is missing.

3:18

The judge’s calls are here: thirty receipts of their own, belonging to no question.

3:24

So the questions’ receipts still held only twenty-two percent of the bill. The judge’s seventy-eight sat on the loose ones.

3:3404 · Why: the ticket

3:36

Why did the judge lose its question?

3:39

Every step carries a hidden ticket. It says which question the step belongs to.

3:45

Search copies the ticket from the question. So does the answer. So both land on the question’s receipt.

3:52

But the helper in the other room never got the ticket.

3:57

With no ticket, the judge starts a receipt of its own.

4:0205 · The fix

4:04

The fix is one changed line. When the code hands work to the helper, it hands over the ticket too.

4:11

Now the judge lands on its question’s receipt.

4:15

All thirty receipts are complete: search, answer, and judge.

4:2106 · How to notice

4:22

In real traffic, nobody tells me what’s missing. So I run two checks.

4:28

Check one: does every call on the bill appear in some trace?

4:32

Only the main call traced: no. The judge is in no trace. Caught.

4:38

The judge on the side: yes. Every call is in some trace. So check one misses it.

4:45

Check two: does every question’s receipt hold all three calls?

4:50

Only the main call: no. The judge on the side: no. Both caught.

4:56

Check two needs to know a question makes three calls. Check one only needs the bill.

5:03Back to Nadia

5:04

So: two shortcuts hid seventy-eight percent of the bill.

5:08

Now I trace every call, and hand the ticket to every helper.

5:13

And every week, I run both checks.

5:16

Next time: a whole week of traffic, and alerts that watch it for me.

5:22Summary

5:24

A trace is a receipt for one request.

5:27

Make sure every receipt is whole.Nadia: Nothing missing!

Key takeaways

  • ✓A trace is the record of one request as a tree of spans: each step (search, the embedding inside it, the answer, the judge) with its start, end, and what it recorded: the model, tokens read and written, cost. A log line says the request took 1.82 s; the trace says which step took the time and which call cost the money.
  • ✓Each question made three billed calls: an embedding for search, an answer from gpt-5.4-nano, and a judge from gpt-5.4-mini. Thirty questions cost 1.63¢. Tracing only the main model call recorded 30 of the 90 calls and 0.36¢: 22% of the bill.
  • ✓The missing call was the expensive one. The judge was 78% of the bill, because mini costs 3.75× nano per token read and 3.6× per token written; at nano's prices its tokens would have cost less than the answer's. The search embedding was almost free.
  • ✓Tracing every call but running the judge in a worker thread recorded all 90 calls and the whole bill, but the 30 judge spans landed in traces of their own. A new thread starts with an empty context, so the span finds no parent. The questions' own traces still held 22%.
  • ✓The fix is one line: submit the work with contextvars.copy_context().run, so the worker gets the caller's context (the ticket). Then all 90 calls sat in their question's trace, and all 30 traces were complete.
  • ✓Two checks, because one isn't enough. Check 1, calls traced vs. calls billed per model, caught the first gap and passed the second. Check 2, questions whose trace holds all three calls, caught both (0 of 30, 0 of 30), but needs to know how many calls a question makes.

A trace is the record of one request, step by step. This chapter traces a small RAG assistant, three ways, and compares each time what the traces recorded with what the model provider billed. The first two ways are shortcuts, and each one hides most of the cost in a different way. It covers what a trace and a span are, the two ways spans go missing, the fix, and two checks that catch both.

The source is Lab 14 (Observability), script 01_tracing.py, Exercise 2 ("is every step recorded?"), rerun for the video in October 2026. Every call is a real OpenAI API call. The course site, its questions and its articles are test data. The lab's other parts of script 01 (an agent's trace, what tracing costs, and handing spans to OpenTelemetry) aren't in the video; a short section below gives their numbers. The rest of the lab follows: a week of traffic and alerts in chapter 37, feedback and traces turned into tests in chapter 38, and finding causes in traces in chapter 39.

A trace is a receipt

A log line says something happened: request r0003 done in 1.82 s. A trace is more like an itemized receipt. It lists every step inside the request, with its time and its cost.

  • A span is one step, from its start to its end, with whatever it recorded: the model, how many tokens it read and wrote, the cost, the passages search returned.
  • Spans nest. The embedding sits inside search, search inside the assistant, the assistant inside the request.
  • Add a trace's spans up (a rollup) and you get one number per request: its cost, its tokens, its model calls.

Tokens are pieces of words, and model calls are priced by the token. Open the spans of one recorded question:

Try it · One request, two recordsLab 14 · script 01 · request r0003, every call traced

Question · For debugging a RAG system, what retrieval-related steps should be done first?

Span · chat gpt-5.4-nano

The answer, written by the small model from the passages.

start → end
0.21 → 0.98 s
model
gpt-5.4-nano
tokens read
305
tokens written
51
cost
0.0125¢

This request's three calls cost 0.0593¢: the judge 0.0468¢ (79%), the answer 0.0125¢, the embedding 0.00003¢. Prices: mini is 3.75× nano per token read and 3.6× per token written.

The assistant: three calls per question

The assistant answers learners' questions about seven short articles on AI engineering. Each question makes three billed calls:

  1. Search. The question is turned into a list of numbers, an embedding (text-embedding-3-small), which finds the 3 closest passages.
  2. The answer. A small model, gpt-5.4-nano, writes it from those passages.
  3. The judge. A bigger model, gpt-5.4-mini, checks whether the answer is supported by the passages: good or bad.

The test sent 30 questions under each of three tracing setups. The bill comes from the API, not from the traces: each OpenAI reply reports how many tokens the call used, and the lab prices them at list price. For the first setup, 30 questions cost 1.63¢.

Setup Calls billed Calls recorded ...inside their question's trace Questions with all 3 calls Bill in the questions' traces
only the main call traced 90 30 30 0 of 30 22%
every call traced, judge in a worker thread 90 90 60 0 of 30 22%
every call traced, context passed to the worker 90 90 90 30 of 30 100%

Shortcut 1: trace only the main call

The first setup traced the call that writes the answer. The search step had a span too, but the embedding call inside it went through a plain, untraced client, and so did the judge. Two of the three calls were billed and in no trace.

The traces recorded 0.36¢ of the 1.63¢ bill: 22%. The bill was 4.5× what the traces showed.

Which call was missing the money? The judge: 78% of the bill. The embedding was almost free (about 0.0008¢ for all 30). The judge isn't expensive because it does more work: across the 30 questions it read 11,919 tokens and wrote 821, and the answer read 9,309 and wrote 1,414. At nano's prices the judge would have cost less than the answer. It's the price: mini costs 3.75× nano per token read and 3.6× per token written.

That's the usual shape of the gap. Auto-instrumentation that wraps only the main model client misses every other call: embeddings, judges, rerankers, retries, background jobs. Those are often the expensive ones.

Shortcut 2: the judge in a worker thread

So the second setup traced every call, and ran the judge on the side, in a thread pool, so that in a real app the learner wouldn't wait for it.

All 90 calls were recorded, and the traces held 100% of the bill. But each of the 30 judge spans landed in a trace of its own, attached to no question. The questions' own traces still held 22% (between 15% and 28% each); the judge's 78% sat in 30 traces of their own.

Why? The tracer keeps the current span in a context variable, like a hidden ticket that says which request a step belongs to. Each new span reads the ticket to find its parent:

# tracing.py, simplified
_current = contextvars.ContextVar("current_span", default=None)

@contextmanager
def span(self, name, attributes=None):
    parent = _current.get()
    if parent is None:          # no ticket: start a new trace
        s = Span(new_trace_id(), new_span_id(), None, name)
    else:                       # nest under the current span
        s = Span(parent.trace_id, new_span_id(), parent.span_id, name)
    token = _current.set(s)
    try:
        yield s
    finally:
        _current.reset(token)
        self.exporter.export(s)

Within one thread, every step copies the ticket from the step before. A new thread starts with an empty context. The judge's span finds no current span, so it starts a new receipt.

The fix: pass the ticket

When the code hands work to the worker, it hands over the ticket too: run the work in a copy of the caller's context.

# 01_tracing.py, Exercise 2
ctx = contextvars.copy_context()
pool.submit(ctx.run, online_judge, question, docs, answer, tracer, client)

Now all 90 calls landed in their question's trace, and all 30 questions were complete. The lab keeps a test that pins the trap down: a span opened in a worker thread without the copied context starts a new trace. Pair it with one that checks your own workers land in the request's trace, so the mistake can't quietly come back. The same goes for any place work changes threads or tasks: a thread pool, a queue consumer, a callback. Python's asyncio tasks copy the context when they're created, but a plain threading.Thread or a pool worker doesn't.

Pick a setup to see where each question's three calls went, and what the two checks say:

Try it · Where the bill wentLab 14 · script 01 · 30 questions × 3 calls, rerun for the video

The answer's call is traced. The search step has a span, but the embedding call inside it goes through a plain client, and so does the judge.

Each question's three calls, by cost

Search embedding: not traced (billed only)Answer (nano): in its question's traceJudge (mini): not traced (billed only)

The embedding costs so little (about 0.00003¢ a question) that it barely shows.

The bill
1.63¢
Calls recorded
30/90
In question traces
22%
Traces of their own
0

30 of 90 calls sit inside their question's trace. The judge alone is 78% of the bill, and nothing records it.

Check 1 · calls traced vs. calls billed, per model

Search embedding0 of 30
Answer (nano)30 of 30
Judge (mini)0 of 30

Caught: two models are missing.

Check 2 · questions whose trace holds all 3 calls

0 of 30

Caught.

Needs to know how many calls a question makes; check 1 only needs the bill.

The bill is each call's token counts, as reported in OpenAI's replies, at list price. Each setup is its own run of the same 30 questions, so the totals differ slightly (1.63–1.65¢).

How to notice: two checks

In real traffic, nobody tells you what's missing. Two checks find it:

  • Check 1: calls traced vs. calls billed, model by model. In the first setup, the embedding and the judge have 0 of 30 traced: caught. In the second, every call is there, so the check passes and misses the problem.
  • Check 2: questions whose trace holds every call they should. With the judge on the side, 0 of 30 have all three: caught. It catches both shortcuts.

Check 2 needs to know how many calls a question should make, which changes with the design (an agent's number of steps varies). Check 1 needs only the bill. Run both, every week, on real traffic.

Also in the lab

These parts of script 01 aren't in the video. From the lab's README (its own run, October 2026):

  • An agent's trace. Lab 09's refund agent on one task: 6 model calls and 5 tool calls in 5.9 s, $0.00161. 96% of its tokens were input (6,557 of 6,795), because every step resends the conversation so far.
  • What tracing costs a request. Per span, in the request's thread: 5.5 µs in memory, 435 µs writing each span to SQLite as it ends, 3.6 µs batched from a background thread. A request (median 1.25 s, 4 spans) pays about 15 µs batched, 0.001%.
  • What it costs to keep. 6.4 KB a request with the prompts and answers, 1.8 KB without. The text is most of the bytes, and it's user data: record it on purpose, with a retention period and access control.
  • OpenTelemetry. All 197 spans went through the real OpenTelemetry SDK with their ids, parents, times and attributes intact. The lab names its attributes by OpenTelemetry's GenAI conventions (gen_ai.request.model, gen_ai.usage.input_tokens), so any tool that reads OpenTelemetry can take them.
  • A batch needs a flush. In the lab's saved live run, 20 requests of 4 spans each left 78 spans, not 80: the last request lost its two. The batching exporter writes from a background thread, and the script exited without calling shutdown(), so the spans still in its queue were dropped. The video's notes found this in the data; it isn't in the README.

What to do

  • Trace every call, not only the main model client: embeddings, judges, rerankers, tool calls, background work.
  • Pass the context wherever work changes threads or tasks, and keep a test that fails when it isn't passed.
  • Reconcile traces with the bill, model by model (check 1), and check that every request's trace is whole (check 2).
  • Record cost on every model-call span, from the reply's token counts, so a rollup gives cost per request.
  • Flush the exporter on shutdown.

Caveats

  • Thirty test questions. The gap's size, 78%, belongs to this assistant: one cheap answer and one pricier judge. The shape is general: whatever isn't traced is invisible, and the expensive call is often not the main one.
  • The bill is list price × reported tokens. It's read from the API's own replies, independently of the traces, but it isn't an invoice: discounts and cached-token prices aren't in it.
  • Three separate runs. Each setup is its own run of the same 30 questions, so the bills differ slightly (1.63–1.65¢), and so do the per-question shares.
  • The recording waited for the judge. It called .result() right after submitting the judge, so the request didn't actually save time. A real app would let the judge finish later; the context still has to be passed.
  • A from-scratch tracer. The trap is the same with OpenTelemetry's Python SDK, which also keeps the current span in a context variable.

The checklist

  1. List every billed call a request makes, by endpoint and model, and trace each one.
  2. Put the token counts and cost on every model-call span.
  3. Copy the context into every thread pool, worker and callback, and test it.
  4. Each week, compare calls traced with calls billed, model by model.
  5. Each week, count requests whose trace is complete, and look at the ones that aren't.
  6. Decide what text to record, for how long, and who can read it.
  7. Flush the exporter before the process exits.

Practice: a tracer that keeps the ticket

Write the lab's tracer from scratch: spans that nest through a context variable, a worker that does or doesn't get the ticket, a rollup, and the two checks. The tests run the assistant's three setups on the recorded costs of all 30 questions and expect the lab's numbers: 22% and 78%, then 30 traces of their own, then 30 of 30 complete. Real threads don't run in the browser's Python, so the worker is imitated the way a new thread behaves: running in an empty contextvars.Context().

Try it · Practice in PythonRuns in your browser · ⌘/Ctrl + Enter to run
trace_practice.py93 lines

Quiz

Try it · Check your understanding0 of 6 answered
  1. 1.What does a trace show that a log line with the total time doesn't?

  2. 2.Only the main model call was traced. What share of the bill did the traces hold?

  3. 3.Why was the judge the expensive call?

  4. 4.Every call was traced, with the judge in a worker thread. Why were the questions' traces still missing 78%?

  5. 5.What fixed the worker-thread gap?

  6. 6.Comparing calls traced with calls billed, model by model, passes with the judge in a worker thread. Why?

Check your understanding

Try each question before opening its answer.

What's the difference between a log line and a trace?

A log line says something happened: the request took 1.82 s. A trace is the request as a tree of spans, each step with its start, end and what it recorded (model, tokens, cost), so it says which step took the time and which call cost the money.

Your traces say this week cost $1,200; the provider bills $4,800. Where do you look?

At every call that isn't the main model call: embeddings, judges, rerankers, retries, background jobs. Compare call counts by endpoint and model with the bill. In the lab, tracing only the main call missed the judge, which was 78% of the bill.

Why was the judge 78% of the bill when it read and wrote about as many tokens as the answer?

Its model costs more: mini is 3.75× nano per token read and 3.6× per token written. At nano's prices the judge's tokens would have cost less than the answer's.

Every call was recorded, yet the questions' traces held 22%. How?

The judge ran in a worker thread. The current span lives in a context variable, and a new thread starts with an empty context, so each judge span found no parent and started a trace of its own: 30 traces attached to no question.

How do you fix it, and keep it fixed?

Submit the work in a copy of the caller's context: pool.submit(contextvars.copy_context().run, fn, …). Keep a test that fails when a worker starts a new trace, as the lab does.

Why run two checks instead of one?

Comparing calls traced with calls billed catches calls that aren't traced, but passes when they're traced into the wrong trace. Counting complete request traces catches both (0 of 30 in each shortcut), but needs to know how many calls a request should make. The first needs only the bill.

Next up: Chapter 37 runs a whole week of traffic with four problems planted in it, puts it on a dashboard, and scores alert rules on which problems they caught, how fast, and how often they fired for nothing.

Learning something useful?

New labs, articles, and videos on evaluating and testing AI systems come out regularly. Follow along so you don't miss the next one.