learn-ai-observability-with-phoebe / Builder session 4 of 10
Learn AI Observability with Phoebe · Builder track · Session 4 of 10

Cost and latency per span: attribution that survives an agent loop

Session b3 gave your agent a span tree. That tree now carries the same tokens at two different depths, which means the obvious way to add up cost is wrong by exactly a factor of two. This session fixes the arithmetic: one depth only, the four token counters you must log rather than one, and the two derived latency quantities that tell you whether a faster model would help before you spend a month migrating to one.

🟡 Builder track AI engineers · platform engineers · technical PMs 45 min
0-3 · Welcome 3-20 · The double count and the quartet 20-40 · Build-along: attribute one run 40-45 · Q&A
Part 0

Why cost and latency belong on the span, not on a bill

Your provider invoice is a single true number that answers no question you actually have. It cannot tell you which tenant, which feature or which failing retry spent the money, and it cannot tell you whether last Tuesday's slow hour was decode time or a tool backend. Everything useful about cost and latency is a per-span fact, and this session is about getting those facts right - because the two most natural ways to compute them are both wrong, in opposite directions.

Live - presented in session Self-study - read after class Official sources covered
★ What you walk out with today The sum-at-one-depth rule that stops your cost dashboard reporting double, the four token counters you have to log before a cache hit rate exists at all, the reason a 200,000-token request can honestly report input_tokens: 50, two latency definitions that need separate SLOs, and two derived quantities - model_time_fraction and orchestration_overhead - that between them decide whether your next optimisation is a model swap, a prompt edit or a missing span.
Every price on this page carries a date: 2026-08-13 Multipliers, per-search fees and per-million rates were read on 2026-08-13 and are provided so the arithmetic is checkable, not because they will still be true when you teach this. The pricing page this course reads from carries its own footnote recording that a previously announced increase was cancelled, which is the cleanest possible argument for dating every number you quote. Substitute your own rates before you present any dollar figure to anyone who might act on it.
Part 1 · the trap b3 set

The same tokens, counted at two depths 6 min live

The agent conventions ask invoke_agent to carry aggregate gen_ai.usage.* for the whole run, and they ask each child chat span to carry its own. Both are correct and both describe the same tokens. Nothing in the data warns you about this, so the first cost query anybody writes - sum the usage attribute across all spans in the trace - returns exactly twice the truth.

One agent run, one set of tokens, three ways to add them up invoke_agent Insight Bot 6.80s AGENT gen_ai.usage.input_tokens=3760 gen_ai.usage.output_tokens=450 aggregate chat claude-sonnet-4-5 (plan) 1.10s gen_ai.usage.input_tokens=820 gen_ai.usage.output_tokens=140 chat claude-sonnet-4-5 (answer) 1.10s gen_ai.usage.input_tokens=2940 gen_ai.usage.output_tokens=310 Sum every span 7,520 input + 900 output = $0.0361 per run. Exactly twice the truth, because the root aggregate and the two children are one set of tokens at two depths. Sum the leaves 3,760 input + 450 output = $0.0180 per run. Correct, and it still holds when the agent adds a third call, because you never trust the parent's aggregate. Read the root 3,760 input + 450 output = $0.0180 per run. Also correct, and cheaper to query - but only while every model call sits inside one agent span. Pick one depth, write it down, never mix. Two depths is a 2x error in your cost model.
🔍 Click to zoom - the double count, and the two queries that avoid it. Dollar figures use this course's modelled rates of $3.00 per million input and $15.00 per million output, read 2026-08-13

Both correct answers have a failure mode, and choosing between them is a real decision rather than a preference. Leaf-only is robust to a framework that forgets to aggregate, and it is the version that keeps working when a new tool or a nested sub-agent appears - but it depends on you having instrumented every model call, including any that a third-party library makes on your behalf. Root-only is a single cheap read per trace and it is the version your finance queries will want, but it silently under-counts the moment a model call happens outside an instrumented agent span, which is exactly what happens the first week somebody adds a background summariser.

★ Sum at one depth, and say which depth in a comment

