Skip to content

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.

run = agent.run_sync("...")
print(run.trace)  # human-readable span tree

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

--8<--"examples/14_tracing/inspect_trace.py"
--8<--"examples/14_tracing/custom_exporter.py"

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:

from runa import project

project.use(Path("../other-app"))
db.traces().list(limit=5)

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:

uv add "runa-ai[postgres]"
export RUNA_DATABASE_URL=postgresql://user:password@host:5432/runa

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:

DELETE FROM traces WHERE start_time < extract(epoch FROM now() - interval '30 days');

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:

observe(capture_outputs=False)  # no outputs in traces, and none in the DEBUG log lines either

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:

class SupportAgent(Agent):
    name = "support_agent"
    hooks = MyHooks()

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

--8<--"examples/11_hooks/run_hooks.py"
--8<--"examples/11_hooks/agent_hooks.py"

More in examples/11_hooks/.