Tracing and Hooks¶
Tracing¶
Every run/run_sync/run_streamed call is traced automatically. There is no separate setup
step. A Trace is a hierarchical tree of Spans: agent, LLM call, tool call, handoff,
guardrail.
Traces are persisted to runa.db by default. Inspect them from the CLI:
runa traces list # most recent traces
runa traces errors # most recent traces that errored
runa traces show TRACE_ID # one trace's full span tree
Grouping Runs¶
Every Agent.run() inside a tracing.trace block gets the block's trace id as its group_id,
including runs started with asyncio.gather. Each run still has its own trace:
from runa import tracing
with tracing.trace("nightly-batch"):
for ticket in tickets:
SupportAgent().run_sync(ticket)
Outside a block, a session-backed run is grouped by its session id.
Privacy Policy¶
observe() configures what tracing captures, for the whole application or for a block:
from runa import observe
observe(capture_inputs=False) # applies immediately, stays applied, applies everywhere
with observe.scoped(redact=["password", "ssn"]):
agent.run_sync(...) # redacted within this block only
Both take the same options: capture_inputs/capture_outputs (whether to keep them at all),
redact (a list of dict keys to scrub) or a custom redactor callable, and
max_input_bytes/max_output_bytes/max_tool_result_bytes to truncate what's kept.
They differ in more than how long they last, which is why the scope is named rather than inferred
from whether a with follows. observe(...) sets the process-wide policy, so it's what an
application configures itself with, once, at startup. observe.scoped(...) applies to the current
task only: under runa serve, one request's block doesn't change what tracing captures for the
other requests in flight, the same way each request gets its own Agent. The flip side is that a
block has to wrap the work it means to cover -- a task started before the block was entered keeps
the policy it already had, so observe.scoped is not a way to configure a whole deployment.
Custom Exporters¶
By default, every finished trace goes to whichever store runa.db resolved for this deployment.
add_exporter adds one more exporter alongside whatever's active, without disabling the default:
from runa import add_exporter, ConsoleExporter
add_exporter(ConsoleExporter()) # runa.db still gets written to, console output is now added
observe(exporter=...) is a full override instead, since it's also how observe.scoped(...)
swaps exporters for one block's duration:
from runa import ConsoleExporter, SQLiteExporter, observe
observe(exporter=[SQLiteExporter(), ConsoleExporter()]) # replaces the exporter list entirely
Write your own by implementing TraceExporter's single method, export(self, trace: Trace) -> None.
Write it as plain synchronous code, and block in it freely: a finished run hands its trace to
tracing's own thread, so a slow write holds up nothing but that one run's last millisecond -- not
the event loop runa serve is answering every other request on. One thread, so your export is
never called from two at once and needs no lock of its own.
Langfuse¶
Install the runa-ai[langfuse] extra and set LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY (a .env
file works, same as OPENAI_API_KEY and friends), and every trace starts going to that Langfuse
project too, runa.db included -- no code change:
uv add "runa-ai[langfuse]"
echo "LANGFUSE_PUBLIC_KEY=pk-lf-..." >> .env
echo "LANGFUSE_SECRET_KEY=sk-lf-..." >> .env
Set LANGFUSE_HOST too for a non-default Langfuse region or a self-hosted instance. Without
LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY set, or without the langfuse extra installed,
tracing behaves exactly as if LangfuseExporter didn't exist -- SQLite only.
To point at a Langfuse project without using the environment (e.g. a second project, or one
picked at runtime), construct LangfuseExporter directly instead and add it with add_exporter:
from runa import add_exporter
from runa.tracing.langfuse import LangfuseExporter
add_exporter(LangfuseExporter(public_key="pk-lf-...", secret_key="sk-lf-..."))
LangfuseExporter posts plain OpenTelemetry spans straight to Langfuse's OTLP endpoint rather
than using the langfuse package: Runa exports a trace's whole span tree at once, after the run
finishes, and Langfuse's own SDK can only backdate an observation's end time, not its start time
-- it would otherwise record every span as starting at export time with an end time already in
the past.
Example¶
More in examples/14_tracing/.
Every trace above -- SQLite, Langfuse, or a custom exporter -- is also browsable visually with
runa ui. See CLI Reference.
Reading Traces¶
runa.db.traces() hands back this deployment's TraceStore, already pointed at whichever
backend it has. Three methods, and no caller names a file or a URL:
from runa import db
store = db.traces()
store.list(limit=20) # most recent first
store.list(agent="SupportAgent") # one agent's runs
store.list(status="error") # what `runa traces errors` shows
store.list(session_id="support_agent-1") # one conversation's runs
store.get("trace_abc123") # one trace with its full span tree
store.save(trace) # what an exporter does
print(trace) renders the span tree as text. To render it yourself -- a different format, your
own dashboard -- walk it rather than re-deriving its shape from the flat spans list:
def show(rows, depth=0):
for row in rows:
print(" " * depth, row.label, row.name, row.duration, row.tokens or "")
show(row.children, depth + 1)
show(trace.walk())
walk returns the root SpanRows, each with its children already ordered by start time. A row
carries what a person reads -- label, name, duration, tokens -- plus the raw span for
everything else, and hands_off to mark the row after which the remaining siblings are the work
of the agent that was handed to. Both runa traces show and runa ui render from this.
Point the process at another project to read its local history:
project.use is startup configuration, said once: it moves every concern at once -- sessions,
traces, eval history, memory, knowledge and the cache -- so an app read from outside its own
directory can't end up with half its state in one file and half in another. runa says it from
the current directory, and both create_apps from the root they're given, so most code never
calls it. Saying it imports nothing: that app's main.py only runs once something reads its
agents, so the call above reads another project's history without running its code.
db.sessions() and db.evals() are the same shape for conversation history and eval runs. All
three replaced the module-level list_traces(db_path=...)-style functions in 0.x; see
Versioning.
Traces Across Replicas¶
The default exporter writes to this process's db/runa.db, which is per-process by design. Three
replicas keep three disjoint histories and a dashboard that shows one of them. Set one variable
and traces (along with sessions, memory, knowledge, the cache and eval history) go to a shared
Postgres:
No code changes: db.traces(), runa traces, runa ui and the exporter all follow that
variable, so a trace written by one replica is readable from any of them.
Retention¶
Traces and spans are append-only, so a long-lived deployment's database grows until the disk
does. Spans cascade from their trace, so one DELETE past a cutoff is the whole retention pass:
Runa ships no retention command and runs no background thread, on purpose: when to delete your data is your decision, not the framework's. See Deployment.
Logs and the Privacy Policy¶
The privacy policy above governs the default log output too, not just spans. LoggingHooks
(the default hooks) logs at INFO without ever carrying content: an agent's answer and a tool's
result are user data, and a production app running at INFO should not be writing them to stdout.
Content is logged at DEBUG only, and passes through the same redact/redactor/capture_outputs
settings on its way:
Hooks¶
Tracing is unconditional. Hooks are optional lifecycle callbacks for your own logic: logging, metrics, side effects.
There is one class, Hooks, with one set of event names. It fires on_agent_start,
on_agent_end, on_handoff, on_tool_start, on_tool_end, on_llm_start, and on_llm_end;
every method is a no-op unless overridden.
What scopes an instance is where you put it, not which class you subclass. Passed per-call, it fires for every agent involved in a run, including subagents:
from runa import Hooks
class MyHooks(Hooks):
async def on_tool_end(self, context, agent, tool, result):
print(f"{tool.name} -> {result!r}")
agent.run_sync("...", hooks=MyHooks())
Assigned to a single Agent subclass's hooks class attribute instead, the same class fires
only for that agent, in every run it takes part in:
The two scopes are not alternatives: both fire for the same event, with the same arguments, the
run's hooks first, so a run-wide audit log records an event before any one agent's callback can
raise out of it. on_handoff is the one event an agent-scoped instance doesn't see for every
turn of its own agent: it fires on the target's hooks, the agent the run was handed to, and
never the sender's. It is told (from_agent, to_agent) at either scope.
LoggingHooks is the framework's default, logging each event through the standard logging
module under the "runa" logger name. Don't subclass it to add behavior; subclass Hooks
directly and pass your own instance instead.
Example¶
More in examples/11_hooks/.