# leaf-only: robust to a missing aggregate, needs full model-call coverage leaf_in = sum(s.attr["gen_ai.usage.input_tokens"] for s in spans if s.op == "chat") leaf_out = sum(s.attr["gen_ai.usage.output_tokens"] for s in spans if s.op == "chat") # root-only: one read, but it trusts the agent span to enclose every call root_in = root.attr["gen_ai.usage.input_tokens"] assert leaf_in == root_in, "coverage gap or a double-counted aggregate"

Turn the choice into a test Run both queries and assert they agree. When they disagree you have learned something specific for free: leaf higher than root means the framework's aggregate is stale or partial, root higher than leaf means model calls are happening outside your instrumented spans. Either way it is a coverage bug with a name, discovered by arithmetic rather than by an invoice.
Self-studyTwo more places the additivity assumption quietly breaks3 min read▶
  • Parallel tool calls. Durations do not add when spans overlap. Two tool spans of 2s each running concurrently under a 2.1s parent are not 4 seconds of anything - the parent is right and your SUM(duration) is meaningless. Any latency figure you compute from a trace has to know whether siblings are sequential or concurrent, which means reading start timestamps and not just durations.
  • Retries inside one span. The conventions put the whole logical operation, including automatic retries, in a single span. That is good for readability and it means a span's duration can contain two or three attempts, and its token counts should be the billed total across them. So a slow span is not necessarily a slow model - it may be a retried model. If you want to tell those apart you have to emit a retry-attempt count yourself, because nothing in the data model will do it for you.
  • Operations that must not report tokens. The conventions specifically tell instrumentations not to report token usage on fetch_response operations. Sum blindly over every span with a usage attribute and you will eventually pick up something that was never meant to carry one.
Part 2 · the quartet

Four counters, because one of them lies 7 min live

Prompt caching changes what the word "input" means, and it changes it silently. On a cached request the field named input_tokens counts only the tokens that arrived after the last cache breakpoint. Nothing errors, nothing warns, and the number is genuinely correct for what it measures. It is simply not the number your cost dashboard thinks it is reading.

One request: a 200,000-token cached document plus a 50-token question last cache breakpoint cached prefix - the 200,000-token document read at 0.1x of base input 50 tok the question The quartet, and what each counter is actually counting gen_ai.usage.input_tokens = 50 Only the tokens after the last breakpoint. This is the number a naive dashboard reads. gen_ai.usage.cache_read.input_tokens = 200000 Billed at 0.1x. The document you really sent, and half of any cache hit rate you compute. gen_ai.usage.cache_creation.input_tokens = 0 1.25x to write for 5 minutes, 2x for an hour. The other half of the hit rate. Zero means a hit. gen_ai.usage.output_tokens = 180 Plus gen_ai.usage.reasoning.output_tokens when the model thinks. Reasoning tokens are billed. A dashboard reading input_tokens alone sees 50 tokens on this request. The billed input was 200,230 tokens. It under-reports this one request by roughly 4,000x. Log all four and their sum. Without the two cache counters kept separately you cannot compute a cache hit rate at all, which is why the spec carries both. One counter is a measurement. Four counters are an explanation of your bill.
🔍 Click to zoom - the quartet, and the 4,000x under-report that one missing counter produces. Multipliers read 2026-08-13
Two specs, two definitions, and the instrumentation has to reconcile them The OpenTelemetry conventions describe gen_ai.usage.cache_read.input_tokens and gen_ai.usage.cache_creation.input_tokens as included in gen_ai.usage.input_tokens. The provider API's own input_tokens field excludes them. Those two sentences describe different arithmetic, so a naive field-to-attribute mapping produces a span that is wrong by the size of your cache. Do the addition in the instrumentation and emit the sum, keeping the two cache counters as their own attributes.
★ The whole quartet, four lines, once, in your client wrapper

u = resp.usage span.set_attribute("gen_ai.usage.cache_creation.input_tokens", u.cache_creation_input_tokens) span.set_attribute("gen_ai.usage.cache_read.input_tokens", u.cache_read_input_tokens) span.set_attribute("gen_ai.usage.output_tokens", u.output_tokens) # the spec wants cache tokens INCLUDED here, the API field excludes them: add them back span.set_attribute("gen_ai.usage.input_tokens", u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens)

