← Back to DevBytes

Observability and Tracing with Pydantic AI: Complete Guide

Observability and Tracing with Pydantic AI: Complete Guide

Building production-grade applications with LLMs requires more than just prompt engineering. When agents start chaining multiple tool calls, model invocations, and validation steps, debugging becomes a black box. Pydantic AI addresses this challenge head-on by integrating first-class observability and tracing capabilities, primarily through its tight integration with Logfire and OpenTelemetry-compatible backends.

This guide walks you through everything you need to know to instrument, trace, and debug your Pydantic AI applications effectively.

What Is Observability in Pydantic AI?

Observability in Pydantic AI refers to the ability to inspect what happens inside your agents during execution. This includes capturing:

Pydantic AI automatically instruments these events as spans — units of work in a distributed trace. Each agent run produces a hierarchical trace that you can visualize and query.

Why Observability Matters for AI Applications

LLM applications behave differently from traditional software. The same input can produce different outputs, models hallucinate, tool calls fail silently, and costs can spiral out of control. Without observability, you are flying blind. Here is why tracing is essential:

Setting Up Logfire Integration

Pydantic AI is built by the same team behind Logfire, an observability platform designed for Python and AI workloads. The integration is seamless and requires minimal setup.

Installation

Install Pydantic AI with the logfire extra, or install Logfire separately:

pip install pydantic-ai logfire

# Or install with the logfire extra
pip install "pydantic-ai[logfire]"

Authenticating with Logfire

After installing, authenticate your local environment with your Logfire account:

logfire auth

This opens a browser to complete authentication. Once done, you can configure your application to send traces to Logfire.

Basic Instrumentation

Here is the minimal setup to start tracing your Pydantic AI agents:

import logfire
from pydantic_ai import Agent

# Configure Logfire — this enables automatic instrumentation
logfire.configure()

# Auto-instrument common libraries (optional but recommended)
logfire.instrument_pydantic_ai()
logfire.instrument_httpx()

# Define and run your agent
agent = Agent(
    'openai:gpt-4o',
    system_prompt='You are a helpful assistant that answers concisely.',
)

result = agent.run_sync('What is the capital of France?')
print(result.data)

The logfire.instrument_pydantic_ai() call automatically instruments all agent runs, tool calls, and model interactions. Every execution now produces a structured trace visible in your Logfire dashboard.

Understanding the Trace Structure

When you run an agent, Pydantic AI generates a hierarchical trace. Understanding this structure helps you navigate and debug effectively.

Trace Hierarchy

A typical agent run produces the following span hierarchy:

Inspecting a Trace Programmatically

You can access trace information directly from the result object without a full observability backend:

import logfire
from pydantic_ai import Agent

logfire.configure()
logfire.instrument_pydantic_ai()

agent = Agent('openai:gpt-4o', system_prompt='You are a math tutor.')

result = agent.run_sync('What is 15 * 23?')

# Access the run history
print("All messages exchanged:")
for message in result.all_messages():
    print(f"  Role: {message.role}")
    if hasattr(message, 'content'):
        print(f"  Content: {message.content}")

# Access usage statistics
print(f"\nUsage: {result.usage()}")

The result.usage() method returns token counts for the entire run, while result.all_messages() gives you the complete conversation history including tool calls and responses.

Tracing Tool Calls in Detail

Tool calls are often the most complex part of an agent's execution. Pydantic AI traces each tool invocation with full argument and return value visibility.

Defining and Tracing Tools

import logfire
from pydantic_ai import Agent, RunContext
from dataclasses import dataclass

logfire.configure()
logfire.instrument_pydantic_ai()

@dataclass
class Dependencies:
    db_connection_string: str

agent = Agent(
    'openai:gpt-4o',
    deps_type=Dependencies,
    system_prompt='You can query a database for user information.',
)

@agent.tool
async def get_user_info(ctx: RunContext[Dependencies], user_id: int) -> dict:
    """Retrieve user information by ID."""
    # This tool call will appear in the trace with:
    # - The user_id argument
    # - The return value
    # - Execution duration
    logfire.info('Querying user', user_id=user_id)
    
    # Simulated database lookup
    users = {
        1: {"name": "Alice", "email": "alice@example.com"},
        2: {"name": "Bob", "email": "bob@example.com"},
    }
    return users.get(user_id, {"error": "User not found"})

