Octov0.11.7
Runtime

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 --traces

That 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.jsonl

A 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.jsonl

And dolphin will run a whole suite that way, one file per case:

dolphin test orders_test.yaml --traces-dir ./traces

That 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-api

Every 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

KindWhen
flow.startedA message was accepted onto a flow's pipeline.
flow.completed / flow.dropped / flow.failedHow that invocation ended, with its duration. failed means unhandled: a message the error path recovered completes.
block.pre-invokeThe message a block is about to receive.
block.post-invokeWhat it returned, with how long it took.
llm.turnOne 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.compactionAn 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.embedOne embedding call of an ai-embed block, with the model, the batch size, and the tokens the provider charged.
source.receive / source.respondA message admitted from an inbound request, and the response eventually written for it.
source.emitA message a scheduled source produced, with no caller to answer.
trace.droppedRecords 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.

AttributeOnMeaning
modelbothThe 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.
usagebothWhat 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.
connectorbothThe connector the call went through, under the name you gave it.
providerbothThe 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.
blockbothThe block's authored name, when it has one. path addresses it either way.
iterationllm.turnWhich 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 / toolCallsllm.turnWhy the model stopped, and how many tools it asked for.
purposellm.turnPresent 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.
batchllm.embedHow 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.jsonl

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

BoundaryWhat happens
flow-ref, events publishThe message is cloned and re-keyed; the trace id rides along.
fork, foreachBranches clone or scope the message; same id.
splitEach element is a new message that copies the parent's variables; same id.
aggregateThe released batch is a brand-new message, so the group remembers the id and restores it.
queue, topicsThe 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:

SettingCost 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 offone 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:

ModuleDestination
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.charge

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

On this page