The caching economics, and why they are a latency problem too

Caching is priced on both sides of the transaction, which is what makes it possible to lose money on it. Writing a cache entry costs more than sending the tokens uncached; reading one costs a tenth. All three multipliers below were read on 2026-08-13.

OperationMultiplier vs base inputWhat it should look like on a span
5-minute cache write1.25xcache_creation.input_tokens non-zero. Break-even arrives after a single read, so one follow-up turn pays for it
1-hour cache write2xcache_creation.input_tokens non-zero and expensive. Needs two reads to break even, so it is a bet on the session continuing
Cache read (hit)0.1xcache_read.input_tokens non-zero, cache_creation zero. This is the span you want to see most of the time

Now the part teams do not expect: the time-to-live runs from the start of the writing request, and streaming time counts against it. A response that takes four minutes to stream has spent four of its five cached minutes before the user has even read it, leaving roughly one minute for the follow-up turn to land. That is a latency problem causing a cost problem, discoverable only if your spans carry both durations and cache counters, which is the entire thesis of this session in one sentence.

Self-studyThree more silent cost regressions, all invisible without the quartet4 min read▶
  • The minimum cacheable prefix is enforced without an error. Depending on the model it sits somewhere between 512 and 4,096 tokens, and a request below it is processed with no caching and no error returned. Your code says it is caching, your bill says it is not, and the only signal is cache_creation.input_tokens and cache_read.input_tokens both sitting at zero on a request you believe is cached. That is a silent multiple-x cost regression whose sole detector is an attribute you chose to log.
  • The invalidation hierarchy runs tools, then system, then messages. Changing tool definitions invalidates the whole cache. "We added one tool" is a plausible sentence in a standup and a doubling of your input bill on the same traffic. The visible signature is a step change in cache hit rate on a deploy, which is why hit rate deserves step-change detection rather than a threshold (session b8).
  • The tokenizer changed under a stable price. Newer models use a newer tokenizer that produces roughly 30% more tokens for identical text. Same posted price, same traffic, ~30% more spend. Your dollars-per-token chart looks perfect; the chart that moves is tokens-per-request, and you only have that chart if tokens are on spans.
  • The contrast worth knowing. One major provider prices cache writes explicitly, so a badly placed breakpoint costs money. Another caches automatically with no write premium, so a prompt whose prefix varies - a timestamp at the top, a username in the system message - silently forfeits the entire discount instead. Both failures look identical on a dashboard that only tracks total spend.
Part 3 · latency

Two definitions, two SLOs, two derived quantities 7 min live

"The bot is slow" is not one measurement. There are two published definitions of model latency and they move for different reasons, so a single p95 on a single number will hide whichever of the two is currently broken.

Baseline latencyTime to first token
What it measuresThe model's own time to process the prompt and produce the response, without reference to tokens per secondThe gap between sending the prompt and the first token of the response arriving
Dominated byDecode. Sensitive to output lengthPrefill. Sensitive to input length
Moves whenYou ask for a longer answer, or switch to a slower-decoding modelYour context grows, a cache miss forces a full prefill, or the provider queues you
Where it livesgen_ai.client.operation.duration as a metric, span duration on the tracegen_ai.response.time_to_first_chunk on the span when streaming, gen_ai.client.operation.time_to_first_chunk as a metric
SLOSeparate. This is the one users feel on completionSeparate. This is the one users feel as responsiveness

Streaming belongs in this table only as a caveat. It improves perceived latency and changes nothing at all about total time, so a streaming rollout will move your TTFT numbers and leave your duration numbers exactly where they were. Treat it as a product decision rather than a performance fix. The published order of latency levers is deliberate and it comes with an explicit warning against optimising prematurely: model choice first, then token length in both directions, then streaming.

The two quantities worth computing on every trace