@agent.tool
async def update_user_email(
    ctx: RunContext[Dependencies], 
    user_id: int, 
    new_email: str
) -> dict:
    """Update a user's email address."""
    logfire.info('Updating email', user_id=user_id, new_email=new_email)
    return {"status": "updated", "user_id": user_id, "email": new_email}

deps = Dependencies(db_connection_string="postgresql://localhost/mydb")
result = agent.run_sync(
    'Find user 1 and update their email to alice.new@example.com',
    deps=deps,
)
print(result.data)

In your Logfire dashboard, you will see each tool call as a child span under the agent run, with the full arguments and return values captured for inspection.

Custom Spans and Logging

Beyond automatic instrumentation, you can add custom spans to mark important business logic boundaries within your tools or agent workflows.

Adding Custom Spans

import logfire
from pydantic_ai import Agent, RunContext

logfire.configure()
logfire.instrument_pydantic_ai()

agent = Agent('openai:gpt-4o', system_prompt='You are a research assistant.')

@agent.tool
async def search_documents(ctx: RunContext[None], query: str) -> str:
    """Search through documents."""
    # Create a custom span for the search phase
    with logfire.span('document_search', query=query):
        # Simulate search logic
        logfire.info('Performing vector search')
        results = f"Found 3 documents matching '{query}'"
        
        # Log structured data
        logfire.info('Search completed', result_count=3, query=query)
        return results

@agent.tool
async def summarize(ctx: RunContext[None], text: str) -> str:
    """Summarize the given text."""
    with logfire.span('summarization', text_length=len(text)):
        logfire.info('Generating summary')
        return f"Summary of: {text[:50]}..."

result = agent.run_sync('Search for "machine learning" and summarize the results.')
print(result.data)

Custom spans nest within the agent run trace, giving you granular visibility into specific operations. This is especially useful for identifying bottlenecks in multi-step workflows.

Using OpenTelemetry Instead of Logfire

If you prefer a different observability backend — such as Jaeger, Zipkin, Datadog, or Honeycomb — Pydantic AI supports OpenTelemetry natively. Since Logfire uses OpenTelemetry under the hood, you can redirect traces to any OTLP-compatible backend.

Configuring OpenTelemetry Export

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
import logfire

# Set up OpenTelemetry with a custom backend
resource = Resource.create({
    "service.name": "my-pydantic-ai-app",
    "deployment.environment": "production",
})

provider = TracerProvider(resource=resource)
otlp_exporter = OTLPSpanExporter(
    endpoint="http://localhost:4317",  # Jaeger, Zipkin, etc.
    insecure=True,
)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
trace.set_tracer_provider(provider)

# Configure Logfire to use the existing OpenTelemetry provider
logfire.configure(send_to_logfire=False)

# Still instrument Pydantic AI — traces go to your OTLP backend
logfire.instrument_pydantic_ai()

from pydantic_ai import Agent

agent = Agent('openai:gpt-4o', system_prompt='You are a helpful assistant.')
result = agent.run_sync('Explain quantum computing in one sentence.')
print(result.data)

This setup sends all Pydantic AI traces to your chosen OpenTelemetry backend while disabling Logfire's own collection. You get the same rich instrumentation without vendor lock-in.

Streaming and Async Tracing

Tracing works identically for streaming and asynchronous agent runs. Each streamed token chunk is captured, and the full trace is completed when the stream finishes.

Tracing a Streaming Run

import asyncio
import logfire
from pydantic_ai import Agent

logfire.configure()
logfire.instrument_pydantic_ai()

agent = Agent('openai:gpt-4o', system_prompt='You are a creative storyteller.')

async def main():
    # Streaming with full trace capture
    async with agent.run_stream('Write a short story about a robot learning to paint.') as result:
        async for chunk in result.stream_text(delta=True):
            print(chunk, end='', flush=True)
        print()  # Newline after streaming completes
    
    # The complete trace is available after the stream ends
    print(f"\nToken usage: {result.usage()}")

