How do I trace tool calls in Pydantic AI?
Rius traces Pydantic AI tool calls with no wrapper code: the framework emits a span named execute_tool plus the tool’s name for each call, and it nests under the agent run. Call rius.init() and Agent.instrument_all(True) once. To trace a step the framework does not span, wrap it with a tool-kind span from the Rius SDK.
How it works
Pydantic AI is OpenTelemetry-native. rius.init() installs the global tracer provider, and Agent.instrument_all(True) tells the framework to emit its spans through it. Rius reads the framework’s GenAI attributes directly and computes cost on its side.
Three kinds of span arrive, and they nest like this:
- The agent run is the root span, named
invoke_agentplus your agent’s name. - Each model request is a child generation named
chatplus the model, with tokens and cost. - Each tool call is a child span named
execute_toolplus the tool’s name.
A run that calls one tool looks like this:
invoke_agent support_agent 6.0s
├─ chat gpt-5-mini 3.3s decides to call the tool
├─ execute_tool account_status 0ms
└─ chat gpt-5-mini 2.7s answersThe first chat span is the model deciding to call the tool, the tool span is the call, and the second chat span is the final answer. Spans carry your agent, model and tool names, so you can tell them apart in the trace without naming anything yourself. See Pydantic AI for the full integration page.
Two details to know about these spans:
- Successful spans arrive with status UNSET rather than OK. The trace status still reads Ok.
- What the spans carry (prompts, completions, tool arguments) is controlled by Pydantic AI’s
InstrumentationSettings, for exampleinclude_content, on top of the SDK’s own privacy controls.
Set it up
- Install the SDK with the base package. Pydantic AI needs no extra.
pip install glassflow-rius pydantic-ai- Initialize Rius, switch on Pydantic AI instrumentation, and register your tool as usual. The tool span appears without further code.
import rius
from pydantic_ai import Agent
rius.init(api_key="ri_...", service_name="my-agent")
Agent.instrument_all(True)
support_agent = Agent(
"openai:gpt-5-mini",
instructions="Answer support questions; check accounts with the tool.",
)
@support_agent.tool_plain
def account_status(email: str) -> str:
"""Fetch a customer's account standing by email."""
return lookup(email)
with rius.session(conversation_id):
result = support_agent.run_sync(ticket)- To trace a step the framework does not span, such as a helper your tool calls or work you run outside a registered tool, open a tool-kind span around it. The context-manager form makes the span current, so anything started inside it nests under it. Unnamed, the span is called
execute_tool refund_lookup, from the tool name.
import rius
from rius import SpanKind
def refund_lookup(order_id: str) -> str:
with rius.start_as_current_span(
kind=SpanKind.TOOL, tool_name="refund_lookup"
) as obs:
obs.set_input(order_id)
result = fetch_refund(order_id)
obs.set_output(result)
return resultTo trace a whole function instead, apply @rius.observe(kind=SpanKind.TOOL, tool_name="refund_lookup") to it. You can also pass tool_call_id, the id the model put on the tool call, to join the tool span back to the generation that asked for it. Exceptions are recorded on the span with an error status and always re-raised. See Tracing your code for the span options.
- Run one request that calls a tool and open the trace in the console. You should see the
invoke_agentroot, thechatgenerations and theexecute_toolspans. Any span you wrapped yourself appears with them.
What you get
- Each tool call shows as its own span under the run, between the model request that chose it and the one that used its result.
- Tool spans sit next to the
chatgenerations, so a slow run shows whether the time went to the model or to a tool. - Spans you wrap yourself carry the tool name, input and output, and a failure records the error type, so it groups with other errors.
- Cost is computed by Rius from the model and token counts on the
chatspans, as a list-price estimate in US dollars. You never send a cost value. - Scoping the run with
rius.session()and your conversation id groups the turns of one conversation, and the framework’s spans inherit the scope.
The Integrations page shows how each framework is covered, since some add run structure with observe wrappers instead of their own spans.
When something else fits
- If you use Pydantic AI without the Rius SDK, point any OpenTelemetry setup at the Rius ingest endpoint instead. The Pydantic AI page links to the vanilla OpenTelemetry route.
- If a tool does not run through the framework at all, the wrapper above is the way to trace it. Use the Pydantic AI integration page for the framework’s own spans.