Neither of these is in any specification. Both are trivial arithmetic over spans you already have, and between them they answer the two questions that get asked in every performance meeting: would a faster model help, and where is the time we cannot see.

The same 6.80 second run, measured twice A · what you shipped - two model spans and a lot of silence invoke_agent Insight Bot 6.80s root 4.50s inside no span you emit chat 1.10 · the gap · chat 1.10 · a further 0.10 at the tail, also invisible model_time_fraction = 2.20s / 6.80s = 32.4% orchestration_overhead = 6.80s - 2.20s = 4.60s (67.6% of the run) Overhead that large is not an orchestration problem. It is a missing span. B · fully instrumented - same run, same code, nothing added but spans invoke_agent Insight Bot 6.80s root retrieval metrics_daily 4.20s chat 1.10 · tool 0.20 · retrieval 4.20 · tool 0.10 · chat 1.10 · tool 0.10 model_time_fraction = 2.20s / 6.80s = 32.4% unchanged orchestration_overhead = 6.80s - 6.80s = 0.00s The fraction needed no new spans to be true. A faster model saves at most 2.20s of 6.80s. Overhead measures your instrumentation. The fraction measures your architecture.
🔍 Click to zoom - the two derived quantities on the same run, before and after coverage

model_time_fraction = sum(chat span durations) / root duration. A low fraction means the problem is tools, network or orchestration, and a faster model will not help. The quietly remarkable thing about this number is that it is computable on day one from auto-instrumentation alone - the chat spans are the ones your SDK gives you free, and the root duration is the one thing you always know. It came out at 32.4% both before and after the coverage work above. Which means the answer to "should we migrate to the fast model" was available on the very first trace, and it was no.

orchestration_overhead = root duration - (sum of sequential children + max of parallel children). This is the coverage thesis of session b1 restated as a metric. Large overhead does not mean your orchestration layer is slow; it means your orchestration layer is undescribed, and the honest reading of a big number here is "there is a span I have not written yet". Note the shape of the formula: sequential children add, concurrent children take a maximum, so you cannot compute it without start timestamps.

★ Both quantities, on any trace you already store

model_time_fraction = sum(s.dur for s in kids if s.op == "chat") / root.dur # sequential siblings add, concurrent siblings take a max covered = merged_interval_length([(s.start, s.start + s.dur) for s in kids]) orchestration_overhead = root.dur - covered

Part 4 · the rule

High duration is two different bugs 4 min live

This is the smallest, most useful rule in the session, and it is the reason tokens belong on spans rather than only on a billing dashboard. A slow span with a lot of output tokens and a slow span with almost no output tokens are unrelated problems with unrelated fixes, and duration alone cannot tell them apart.

One 12.4 second span. The output token count is the whole diagnosis. OUTPUT TOKENS LOW · 90 OUTPUT TOKENS HIGH · 3,100 DURATION HIGH 12.4s A service problem The model barely generated anything, so the time went somewhere else: queueing, a cold start, the network, or the tool backend sitting behind this span. Fix: look at the children and the provider, never at the prompt. A generation-length problem The model was decoding the whole time. This is a prompt and max_tokens conversation, not an infrastructure one, and no provider can fix it. Fix: ask for less output, or accept the cost of the answer you wanted. DURATION LOW 0.9s Fine, and worth one glance Fast and short. Check it is not the empty answer: a 2-3 token reply, finish reason end_turn, HTTP 200, no error anywhere. Fine Fast decode on a long answer. Nothing to do about latency here. Watch cost per successful task instead. Without per-span token counts these two cells are the same alert and the same wrong fix.
🔍 Click to zoom - the diagnostic rule, and why tokens have to live on the span
The argument, in one sentence you can take to a review Tokens on a billing dashboard let you know what you spent. Tokens on a span let you tell a queueing problem from a verbosity problem in the time it takes to open one trace. That is the whole return on the two extra set_attribute calls, and it is why the token attributes are Recommended rather than optional in the conventions.
Self-studyWhat multiplies underneath all of this: agents are expensive by design3 min read▶