asyncio.run(main())

Tracing Async Tool Calls

import asyncio
import logfire
import httpx
from pydantic_ai import Agent, RunContext

logfire.configure()
logfire.instrument_pydantic_ai()
logfire.instrument_httpx()  # Trace HTTP calls made by httpx

agent = Agent('openai:gpt-4o', system_prompt='You can fetch weather data.')

@agent.tool
async def get_weather(ctx: RunContext[None], city: str) -> dict:
    """Fetch current weather for a city."""
    async with httpx.AsyncClient() as client:
        # This HTTP call will appear as a nested span
        response = await client.get(
            f"https://wttr.in/{city}",
            params={"format": "j1"},
        )
        data = response.json()
        current = data["current_condition"][0]
        return {
            "city": city,
            "temp_c": current["temp_C"],
            "description": current["weatherDesc"][0]["value"],
        }

async def main():
    result = await agent.run('What is the weather in London?')
    print(result.data)

asyncio.run(main())

With logfire.instrument_httpx(), every HTTP request made inside a tool call appears as a child span, showing the URL, status code, and duration. This is invaluable for debugging external API integrations.

Working with Multi-Agent Systems

When agents delegate to other agents, tracing becomes critical for understanding the full execution flow. Pydantic AI captures the delegation chain in the trace hierarchy.

Tracing Agent Delegation

import logfire
from pydantic_ai import Agent, RunContext

logfire.configure()
logfire.instrument_pydantic_ai()

# Specialist agent for math
math_agent = Agent(
    'openai:gpt-4o',
    system_prompt='You are a math expert. Solve problems step by step.',
)

# Specialist agent for writing
writing_agent = Agent(
    'openai:gpt-4o',
    system_prompt='You are a professional writer. Create polished content.',
)

# Orchestrator agent that delegates to specialists
orchestrator = Agent(
    'openai:gpt-4o',
    system_prompt='You coordinate between specialists. Delegate tasks appropriately.',
)

@orchestrator.tool
async def solve_math(ctx: RunContext[None], problem: str) -> str:
    """Delegate a math problem to the math specialist."""
    logfire.info('Delegating to math agent', problem=problem)
    result = await math_agent.run(problem, usage=ctx.usage)
    return result.data

@orchestrator.tool
async def write_content(ctx: RunContext[None], topic: str) -> str:
    """Delegate writing to the writing specialist."""
    logfire.info('Delegating to writing agent', topic=topic)
    result = await writing_agent.run(topic, usage=ctx.usage)
    return result.data

result = orchestrator.run_sync(
    'Calculate the area of a circle with radius 5, then write a paragraph about it.'
)
print(result.data)

In the trace, you will see the orchestrator's run as the top-level span, with each delegation appearing as a nested agent run span. This makes it easy to see which specialist handled which part and how long each took.

Monitoring Costs and Token Usage

One of the most practical uses of observability is tracking token consumption. Pydantic AI captures usage data at every model call.

Extracting Usage Data

import logfire
from pydantic_ai import Agent

logfire.configure()
logfire.instrument_pydantic_ai()

agent = Agent('openai:gpt-4o', system_prompt='You are a helpful assistant.')

result = agent.run_sync('Explain the theory of relativity.')

usage = result.usage()
print(f"Request tokens:  {usage.request_tokens}")
print(f"Response tokens: {usage.response_tokens}")
print(f"Total tokens:    {usage.total_tokens}")
print(f"Details:         {usage.details}")

# For multi-turn conversations, track cumulative usage
result2 = agent.run_sync(
    'Now explain it in simpler terms.',
    message_history=result.all_messages(),
)
combined_usage = result2.usage()
print(f"\nCumulative total tokens: {combined_usage.total_tokens}")

In Logfire, you can create dashboards and alerts based on token usage metrics, helping you catch cost anomalies before they become problems.

Best Practices for Observability

1. Instrument Early and Always

