Skip to content

Pydantic AI

Pydantic AI writes OpenTelemetry spans itself, in the OpenTelemetry GenAI vocabulary (gen_ai.*): an invoke_agent span for the run, a chat span for each model call and an execute_tool span for each tool call. Spanlight reads them for the kind, the model, the token counts and the messages. Logfire is not needed: hand Pydantic AI an OpenTelemetry tracer provider that exports to Spanlight’s OTLP endpoint.

Before you start

  • A running Spanlight instance that your application can reach
  • A project API key with the ingest:write scope (the default). Creating one needs the member, admin or owner role
  • Python 3.13 (the version this page was tested with)
Terminal window
pip install "pydantic-ai-slim[openai]==2.57.0" "openai==3.35.1" "opentelemetry-sdk==1.45.1" "opentelemetry-exporter-otlp-proto-http==1.45.1"

Point the OpenTelemetry exporter at Spanlight with the standard variables:

Terminal window
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:8080/v1/otlp/traces
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer%20spl_live_..."

Use the traces-specific endpoint variable with the full path. Configure an exporter explains why. Pydantic AI records the messages on the span by default, so no other setting is needed.

Create a tracer provider that exports over OTLP and turn on instrumentation for every agent with Agent.instrument_all:

from openai import AsyncOpenAI
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from pydantic_ai import Agent, InstrumentationSettings
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
Agent.instrument_all(InstrumentationSettings(tracer_provider=provider))
agent = Agent(
OpenAIChatModel("gpt-4.1-mini", provider=OpenAIProvider(openai_client=AsyncOpenAI())),
name="weather_agent",
instructions="Answer weather questions with the get_weather tool.",
)
@agent.tool_plain
def get_weather(city: str, unit: str = "celsius") -> str:
"""Get the current weather for a city."""
return "18 degrees and sunny"
agent.run_sync("What is the weather in Paris and Tokyo? Use the tool.")
provider.force_flush()

A long-running server does not need provider.force_flush(); a short script does, or its last spans are lost.

Open Traces in the dashboard and pick the project that owns your key. The recorded run gives a trace named invoke_agent weather_agent with 5 spans: the agent run, two chat gpt-4.1-mini model calls and two execute_tool get_weather tool calls. Open a chat span: it shows the messages, the answer, the token counts and the cost. The trace’s token total is the sum of its two model calls.

Span in the recorded run gen_ai.operation.name Spanlight kind
invoke_agent weather_agent invoke_agent agent
chat gpt-4.1-mini chat llm
execute_tool get_weather execute_tool tool

On each model call:

  • Provider and model from gen_ai.provider.name and gen_ai.response.model, the billed snapshot such as gpt-4.1-mini-2025-04-14.
  • Input, output and cached tokens from gen_ai.usage.input_tokens, gen_ai.usage.output_tokens and gen_ai.usage.cache_read.input_tokens.
  • Messages and answer from gen_ai.input.messages and gen_ai.output.messages, including the tool calls the model asked for and the tool results sent back.
  • Finish reason from gen_ai.response.finish_reasons. Pydantic AI writes tool_call, which Spanlight stores as tool_calls, and stop on the answer.
  • Cost, computed by Spanlight from the model and the token counts; see How cost is calculated.

The run total is counted once. With the default settings, Pydantic AI puts the run’s total usage on the invoke_agent span under gen_ai.aggregated_usage.*, which Spanlight does not read as usage, so only the chat spans are counted. With use_aggregated_usage_attribute_names=False (the second recorded run) the agent span repeats the total under gen_ai.usage.*. Spanlight then marks it a usage rollup, shown as “Counted on child spans”, and still counts only the chat spans: both runs store the same trace totals. See Usage counted once.

Which attribute fills which field is listed in OpenTelemetry GenAI.

  • Time to first token. The recorded runs did not stream.
  • Cache writes. The recorded calls went to an OpenAI stand-in, which reports none.
  • The session. Pydantic AI’s gen_ai.conversation.id is not read as the trace’s session; set session.id on the spans if you need one (see Trace fields).
  • Exporter-computed cost. Pydantic AI sends operation.cost on each chat span; Spanlight never reads it and prices every span itself.
  • Other providers, streaming, structured output and Logfire. They were not recorded, so this page makes no claim about them.

OTLP compatibility shows both recorded runs field by field.

  • No trace arrives. Call Agent.instrument_all with the tracer provider before the first run, and flush the provider before a script exits. Then work through No trace arrives.
  • The exporter gets 422 VALIDATION_ERROR. It sent to /v1/traces. Set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT to the full /v1/otlp/traces URL.
  • The agent span shows tokens but says “Counted on child spans”. It is a usage rollup, so its tokens are not added to the trace a second time; see What is recorded.