One published production account puts agent workloads at roughly 4x the tokens of a chat interaction and multi-agent systems at roughly 15x, with token usage alone explaining about 80% of the variance in performance on one browsing benchmark. State the caveat when you use those numbers: they describe one company's own research feature around 2025, not a law of nature. They are a well-documented order of magnitude from a real production system and nothing more.

Two consequences for your span design. First, if a 15x token multiplier is on the table, per-span attribution stops being a nice-to-have and becomes the only way anyone will approve the architecture. Second, the denominator matters more than the numerator: cost per successful task is mean attempt cost divided by success rate, so a 60% success rate multiplies your effective cost by about 1.67x, and the failed attempts are the expensive ones because failures run long before giving up. Session a4 does that arithmetic properly for a leadership audience; what you owe it from here is the per-span data it needs.

Other line items that show up as unexplained spend and are worth a span attribute each, all read 2026-08-13: web search at $10 per 1,000 searches with results re-counted as input tokens on later turns; a single unbounded web fetch running to roughly 125,000 tokens for a 500 kB PDF; code execution at 1,550 free container-hours per organisation per month then $0.05 per hour, billed when files are attached even if the tool is never called; a 1.1x multiplier on every token category when you pin inference geography; tool-use system prompt overhead of 286 to 804 tokens per request that never appears in your own prompt; and batch processing at 50% off input and output which stacks with caching.

Demo 1 of 2

Attribute one run, end to end ★ 12 min · everyone builds

Take one real agent run in your own system and produce a defensible cost figure for it. The goal is not the number, it is the list of things you had to fix to get the number.

Write both cost queries from Part 1 - leaf-only and root-only - and run them on the same trace. If they disagree, stop and find out why before you do anything else. That disagreement is worth more than the cost figure.

Add the quartet to your client wrapper: gen_ai.usage.input_tokens as the reconciled sum, plus gen_ai.usage.cache_creation.input_tokens, gen_ai.usage.cache_read.input_tokens and gen_ai.usage.output_tokens separately. Then compute a cache hit rate. If you could not compute one before, notice that the blocker was never analysis - it was two missing attributes.

Find one cached request in your traffic and check whether input_tokens alone would have described it honestly. On a large cached prefix the gap between that field and your billed input is the size of your document, which for a 200,000-token prefix and a short question is a factor of thousands.

Now price it, twice: once at your current rates, once at rates with a 30% higher token count for the same text. That second number is what a tokenizer change does to you at an unchanged posted price, and it is the cheapest stress test in this course.

The one habit worth forming today Put the tokens on the span in the same wrapper that starts the span, not in a later enrichment step. Attribution that depends on a second system joining two datasets is attribution that stops working on the day of an incident, which is the only day you need it.
Demo 2 of 2

Decide whether a faster model would help ★ 8 min · everyone computes

Somebody in your organisation is currently proposing a model migration to fix latency. This exercise settles that argument with two divisions and no meetings.

Pick the slowest trace you can find. Sum the durations of every chat span in it and divide by the root duration. That is model_time_fraction. If it is under about a third, a faster model cannot save more than a third of the wall clock even if it were instantaneous, and you now have a number to say so with.

Merge the child intervals - overlapping siblings union, they do not add - and subtract from the root duration. That is orchestration_overhead. Read a large value as a missing span rather than as slow glue, and go find which step it is.

Split your latency dashboard in two: time to first chunk, and total duration. Give them separate SLOs. Then go back through the last month of latency incidents and label each one as prefill or decode. Most teams find their single p95 was averaging two unrelated failure modes into one flat line.

Finally, take the ten slowest spans and plot duration against output tokens. The two clusters from Part 4 will separate visibly, and each cluster goes to a different team. That plot is a better prioritisation artifact than any latency percentile.

What this session hands to b5 You now have per-span tokens, per-span durations and two derived quantities. Session b5 mounts the simulator and puts them to work on a real diagnosis: a run where orchestration_overhead is 4.60 seconds of 6.80, and where the cost per run becomes knowable at $0.0180 only once the token attributes exist. The arithmetic you built today is what makes the seventh rung of that ladder possible.
Homework

