Tracing
Record what happened to a single message: every flow, block, model turn and source, joined end to end by one id.
Tracing records the path of one message: every flow it entered, every block it passed through, every model call made on its behalf, and the source that admitted it, all joined by a single id that survives every boundary the message crosses.
Tracing is a development feature. It is off by default, and it is not recommended under load: it will significantly reduce throughput. Every traced block is recorded on the flow's own goroutine, and capturing payloads marshals the message once per block. Turn it on to understand a run, not to leave running. The runtime logs this warning at startup whenever tracing is enabled.
Why it exists
Neither of the runtime's other two ways of watching itself answers "what happened
to this message". Metrics are aggregates whose labels
all come from the config and never from message data, so they can tell you a flow
is failing but never which message failed. Spies record
everything about one block, but only under octo invoke. A trace is the missing
middle: per-message, on live traffic, and joined end to end.
Turning it on
octo run --config app.yaml --tracesThat writes octo-trace-<time>-<pid>.jsonl into the working directory. Pick a
path with --traces-file, and read it with anything that speaks JSON Lines:
octo run --config app.yaml --traces --traces-file /tmp/run.jsonl
jq -c '{seq,kind,traceId,flow,path,durationNs}' /tmp/run.jsonlA run that did not ask for traces creates no file and logs nothing.
Tracing one flow, and a whole test suite
The same flags work on octo invoke, which runs a single flow and exits:
octo invoke --config app.yaml --flow orders --data '{"orderId":"A-1"}' \
--traces --traces-file /tmp/orders.jsonlAnd dolphin will run a whole suite that way, one file per case:
dolphin test orders_test.yaml --traces-dir ./tracesThat is the cheapest way to find out what a flow's messages actually look like:
the cases already say how to exercise it, and the mocks they carry mean nothing
real is called. Each case's trace lands at <suite>.<n>.trace.jsonl beside the
others.
Bodies and variables are captured by default and can carry credentials and personal
data. OCTO_TRACING_BODIES=false OCTO_TRACING_VARS=false records the sequence only.
What a trace looks like
One HTTP request through a flow that delegates to two others, one fire-and-forget and one synchronous, is one trace:
seq kind flow path
1 flow.started orders-api
2 block.pre-invoke orders-api orders-api.dump-inbound
3 block.post-invoke orders-api orders-api.dump-inbound
6 block.pre-invoke orders-api orders-api.audit-async
9 flow.started audit ← one-way flow-ref
11 flow.started enrich-order ← sync flow-ref
12 block.pre-invoke enrich-order enrich-order.normalize-order
15 block.pre-invoke enrich-order enrich-order.priority-check[then].set-variable
21 flow.completed enrich-order
23 flow.completed audit
28 flow.completed orders-apiEvery one of those records carries the same traceId. The audit flow's records
interleave with enrich-order's because it really is running concurrently.
seq totally orders the records one runtime produced. It is unique and gapless at
the point of publication, but the stream is not necessarily written in seq
order: a publisher stamps the number and then queues the record, so sort by it
rather than assuming it is already sorted. A gap in seq means records were lost,
and the stream says so in band. See Dropped records.
Record kinds
| Kind | When |
|---|---|
flow.started | A message was accepted onto a flow's pipeline. |
flow.completed / flow.dropped / flow.failed | How that invocation ended, with its duration. failed means unhandled: a message the error path recovered completes. |
block.pre-invoke | The message a block is about to receive. |
block.post-invoke | What it returned, with how long it took. |
llm.turn | One model turn of any AI block (an ai-agent iteration, an ai-router round, an ai-retry attempt, an ai-mapping call) with the model that served it and the token usage the provider reported. |
agent.compaction | An ai-agent shrinking its own conversation to stay inside its context budget, with strategy, before, after, dropped and contextMaxTokens. It is not a model turn: pruning calls no model. A summarize compaction's model call is recorded separately as an llm.turn marked purpose: memory-compaction. |
llm.embed | One embedding call of an ai-embed block, with the model, the batch size, and the tokens the provider charged. |
source.receive / source.respond | A message admitted from an inbound request, and the response eventually written for it. |
source.emit | A message a scheduled source produced, with no caller to answer. |
trace.dropped | Records that could not be kept. |
What a model call cost
Every call the runtime makes to a provider, blocking or streamed, chat or
embedding, produces one record with the tokens that call was billed for and the
model that served it. Summing them by traceId tells you what a single execution
spent. The runtime reports tokens, not money: prices change faster than a runtime
release cycle, so pricing is the reader's job, and the record carries everything
that lookup needs.
| Attribute | On | Meaning |
|---|---|---|
model | both | The model that actually served the call, as the provider reported it, falling back to the configured id. A configured alias resolves to a dated snapshot, and it is the snapshot that has a price. |
usage | both | What the provider charged. {inputTokens, outputTokens, thinkingTokens, cachedTokens, cacheWriteTokens, promptTokens} for llm.turn, {inputTokens} for llm.embed. outputTokens is billing-authoritative and already includes thinkingTokens, so adding them double-counts. cachedTokens is cache reads and cacheWriteTokens is cache creation; a read is cheaper and a write dearer than ordinary input, so they are reported apart rather than summed. Only Anthropic reports a write count. promptTokens is every token the provider read, the portable answer to "how full was the context": Anthropic's inputTokens excludes cached reads while the other three include them. |
connector | both | The connector the call went through, under the name you gave it. |
provider | both | The vendor family that served the call, in the price catalogue's vocabulary: ANTHROPIC, OPENAI, GOOGLE (Gemini's vendor is spelled GOOGLE). Absent when the connector does not report one. Read this rather than guessing from model, which does not settle it: gpt-4o is published under more than one vendor. |
block | both | The block's authored name, when it has one. path addresses it either way. |
iteration | llm.turn | Which pass of the block's own loop this was, one-based: an agent's iteration, a router's round, a retry's attempt. Absent for a block that calls the model once per message. |
stopReason / toolCalls | llm.turn | Why the model stopped, and how many tools it asked for. |
purpose | llm.turn | Present only on a call that is not one of the block's own turns. Today the one case is memory-compaction: the summarizing call an ai-agent with memoryCompaction: summarize makes to shrink its own memory. It is billed to the agent's block but carries no iteration. |
batch | llm.embed | How many texts that one call embedded. |
usage is the same shape an ai-agent's
turn_end event reports, so the two observers
of one call cannot disagree about what it cost.
A record with no usage means the provider reported none. For a chat turn that
is a failure, and the record carries error instead. For an embedding it is
routine: Gemini's embeddings API returns no token count at all. Either way the
absence is reported as absence, not as a zero that would read as "free".
# What did this run spend, per block and model? Both kinds, since a flow that
# embeds and then reasons pays for both. `out` is simply null for an
# embedding, which has no output to bill.
jq -c 'select(.kind=="llm.turn" or .kind=="llm.embed") |
{kind, path, model: .attrs.model,
in: .attrs.usage.inputTokens, out: .attrs.usage.outputTokens}' \
/tmp/run.jsonlThe trace id
The id is minted once, by whoever sees the work first (a source, or the flow
boundary) and everything downstream joins it rather than starting its own. It
survives every boundary the runtime has. EventID is re-minted whenever work
crosses into a sub-invocation, so it cannot be the thread; the trace id can:
| Boundary | What happens |
|---|---|
flow-ref, events publish | The message is cloned and re-keyed; the trace id rides along. |
fork, foreach | Branches clone or scope the message; same id. |
split | Each element is a new message that copies the parent's variables; same id. |
aggregate | The released batch is a brand-new message, so the group remembers the id and restores it. |
queue, topics | The id travels as a message variable, which the wire encoding ships as a header, so a trace survives crossing into another process. |
It rides in the message's variables under a reserved, internal name, so it is
stripped from anything user-facing (octo invoke output, spy dumps) and cannot
collide with a variable of your own. It is not readable from CEL, because it
exists only while tracing is on.
Cost, and turning it down
The defaults are tuned for "I am watching this run", not for production:
| Setting | Cost per traced block |
|---|---|
--traces-bodies --traces-vars (default) | ~700ns, 30 allocations: one json.Marshal of the message |
--traces-bodies=false --traces-vars=false | ~15ns, no allocations |
| tracing off | one atomic load, nothing built |
llm.turn and llm.embed are recorded per model call, not per block, and a
model call is a network round trip, so their cost is not measurable next to the
call they describe. With tracing off they add one atomic load and do not even read
the clock.
Two ways to make it cheaper:
# Record the sequence without payloads.
octo run --config app.yaml --traces --traces-bodies=false --traces-vars=false
# Trace only the blocks you care about, in the same grammar --spies accepts.
octo run --config app.yaml --traces \
--traces-blocks 'orders.charge,orders.fanout[audit].log-it'--traces-blocks filters block.pre-invoke and block.post-invoke only.
llm.turn and llm.embed are always recorded, from every AI block in the config,
so that a block filter cannot make a trace under-report what a run spent.
Payloads are on by default because "what did the message look like here" is the
question tracing exists to answer. A body over --traces-max-payload (32 KiB) is
omitted whole and flagged truncated, never cut short, because a JSON document
sliced at a byte boundary would break every reader after it in the stream.
Bodies and variables can carry personal data and credentials: the http source
copies configured request headers into variables. The standalone trace file is
created 0600, and the k8s runtime publishes to a shared subject. Turn payload
capture off for anything you would not want written down.
Dropped records
Records are queued and drained by a goroutine of the tracer's own. If that queue fills, records are dropped rather than blocking the flow, because tracing must never put a flow worker behind a disk write.
A dropped record is never silent. The stream carries a trace.dropped marker
naming how many were lost, and the gap is also visible as a jump in seq.
Raise --traces-buffer if you are losing records in bursts; narrow
--traces-blocks if you are losing them steadily.
Where traces go
The destination is the runtime services module's choice, not a flag, the same way queues are in-process for one module, NATS subjects for another, and long polls against your own server for the third:
| Module | Destination |
|---|---|
| standalone (default build) | A JSONL file, one object per line. Not a JSON array: a runtime is usually killed rather than stopped, and an array whose closing bracket was never written is unreadable, while JSONL cut anywhere is still valid up to its last complete line. |
k8s (-tags k8s) | The internal.traces NATS subject, consumed by the platform's observability service, which stores the records and prices the model calls among them. See Traces. |
api (-tags api) | Batched to your own server, if its discovery document says it accepts traces. Records are dropped rather than held when a batch cannot be sent, so a platform that is unwell does not become a runtime that runs out of memory. See The platform API. |
The file is flushed whenever the runtime goes idle, so you can tail -f it during
a run, and synced on a graceful stop.
In a deployment
A deployed pod passes no arguments, since the image's CMD stands, so tracing is
enabled through the environment instead. Every flag's default is the variable of
the same meaning:
OCTO_TRACING=true
OCTO_TRACING_BODIES=false
OCTO_TRACING_BLOCKS=orders.chargeOn the platform you do not set the first one by hand: Trace this deployment in the deploy and rollout dialogs is a per-deployment setting that supplies it. The other two narrow what is captured and are bound as ordinary env vars.
All of them are read from the process environment only, not from a .env
file: they are resolved when octo run parses its flags, before any config is
loaded. That is why the setting reaches a live deployment through a rollout, since
a pod has to start with it.
What the pod publishes is then stored and queryable. See Traces for how records are kept, what a model call is priced at, how to tell an unpriced call from a free one, and the API over all of it.
octo invoke never traces, because it starts no hosted services, which is also why
test suites are unaffected by any of this.