Add observability from day one of development. Retrofitting tracing into a complex agent system is painful and error-prone. The overhead is negligible, and the insights are invaluable.

2. Use Meaningful Span Names

When creating custom spans, use descriptive names that reflect business logic, not implementation details:

# Good — describes what the operation does
with logfire.span('invoice_validation', invoice_id=inv_id):
    ...

# Bad — too generic
with logfire.span('process'):
    ...

3. Log Structured Data, Not Strings

Structured logging enables filtering and aggregation in your observability backend:

# Good — structured and queryable
logfire.info('tool_executed', tool_name='search', duration_ms=142, success=True)

# Bad — unstructured, hard to query
logfire.info(f'Tool search executed in 142ms, success=True')

4. Set Up Alerts for Anomalies

Configure alerts for:

5. Use Distributed Tracing for Microservices

If your agent calls external services, propagate trace context so you can see the full request path:

import logfire
from opentelemetry.propagate import inject
import httpx

logfire.configure()
logfire.instrument_pydantic_ai()

async def call_external_service(url: str, data: dict) -> dict:
    headers = {}
    # Inject trace context into headers for distributed tracing
    inject(headers)
    
    async with httpx.AsyncClient() as client:
        response = await client.post(url, json=data, headers=headers)
        return response.json()

6. Sample in Production, Trace Everything in Development

In production, consider sampling to reduce costs while maintaining visibility:

import logfire

# In development — capture everything
logfire.configure(environment='development')

# In production — use sampling
logfire.configure(
    environment='production',
    # Logfire handles sampling automatically based on your plan,
    # but you can also configure custom sampling rates
)

7. Tag Traces with Business Context

Add tags and metadata to traces so you can filter by user, feature, or experiment:

import logfire
from pydantic_ai import Agent

logfire.configure()
logfire.instrument_pydantic_ai()

agent = Agent('openai:gpt-4o', system_prompt='You are a customer support agent.')

# Add business context to the trace
logfire.log('info', 'support_request_started', 
    user_id='user_12345',
    tier='premium',
    feature='billing_support',
)

result = agent.run_sync('How do I upgrade my plan?')
print(result.data)

Debugging Common Issues with Traces

Identifying Infinite Loops

Agents can sometimes get stuck in tool-call loops. Traces make this immediately visible — you will see repeated tool call spans with similar arguments. Set a maximum number of result retries to prevent this:

from pydantic_ai import Agent

agent = Agent(
    'openai:gpt-4o',
    system_prompt='You are a helpful assistant.',
    retries=3,  # Maximum validation retries
)

# In traces, if you see more than 3 retry spans, something is wrong

Diagnosing Validation Failures

When structured output validation fails, traces show the raw model response alongside the validation error, making it easy to understand why the model's output did not match your schema:

from pydantic import BaseModel, field_validator
from pydantic_ai import Agent

class UserResponse(BaseModel):
    name: str
    age: int
    
    @field_validator('age')
    @classmethod
    def validate_age(cls, v):
        if v < 0 or v > 150:
            raise ValueError('Age must be between 0 and 150')
        return v

agent = Agent(
    'openai:gpt-4o',
    output_type=UserResponse,
    system_prompt='Extract user information from text.',
)

# If validation fails, the trace shows:
# 1. The raw model response
# 2. The validation error
# 3. Any retry attempts with corrected output
result = agent.run_sync('John is 25 years old.')
print(result.data)

Conclusion

Observability and tracing are not optional features for production AI applications — they are fundamental requirements. Pydantic AI's deep integration with Logfire and OpenTelemetry makes it straightforward to gain full visibility into your agents' behavior, from individual LLM calls to complex multi-agent delegation chains. By instrumenting early, logging structured data, and leveraging the trace hierarchy for debugging, you can build reliable, cost-efficient, and debuggable AI systems. Start with the basic logfire.configure() and logfire.instrument_pydantic_ai() calls, then gradually add custom spans, alerts, and business context tags as your application grows. The investment in observability pays off the first time you need to debug an unexpected agent response in production.

— Ad —

Google AdSense will appear here after approval

← Back to all articles