Try it yourself - this week ◐ 45-60 min total

Source material

Official sources covered

Every attribute name, multiplier and definition on this page traces to a primary source - the OpenTelemetry GenAI semantic conventions and the provider's own caching and latency documentation - catalogued in this course's source map and read on 2026-08-13. This page covers:

✓Token attribution double-counts by design: aggregate usage on invoke_agent plus per-call usage on each childPart 1 · the trap set up in b3, resolved as sum-at-one-depth
✓The Recommended token attributes, including cache_read.input_tokens and cache_creation.input_tokens as included in input tokensPart 2 · and the reconciliation the instrumentation has to perform
✓Cache multipliers 1.25x / 2x / 0.1x, break-even after one read at 5 minutes or two at 1 hour, TTL from the start of the writing requestPart 2 · read 2026-08-13, re-verify per cohort
✓Baseline latency versus time to first token, and the three latency levers in their published orderPart 3 · separate SLOs, streaming as perceived only
✓One span per logical operation including automatic retries; billed token counts; no usage on fetch_responsePart 1 self-study · why additivity breaks
◐model_time_fraction and orchestration_overheadPart 3 · derivations, not specification. No standard defines them - they are arithmetic over spans you already have
◐Agent token multipliers of roughly 4x and 15x, and token usage explaining ~80% of variancePart 4 self-study · one production system circa 2025, not a universal law
◐Cost per successful task as mean attempt cost divided by success rateNamed here as a derivation · leadership treatment is a4
◐The latency and token metrics with their exact bucket boundariesNot covered by design · b8 owns alerting and SLOs on these
◐Per-platform dollar pricing beyond the multipliersDeliberately absent · the source map could not verify platform pricing, so this page quotes none
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Your root invoke_agent span reports 3,760 input tokens. Its two chat children report 820 and 2,940. Your cost dashboard sums the usage attribute across every span in the trace. What is it reporting?

The conventions ask the agent span for an aggregate and each model call for its own, so both are present and both are right. Summing across depths counts every token twice, and the error is silent because 7,520 is a perfectly plausible number. Sum at one depth only, say which depth in a comment, and assert the two agree so a coverage gap announces itself as arithmetic rather than as an invoice.

2 · A request sends a 200,000-token cached document plus a 50-token question. The span reports gen_ai.usage.input_tokens: 50. What has gone wrong?

The measurement is honest and the interpretation is not. Billed input here is 200,230 tokens, of which 200,000 were read from cache at a tenth of base rate. Keeping the two cache counters separately is also the only way a cache hit rate can exist at all, which is precisely why the conventions carry both of them rather than a single combined figure.

3 · A trace has a root duration of 6.80s and its chat spans total 2.20s. Someone proposes migrating to a faster model to fix latency. What do you say?

This is the cheapest decision in the session: two durations and one division. The pleasant surprise is that model_time_fraction needs no instrumentation work at all, because chat spans are what auto-instrumentation gives you free and the root duration is always known. The number was available on the very first trace, before any coverage work, and it already said no.

Builder session 4 cheat sheet · pin this

Sum at one depthLeaves or root, never both. Two depths is a 2x error, and it is silent. Assert they agree.
The token quartetinput_tokens, output_tokens, cache_creation.input_tokens, cache_read.input_tokens, and their sum.
The breakpoint trapinput_tokens counts only tokens after the last cache breakpoint. 200k cached + a 50-token question reads 50.
Cache multipliers (2026-08-13)Write 1.25x at 5 min, 2x at 1 hour, read 0.1x. Break-even after one read, or two on the hour TTL.
Hit rate needs both countersNo cache_creation and cache_read separately, no hit rate. That is why the spec carries both.
Two latency SLOsTTFT is prefill and input-length sensitive. Total is decode and output-length sensitive. Never one p95.
model_time_fractionsum(chat durations) / root duration. Low means a faster model will not help. Free from day one.
The diagnostic ruleSlow + many output tokens = fix the prompt. Slow + few output tokens = a service problem. Tokens on spans is what lets you tell.