From the open engine’s docs (traza 0.28.0). View source Engine routes reach a Traza Cloud project through the gateway.
Context X-Ray and Cache Autopsy
Traza already stores every request an agent sent to a model. This guide covers reading one of those requests as what it was — the context window the model saw — and asking it the questions people actually have when an agent goes wrong or gets expensive:
- What exactly did the model see? The context split into chunks — system prompt, user turns, the model’s own earlier words, tool calls, tool results, tool definitions — each with an estimated token count.
- Where did each part come from? The step of the run that introduced it:
this tool result came from the
read_filecall at step 3; this paragraph is what the model itself said at step 6. - What is dead weight? Tool results that have sat in the context for many steps without the model ever drawing on them, and content that is in the context twice.
- What was lost? What the conversation’s previous call had that this one does not — the instruction a compaction summarized away.
- Why did the prompt cache break, and what did it cost? The Cache Autopsy compares every call with the one before it in the same conversation, names the first chunk that changed and the bytes that changed in it — usually one volatile token, a timestamp or a request id — and prices the tokens the cache could have served and did not.
All of it is deterministic arithmetic over stored spans, in the one
traza-server process. Traza never calls or embeds a model to do it, nothing
is learned, and nothing leaves the process. Route shapes are in the
HTTP API reference.
See it in one call
The examples use a weather agent instrumented for Anthropic: a call that asks
for get_weather, the tool, and a call that answers. Its system prompt embeds
the current time — a common pattern, and a cache killer.
curl -s localhost:8080/v1/xray/4bf92f3577b34da6/m2
An excerpt of the real response, shares rounded (the full body is in the reference):
{
"run": "weather-9",
"step_index": 2,
"model": "claude-sonnet-x",
"chunks": [
{"index": 0, "role": "system", "kind": "system", "est_tokens": 14,
"introduced_by": {"step_index": 2, "kind": "llm", "name": "claude-sonnet-x", "via": "content"},
"age_steps": 0, "stale": false,
"preview": "You are a weather assistant. Now: 2026-10-14T09:00:02Z."},
{"index": 1, "role": "user", "kind": "user", "est_tokens": 9,
"introduced_by": {"step_index": 0, "kind": "llm", "name": "claude-sonnet-x", "via": "content"},
"age_steps": 2, "preview": "Weather in Paris? Answer in Celsius."},
{"index": 2, "role": "assistant", "kind": "tool_call", "name": "get_weather", "tool_call_id": "tu_1",
"introduced_by": {"step_index": 0, "kind": "llm", "via": "content"},
"preview": "get_weather({\"city\":\"Paris\"})"},
{"index": 3, "role": "user", "kind": "tool_result", "tool_call_id": "tu_1",
"introduced_by": {"step_index": 1, "kind": "tool", "name": "get_weather", "via": "content"},
"age_steps": 1, "preview": "{\"temp_c\": 18, \"sky\": \"rain\"}"}
],
"totals": {"chunks": 4, "est_tokens": 39, "reported_input_tokens": 96,
"reported_context_tokens": 96, "cache_read": 0},
"composition": {"by_source": [
{"key": "system", "est_tokens": 14, "share": 0.359},
{"key": "user", "est_tokens": 9, "share": 0.231},
{"key": "tool calls", "est_tokens": 8, "share": 0.205},
{"key": "tool:get_weather", "est_tokens": 8, "share": 0.205}]},
"previous": {"step_index": 0, "shared_chunks": 2},
"dropped_since_previous": [],
"edited_since_previous": [
{"index": 0, "previous_index": 0, "role": "system", "volatile": "timestamp",
"diff": {"offset": 34, "before": "2026-10-14T09:00:01Z", "after": "2026-10-14T09:00:02Z"}}],
"cache": {
"cause": "volatile_content", "previous_step_index": 0, "prefix_chunks_shared": 0,
"first_divergent_chunk": {"index": 0, "role": "system", "change": "edited", "volatile": "timestamp",
"diff": {"offset": 34, "before": "2026-10-14T09:00:01Z", "after": "2026-10-14T09:00:02Z"}},
"est_tokens_lost": 10, "calibration": null, "est_cost_lost_usd": 0.000027,
"explanation": "chunk 0 (system) changed in place since step 0: \"2026-10-14T09:00:01Z\" → \"2026-10-14T09:00:02Z\" (a timestamp); ~10 tokens of content step 0 already had were re-processed"}
}
Read top to bottom: the user’s question and the model’s tool call came in with
the first call; the tool result came from the tool step; the system prompt is
new in this call — because one second of clock in it changed, which is also
why the provider’s cache served nothing. (calibration is null here: the
agent uses a tool whose definition the span did not record, so the provider’s
96 tokens cover text the estimate cannot see, and the raw estimate stands.)
See it run
examples/coding-agents/run.sh
runs both routes on imported Claude Code transcripts. The
autopsy of one session names an expired TTL, priced, and a /compact
(new_context, rewritten); the X-Ray of the call before the compaction
lists its chunks with the step each came from, and the X-Ray of the call
after it lists, under dropped_since_previous, the instruction the
compaction took.
The context window as chunks
The request a step recorded (its input,
with gen_ai.system_instructions folded in as the first message) is split into
one chunk per content part. The message shapes of the conventions Traza
reads all fold to the same unit:
| Shape | Chunks |
|---|---|
{"role", "content": "text"} (OpenAI, native, indexed OpenLLMetry and OpenInference layouts) |
one chunk, kind from the role |
"tool_calls": [...] on a message (OpenAI, indexed layouts) |
one tool_call chunk per call |
Anthropic content parts: text, tool_use, tool_result, thinking, image/document |
one chunk per part |
OTel GenAI parts: text, tool_call, tool_call_response, blob/uri/file |
one chunk per part |
Gemini parts: text, functionCall, functionResponse |
one chunk per part |
OpenAI Responses items: message (input_text/output_text parts), function_call, function_call_output |
one chunk per part or item |
A provider request body ({"messages": [...], "system": ..., "tools": [...]}, OpenInference’s input.value) |
its system, then its messages; its tools as definitions |
A bare prompt (text, llm.prompt) |
one other chunk with no role |
Kinds. system (roles system, developer), user (user, human),
assistant (the model’s text, reasoning included), tool_call, tool_result
(role tool, Anthropic tool_result parts, OTel tool_call_response parts),
and other (tool definitions, media, unrecognized parts).
Tool definitions come first, under the pseudo-role tools, one chunk per
tool — from gen_ai.tool.definitions, OpenLLMetry’s
llm.request.functions.{i}.*, OpenInference’s llm.tools.{i}.tool.json_schema,
or a request body’s tools. They are part of what the model is billed for and
part of the prompt a provider caches, which is why they lead: Anthropic orders
its cache prefix tools, system, messages.
A tool call’s text is name(arguments): the arguments exactly as recorded
when they travelled as text (OpenAI’s arguments string), canonical JSON when
they were recorded as structured JSON. A tool definition’s text is likewise
its recorded text when it has one — a gen_ai.tool.definitions list recorded
as one string is split into each tool’s own bytes — else canonical JSON. A tool
result’s text is its content: a string, a list of text parts joined by
newlines, or a content envelope’s text. A media part is shown as a placeholder
([image: image/png, 182044 bytes recorded]) and estimates to zero tokens:
its encoding’s length says nothing about what a provider bills for it.
Token estimates are about four ASCII characters, or one and a half other
characters, per token — an estimate, not a tokenizer, and labelled est_ on
every field. The provider’s own figures sit beside them in totals:
reported_input_tokens as reported, and reported_context_tokens, the
context resolved across providers’ cache conventions (the table in
prompt caching).
Two identities per chunk. digest (16 hex characters) identifies the
chunk’s content: a JSON object or array held as text is canonicalized first,
and role and position are not part of it — so the same tool output matches
whether the tool span stored it parsed and the next prompt carried it as text,
or the other way round. The Cache Autopsy compares a second, internal identity
instead — role, kind, tool-call id and the chunk’s text as shown above —
because a provider’s cache matches bytes: the same content re-serialized with
its keys in another order is new to the cache even though it is not new
content. Text-recorded values (messages, tool-call arguments, tool
definitions) keep their recorded bytes here; a value the instrumentation
recorded as structured JSON no longer has a byte order to keep, so for it the
identity is its canonical form.
Offloaded prompts are resolved. A request offloaded to the payload store
(anything over --payload-threshold-bytes, 256 KiB by default — long agent
contexts usually are) is read back before it is chunked, under the caller’s
tenant, and so are the run’s earlier requests the analysis compares against —
the 32 calls a predecessor can be first, newest first, then older history
(read only for provenance and staleness) with what the budget has left.
resolve=false skips that: an offloaded request is then one opaque chunk,
context_unavailable says why, and the cache verdict is unknown.
The same holds for every part of a context, read separately: the request,
the tool definitions, and — for a call recorded as a
delta — the earlier answer it continues
from. Whichever part stays a reference (the budget ran out, its bytes are
gone, or resolve=false) is chunked as its placeholder, which is not what
the model saw, so the context is reported as not read whole
(context_unavailable): it is not compared with its predecessor’s (unknown),
not used to calibrate, and a chunk the previous call had that it lacks is
unverified rather than lost. What was not read is never evidence.
Provenance: where each chunk came from
introduced_by is the earliest step of the run whose request or response
contained a chunk with the same content — step_index, kind, its tool,
model or agent name, and its span. So:
- a tool result is introduced by the tool step that returned it;
- the model’s own earlier text and tool calls, by the model call that produced them (its response);
- a user turn or a system prompt, by the first call that carried it;
- a sub-agent’s task or answer, by the agent step that recorded it.
via is content for all of these. When an agent shows the model a tool
result different from what the tool returned — truncated, summarized,
annotated — no tool step holds the same content, and the chunk is attributed
to the tool step whose gen_ai.tool.call.id matches the result’s call id,
with via: "tool_call_id": the latest such step before the call that first
carried the result, since a call id can repeat within a run (a retried call,
a framework that numbers calls per turn). A tool step that holds the same
content always wins over an id match. That is worth noticing on its own: the
model did not see what the tool said.
age_steps is the steps between the introducing step and this call.
composition.by_source totals the context by where it came from —
system, user, assistant, tool calls, tool definitions, and
tool:<name> for each tool’s results — so “61% of this context is read_file
output” is one field away.
Stale tool results
A chunk is stale when it is a tool result older than
--xray-stale-steps steps (default 20;
stale_steps= per request) that no model output since has drawn on.
“Drawn on” is a heuristic, and is labelled one: some distinctive word of the
result — a run of four to sixty-four letters, digits or _ . / -, like
parse_config or src/main.rs — first appears in a model output (an
answer, or a tool call’s arguments) after the result arrived. A word the model
had already said before the result arrived does not count; that is what keeps
common words from making everything look referenced. So a stale result is one
the model never echoed anything new from.
That is evidence, not proof, in both directions: a model can use a result
without quoting it, and can quote a word that happened to arrive from
elsewhere. But a long-lived result the model never echoed is the first thing to
cut when a context is too big, and totals.stale_est_tokens says how much
cutting it would save. When no model output after the result was recorded at
all, nothing is called stale — there is nothing to judge by. Nor is anything
when some model output of the history (an answer, or a tool’s arguments) was
offloaded and could not be read: the words it said are unknown, and a result
it quoted would look unquoted. provenance_partial says so, as it does when
any value of the history stayed unresolved, since content first seen there
would read as introduced later.
Duplicates
duplicates lists content present more than once — the same file read twice,
the same document retrieved into two turns — with the positions and the
estimated tokens of one copy; each repeat’s chunk carries duplicate_of, and
totals.duplicate_est_tokens counts every copy after the first.
What the previous call had: compaction loss
The X-Ray finds the conversation’s previous call — the same predecessor the Cache Autopsy uses (below) — and compares the two contexts:
dropped_since_previous: chunks it had that this call does not. A droppedsystemoruserchunk is flagged"kind": "instruction": a constraint the agent was given and may no longer see. “Never edit files under migrations/” summarized away by a compaction shows up here, with the step that first introduced it.edited_since_previous: chunks that came back changed — the same role and kind, differing in a stretch at most a tenth of the text — with the first differing bytes and what kind of token they were. A re-stamped system prompt is an edit, not a lost instruction, however long it is.
Before a chunk is called lost, every chunk of this call that could be it
edited (the same role and kind, and a length within a tenth of it) is
compared with it. When that comparison could not be made — the old text was
not kept for lack of room (texts_partial), or the comparison bound was
reached — the dropped chunk is marked "unverified": true: it may have been
changed rather than lost. So is every dropped chunk when this call’s context
was not read whole — cut at 2,000 chunks (chunks_truncated), or partly
unreadable (context_unavailable): a chunk not found in what was read may
sit in what was not. (Prepend one instruction to a 2,000-chunk history and
its last chunk falls past the cut; it is listed, unverified, not lost.) A
chunk found edited is an edit either way. When the previous call’s context
was cut (cache.chunks_truncated), only its first 2,000 chunks are checked.
xray_step counts only verified losses as lost instructions.
Conversations recorded as deltas
Some recorders write each model call’s new messages only, not its whole context again — a transcript of a long session would otherwise grow with the square of its length. The recorders do this for Claude Code and Codex, and mark it on every model call:
gen_ai.conversation.idnames the conversation the call belongs to: the session for a main transcript,<session>/<agent id>for a Claude Code sub-agent, the thread for Codex.traza.input.delta: truesays the call’sgen_ai.input.messagesholds only the messages sent since the conversation’s previous call. The first call after a compaction is markedfalse: it starts over, and its input is all it saw.
The X-Ray and the Cache Autopsy read such a call as the whole context it was: the conversation’s previous context, then what that call answered, then the call’s own messages — rebuilt by reading the run’s model calls in step order. An X-Ray of the fortieth call of a Claude Code session shows the task, every answer and every tool result of the forty, with each chunk’s provenance and age, not the one tool result the transcript recorded for it. A call whose delta is empty (a retry) is the conversation so far plus the last answer. Any other producer can use the same two attributes.
A conversation is identified by its whole id, compared through a SHA-256 digest of it — never a prefix, so two long ids that begin alike are two conversations, and neither continues the other. A delta call that names no conversation that can be read (none, or an id so long it was offloaded) has nothing to be rebuilt from, and says so (could not be rebuilt) rather than passing its delta for its whole context. Gaps carry forward too: when an earlier call of the conversation, or the answer it gave, could not be read (offloaded and unresolved), every later delta call of it is reported with its context unavailable — it carries that gap — rather than as a whole context with a placeholder inside.
A call that names a conversation pairs with the nearest earlier call of
that conversation, whatever the two share, and never with a call of
another conversation (or of none). After a compaction that is the call it
compacted — so dropped_since_previous lists everything the compaction took,
the instructions flagged — and the autopsy calls it new_context,
rewritten. A sub-agent’s first call is the first of its own conversation.
Bounds and what they cost: one request keeps at most 64 conversations’
running contexts and 64 MiB of their chunk text; past either the least
recently continued conversation is dropped, and a later delta call of it is
reported with its context unavailable (could not be rebuilt) rather than
as a context it was not. A rebuilt context is held to the 2,000-chunk bound
like any other (chunks_truncated). The X-Ray reads the run’s earlier calls
twice when it rebuilds — once in order to rebuild the call and the comparison
window, once as recorded for provenance — inside the same query deadline and
payload budgets. A call past a run’s first 20,000 spans is read with the
20,000 spans that end at it (below): a conversation already under way before
them is rebuilt from its first compaction inside them on, and a delta call of
it before that compaction says it could not be rebuilt, its conversation
having begun before the spans read. Measured on one real Claude Code session of 1,482
model calls (26 MB of transcript, release build, a laptop): the X-Ray of its
last call rebuilt 1,002 chunks in 0.5 s, and the autopsy of all 1,482 calls
took 1.4 s, finding its two compactions as rewritten.
What a transcript does not record, the rebuilt context cannot show: Claude
Code and Codex transcripts carry neither the system prompt nor the tool
definitions, so a rebuilt context is the conversation only. A stable
verdict therefore says the recorded prefix held; when the provider reports
no cache read for such a call, the autopsy counts it among disagreements —
the unrecorded part changed, or the cache entry was evicted.
Cache Autopsy
curl -s localhost:8080/v1/cache/weather-9
returns one verdict per model call of the run, in step order, and a summary (full body in the reference):
{"summary": {"steps": 2, "breaks": 1,
"by_cause": {"new_context": 1, "partial_hit": 0, "reordered": 0,
"stable": 0, "ttl_expired": 0, "unknown": 0,
"volatile_content": 1},
"est_tokens_lost": 10, "est_cost_lost_usd": 0.000027, "disagreements": 0,
"culprits": [{"cause": "volatile_content", "change": "edited", "role": "system",
"volatile": "timestamp", "breaks": 1, "est_tokens_lost": 10,
"est_cost_lost_usd": 0.000027, "example_step_index": 2,
"example": {"offset": 34, "before": "2026-10-14T09:00:01Z",
"after": "2026-10-14T09:00:02Z"},
"advice": "A clock value sits inside the cached prefix. Move the time to the end of the prompt (after the last cache breakpoint), or round it to a coarser unit such as the day."}],
"culprits_truncated": false}}
culprits groups the breaks by shape, costliest first — by estimated cost
when every shape could be priced, else by estimated tokens lost, since
neither a mix of priced and unpriced shapes nor tokens across differently
priced models ranks by money — the kind of headline that reads a timestamp in
the system prompt broke the cache on 47 of 48 calls. Each carries advice:
fixed guidance chosen by the shape (a lookup table in the code, not generated
text), such as moving a clock value after the cached prefix, appending
retrieved documents after the history rather than ahead of it, or serializing
tool output with sorted keys. At most ten are listed; culprits_truncated
says when there were more.
The predecessor
A provider’s prompt cache serves the longest previously-seen prefix of a prompt, byte for byte, while the entry lives. So each call is compared with the call before it in the same conversation — its predecessor — chosen among the previous 32 model calls of the same tenant:
- A candidate qualifies when its leading chunks match this call’s past the tool definitions — the same system prompt, or the same first turn — or, failing that, when the two share at least half of either context’s chunks (which is what still pairs a context whose tool definitions were re-ordered). Leading chunks also match when they are near-identical, so a system prompt with a clock in it does not hide the conversation it belongs to. A compaction keeps the system prompt and replaces the history, so it finds the call it compacted whatever share of the context it replaced; sharing only the tool catalog is not enough.
- Among qualifying candidates, the one whose matching lead is longest wins, then the one sharing the most chunks, then the nearest. A sub-agent that opens with its parent’s system prompt therefore does not capture the parent’s next call, which continues a longer prefix.
- When both calls name an agent —
gen_ai.agent.nameon the call, else the name of the nearest enclosing agent step — calls of different agents never pair. - When a call names its conversation (
gen_ai.conversation.id), none of the above applies: its predecessor is the nearest earlier call of the same conversation, and nothing else (conversations recorded as deltas).
A call with no predecessor is new_context. The explanation says which case
it is: the first model call of the run, no earlier call among the
previous 32 shares this call’s leading chunks or half of its chunks — a new
conversation, or one whose previous call lies further back — or, for a call
that names its conversation, no earlier call among the previous 32 belongs
to this call’s conversation. If a nearer
earlier call of the tenant could not be read (not recorded, or offloaded and
unresolvable) before any qualifying one was found, the verdict is unknown
rather than a pairing across the gap.
Causes
The two contexts are compared chunk by chunk from the start, on exact
identity; prefix_chunks_shared is how far they agree.
cause |
When | first_divergent_chunk.change |
|---|---|---|
stable |
The predecessor’s whole context is an unchanged prefix of this one, within the TTL | — |
| A new turn: everything up to the predecessor’s last chunk is unchanged, this call puts new content (or an edit at its very end) in that chunk’s place, and nothing the predecessor had follows — a stateless pipeline’s next input, a replaced last message. The cache served all it could | replaced or edited |
|
ttl_expired |
The prefix is intact (or a new turn), but the TTL had passed since the predecessor began, and the provider reports no cache read — a read makes the call stable or partial_hit (below) |
— or replaced / edited |
partial_hit |
The prefix is intact and the TTL had passed, yet the provider read part of the predecessor’s reported context from its cache: part of the prompt outlived the cache lifetime and part did not. Lost: what it did not serve (below) | — or replaced / edited |
volatile_content |
A chunk inside the prefix changed in place — the same role and kind, at most a tenth of its text differing, verified on both texts — with content the predecessor had after the change | edited |
| A chunk inside the prefix was swapped for different content while what followed it survives (or its text was not kept, so an edit could not be confirmed) | replaced |
|
| A new chunk sits ahead of content the predecessor already had | inserted |
|
reordered |
The same content in different bytes (key order, spacing), or under another role | reserialized |
| Both differing chunks exist in both contexts, in other places | moved |
|
new_context |
No predecessor | — |
| A chunk was removed from inside the prefix (pruning) | removed |
|
| Less than half of what followed the divergence survives (compaction, truncation) | rewritten |
|
unknown |
This context is a strict prefix of the predecessor’s: whether a shorter prompt hits depends on where the provider’s cache breakpoints sat, which no trace records | shrunk |
This call’s or the predecessor’s context cannot be read whole (context_unavailable) |
— | |
| A context was cut at 2,000 chunks and what lies past the cut could change the verdict (below) | — or as compared |
A context cut at 2,000 chunks (chunks_truncated on the verdict, and on
the autopsy) is compared as far as it was split, and what lies past the cut
is never taken to match. A chunk found is found, but one not found may sit
past the cut, so a verdict is asserted only when nothing there could change
it: a predecessor read whole that this call starts with is still stable,
and an insertion ahead of history or a move — decided by what was found — is
still named. A verdict that needs the rest — that the whole prefix held, that
nothing the predecessor had follows (a new turn), that most of it is gone (a
rewrite), that a chunk was removed — is unknown, with nothing counted lost
and the explanation saying where the compared chunks diverged and which
context was cut; first_divergent_chunk, when present, describes the chunks
compared. A verdict that stands counts lost tokens among the compared chunks
only.
The TTL defaults to 300 seconds — Anthropic’s default cache lifetime and the
low end of OpenAI’s — and ttl_seconds= sets it per request (up to a day) for
deployments on longer-lived caches. gap_ns is the time since the predecessor
began, which is when its prompt entered the cache. A structural divergence
outranks an expired TTL as the cause; the explanation mentions both.
The TTL is an assumption; the provider’s report is evidence. When the prefix was carried intact past the TTL, the call’s cache read is set against what its predecessor reported as its whole context — both counts in the provider’s own tokens:
- All of it read: the cache plainly outlived the assumption. The verdict
is
stable, nothing is counted lost, and the explanation says the cache outlived the assumed TTL. Real producers run longer caches than five minutes: on one real Claude Code session, 15 of the 25 calls a five-minute TTL called expired had read their whole prefix from the cache. - Part of it read: part of the prompt outlived the cache lifetime and
part did not — a 100,000-token context whose 50,000-token system prefix
stayed warm while the conversation after it expired, say. The verdict is
partial_hit, andest_tokens_lostis the predecessor’s reported context less the read (here 50,000), priced like any other loss. Neitherstable(nothing lost) norttl_expired(everything lost) would be true. The unserved part may also include text past the provider’s last cache breakpoint, which a trace cannot tell from expired text; the explanation says so. - Nothing read:
ttl_expiredstands.
No structural verdict ever yields to a reported count.
The volatile token
first_divergent_chunk.diff names the first differing stretch of the two
chunks’ text: offset (a byte offset into the chunk’s text), before and
after, widened to the whole word the difference falls in and clipped to 80
characters a side. For an in-place edit, volatile classifies what was
swapped: timestamp (a date, a time of day, or a 10/13/16/19-digit epoch),
uuid, hex_id (eight or more hex digits mixing digits and letters — request
ids, hashes), number, or text.
Lost tokens and money
est_tokens_lost is the estimated tokens of content the predecessor already
had that this call carried after the break — what the cache could have
served and the provider processed afresh: the chunks after the divergent one
that the predecessor had, and, for an edited chunk, its own text after the
change (a clock at the top of a long system prompt costs the whole prompt
below it) — plus, when the TTL had expired, the shared prefix too. A new turn
loses nothing, which is why it is not a break.
calibration is the call’s provider-reported context count over the
estimate for the whole context, clamped to 0.25–4×, and est_tokens_lost is
scaled by it so the figure leans toward the provider’s tokenizer rather than
the four-characters rule. It is applied only when the estimate covers what the
provider counted: not when the context holds media (estimated at zero), uses
tools without recording their definitions, was cut at the chunk cap, or was
not read whole (an offloaded part — definitions, or an earlier answer of a
delta-recorded conversation — left as a placeholder) — the ratio would then
mostly measure what was not recorded — and calibration is null and the
raw estimate stands. Even when applied, the ratio absorbs
anything the provider adds that no span records (injected system text,
formatting tokens). The explanation quotes the figure reported.
est_cost_lost_usd is those tokens at the model’s input rate less its cache
read rate, from --pricing — present only
when the table names both for the model. A model the table cannot price
reports tokens and no money, and summary.unpriced_breaks counts such breaks,
so a total is never silently an undercount.
When the provider disagrees
cache_read and reported_hit are the provider’s own report, shown beside
every verdict. summary.disagreements counts calls where the two contradict
each other: a stable prefix with no cache read reported (the provider did not
cache — no cache breakpoint set, or a prefix below its minimum cacheable
length), or a read reported where nothing could have been served. The
autopsy does not explain these away; it counts them, and
summary.disagreement_advice lists what to check. The one report it acts on
is a read of most of an intact prefix past the assumed TTL (above): that call
is stable, and agrees with the provider.
Pricing cached tokens
The same pricing keys fix cached-token arithmetic everywhere Traza derives a
cost. With cache_read_per_mtok and cache_write_per_mtok in a model’s entry,
a call’s priced cost counts cache reads and writes at their own rates under its
provider’s convention — on top of the prompt for Anthropic, Bedrock and Claude
on Vertex, carved out of it for OpenAI, Azure OpenAI and Gemini on Vertex
(Vertex is decided by the model’s family; any other Vertex model keeps the
plain arithmetic). Without them, nothing changes.
See Model pricing.
From an agent: xray_step
With --mcp, the xray_step tool returns a compact X-Ray: a
headline of counts in Traza’s own words, then — inside the untrusted-telemetry
block — the model and run, the composition, the cache verdict with its diff,
what was dropped or edited since the previous call, repeats, and one line per
chunk. It takes trace_id and span_id (as get_run_tape lists them), or
run_id and step (a tape index) — either way the run is read once, under
one query deadline; with a credential that sees every tenant, a run present in
more than one is refused rather than guessed at. Everything the telemetry
wrote — previews, roles, model names, the explanation, which quotes recorded
text — renders inside the block; the headline counts only verified lost
instructions and says how many losses are unverified. The result is clamped to
--mcp-max-result-bytes with the loss stated, the chunk list cut from its end
first, and the note says whether the summary rows survived.
Tenancy and erasure
Both routes take the usual tenant= selector and binding: a bound credential
reads only its own tenant, and naming another is 403 {"required": "unbound"}.
The X-Ray is addressed by span, and span ids can repeat across tenants: in the
operator view (an unbound caller naming no tenant) a span id found in more than
one tenant is refused with 400 asking for tenant=, and a span found in
exactly one is read entirely under that tenant — its run, its history and its
payloads. Runs, spans and payloads are read under the store’s erasure mask, so
an erased trace or session is simply absent, and nothing here keeps a copy of
anything.
Limits, stated plainly
- Estimates and heuristics are labelled. Per-chunk tokens are estimates; staleness is the heuristic above; “near-identical” is a 90%-shared-text rule; the predecessor search is a heuristic about which call a conversation continues. Conversations that open with the same system prompt or share most of their content can pair across each other unless their calls name different agents: the first call of a sub-agent that reuses its parent’s system prompt pairs with the parent’s latest call, and lists the parent’s turns as dropped. A compaction that rewrites the system prompt too leaves nothing to link it to the call it compacted.
- The provider’s cache rules are not modelled — minimum cacheable length,
breakpoint placement, TTL refreshes on a hit, per-organization cache
isolation. The verdicts describe the recorded prompt;
cache_readanddisagreementsare where the provider’s behaviour shows. - Byte-exactness is the recording’s. Instrumentation records messages, not
the HTTP body the SDK sent; a difference the instrumentation normalized away
is invisible, and one it introduced is reported. Values recorded as
structured JSON (Anthropic’s
tool_useinput, indexed tool-definition attributes, a provider body’stools) are compared in canonical key order, so a key-order change in them is invisible; text-recorded arguments and definitions keep their bytes, offloaded or not. - Bounds. One request reads 20,000 of its run’s spans: the first 20,000,
or — for a call past them, such as the latest call of a long session — the
20,000 that end at it. Its
step_index(and every index it names) still counts the whole run’s steps, andprovenance_partialistrue: a chunk first seen before those spans is not traced to its step. (A call that is not among its run’s spans at all is X-rayed alone:step_index: nullhere and incache, cacheunknown.) A context is split into at most 2,000 chunks (chunks_truncatedon the X-Ray, on a verdict when either context compared was cut, and on the autopsy; past the cut nothing is taken to match or to be missing — see Causes and compaction loss). Up to 64 MiB of offloaded payload is resolved for the call under an X-Ray and as much again for its history — the 32 calls a predecessor can be first, newest first — and an autopsy resolves up to 64 MiB per call and 512 MiB across all its calls (resolved_partial; a context a value left unresolved is not read whole,context_unavailable). These are bounds on reading work, not on response size, which is why they are not--tape-max-bytes(the cap on content a tape returns); the query deadline bounds them as well. At most 200,000 content digests are kept for provenance and 500,000 words for staleness (provenance_partial; staleness is then not judged at all), one request remembers 64 running conversations and 4,096 dropped ones, each by the SHA-256 of its id, and 32 MiB of distinct chunk text for previews and diffs — the examined call’s and the comparison window’s, released as calls leave the window (texts_partial). Pairing lost chunks with edited ones compares at most 64 MiB of text and 4,096 pairs (unverifiedpast it). At most 256 dropped and 256 edited chunks, 64 duplicate groups and 10 culprits are listed (dropped_truncated,edited_truncated,duplicates_truncated,culprits_truncated), and an autopsy covers at most 2,000 model calls (truncated). The whole request runs inside one--query-deadline-msbudget and is refused with400, never answered partially, when it runs out. - Digests are identities, not commitments. They are Traza’s 128-bit hash, reported as 16 hex characters. A producer able to write its own telemetry can manufacture a collision; it would get a wrong answer about its own data in its own tenant, and nothing else. The autopsy confirms a digest match on the text whenever both texts are held.
- Media is not estimated, and an unrecognized message shape is an
otherchunk holding its canonical JSON rather than a guess at its parts.