Skip to content

[SDK] feat: add native Pydantic AI integration#7536

Open
aduverger wants to merge 4 commits into
comet-ml:mainfrom
aduverger:pydantic_ai
Open

[SDK] feat: add native Pydantic AI integration#7536
aduverger wants to merge 4 commits into
comet-ml:mainfrom
aduverger:pydantic_ai

Conversation

@aduverger

@aduverger aduverger commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Context

We've been using Pydantic AI with Opik on production for about 6 months now. The logfire workaround was not good enough as we needed better integration and customisation of Opik traces.

The custom Pydantic AI OpikSpanProcessor was initially developed in our in-house agent framework. I moved it into Opik with Claude as part of this PR.

What I would like to discuss in particular is token tracking: I had to implement a few rules to retrieve cached tokens (and deduce them from input tokens). Do you have a more generalisable way to do it for any LLM provider ?

Details

Adds a native opik.integrations.pydantic_ai module so Pydantic AI agents are traced with a single track_pydantic_ai() call — no logfire and no manual OpenTelemetry env vars. Every agent.run becomes an Opik trace with correctly named, nested llm / tool spans, token usage and cost. This replaces the documented logfire workaround, which required an extra dependency, hand-written OTEL env-var glue, and left span naming/enrichment to the backend.

  • Motivation: Pydantic AI exposes no native callback hooks — its only observability surface is OpenTelemetry. So, like every other agent integration in Opik (ADK, LangChain, LlamaIndex, CrewAI, DSPy, Haystack), this consumes the framework's spans in-process and writes Opik spans directly, bringing Pydantic AI to parity.
  • OpikSpanProcessor maps gen_ai.* spans: invoke_agentgeneral (renamed run <agent>), chat <model>llm, execute_tooltool. It creates a trace when none is active, or nests under the surrounding @opik.track trace.
  • Captures cache-discounted token usage (Anthropic/Bedrock), structured output (final_result), tool args/result, and error info. Reserved opik.* keys (opik.metadata, opik.provider, opik.prompts, opik.thread_id) are read from the native agent.run(..., metadata=...).
  • track_pydantic_ai(agent=None, *, project_name, metadata, tags) instruments all agents or a single one. Following convention, pydantic-ai is imported lazily and pinned only in the integration's test requirements.txt (not setup.py).
import opik
from pydantic_ai import Agent
from opik.integrations.pydantic_ai import track_pydantic_ai

opik.configure()
track_pydantic_ai()

agent = Agent("openai:gpt-4o")
agent.run_sync('Where does "hello world" come from?')

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves # N/A
  • OPIK- N/A

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 4.8
  • Scope: Full implementation (SDK module + tests) and documentation, authored from an existing in-house reference implementation.
  • Human verification: Author reviewed and approved each step; tests and pre-commit run locally; span attributes verified against Pydantic AI 2.13 output.

Testing

  • pytest tests/unit/integrations/pydantic_ai tests/library_integration/pydantic_ai — 10 passing (6 library-integration via Pydantic AI's TestModel, hermetic/no API keys; 4 unit for cache-token math and JSON parsing).
  • pre-commit run --files <changed py files> — ruff, ruff-format, mypy all clean.
  • Scenarios validated: standalone run creates its own trace; nesting under @opik.track yields a single trace; tool spans; structured output; thread_id grouping via metadata.
  • Manual end-to-end against a live Opik backend + real LLM provider:
image

Documentation

  • Rewrote the Pydantic AI integration page for both surfaces: docs-v2/integrations/pydantic-ai.mdx and docs/tracing/integrations/pydantic-ai.mdx (native opik.configure()track_pydantic_ai() flow; cost tracking; thread grouping; metadata/prompt enrichment; @track nesting).
  • Replaced the old logfire trace image with a native-integration screenshot (img/tracing/pydantic-ai/pydantic-ai_trace.png) showing run <agent>chat <model>execute_tool spans from a real run.

@aduverger
aduverger requested review from a team as code owners July 20, 2026 18:23
@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file python Pull requests that update Python code tests Including test files, or tests related like configuration. Python SDK 🔴 size/XL labels Jul 20, 2026
Comment on lines +43 to +54
assert result.output is not None
assert len(fake_backend.trace_trees) == 1
trace_tree = fake_backend.trace_trees[0]

assert trace_tree.name == "run my_agent"
assert trace_tree.project_name == "pydantic-ai-test"
assert trace_tree.input == {"prompt": "hello"}
assert trace_tree.output is not None

root_span = trace_tree.spans[0]
assert root_span.name == "run my_agent"
assert root_span.type == "general"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misses span nesting regressions

According to the PR description, trace_tree.output is not None, root_span.name == "run my_agent", and llm_spans = [s for s in _flatten(trace_tree.spans) if s.type == "llm"] only prove a trace exists and that at least one llm span was emitted, and _flatten() drops the parent/child shape so flat or mis-parented spans would still pass. Can we assert the expected tree layout and concrete output value here, and apply the same pattern to the tool, structured-output, and nested-trace tests below?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/tests/library_integration/pydantic_ai/test_pydantic_ai.py around lines 35-60
in `test_pydantic_ai__standalone_run__creates_trace_with_nested_spans`, replace the
current `_flatten(trace_tree.spans)`-based `llm_spans` assertions with checks that
verify the actual parent/child span hierarchy from `trace_tree.spans` (e.g., assert the
root span named “run my_agent” directly contains the expected `llm` span(s), and
fail if spans are only present in a flat list). Also capture the concrete run output by
asserting `trace_tree.output` matches the actual `result.output` (not just `is not
None`), using the same output shape conventions already used in the structured-output
test. Apply the same “don’t flatten; assert tree structure + concrete output”
pattern to the tool test (lines 62-79), the structured-output test (lines 81-95), and
the nested-trace test (lines 97-117) so they all validate correct nesting and precise
traced values.

Comment on lines +131 to +135
def test_track_pydantic_ai__returns_instrumented_agent():
agent = Agent(TestModel(), name="return_agent")
returned = track_pydantic_ai(agent)
assert returned is agent
assert isinstance(agent.instrument, InstrumentationSettings)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default instrumentation path untested

According to the PR description, track_pydantic_ai(agent=None) is the primary entry point, but this test only covers track_pydantic_ai(agent), so regressions in Agent.instrument_all(settings) would still pass CI — can we add a library-integration test for track_pydantic_ai() with no agent that creates a fresh Agent(TestModel()) and asserts the trace/span tree?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/tests/library_integration/pydantic_ai/test_pydantic_ai.py around lines
131-135, extend the coverage for `test_track_pydantic_ai__returns_instrumented_agent` by
adding a new test for the `track_pydantic_ai(agent=None)` branch. Create a test that
calls `track_pydantic_ai()` with no arguments, then instantiates a fresh
`Agent(TestModel())`, runs it (e.g., `run_sync("hello")`), flushes with
`opik.flush_tracker()`, and asserts the fake backend has exactly one trace tree with at
least the expected root span name/type (and optionally nested llm/tool spans as in the
other tests). Refactor or reuse existing helpers like `_instrument`,
`_provider_with_opik_processor`, and `_flatten` to keep assertions consistent and ensure
the test is isolated from other tests’ global instrumentation side effects.

Comment on lines +1 to +5
from typing import Any, Dict, List, Optional

from opentelemetry.sdk.trace import TracerProvider
from pydantic_ai import Agent
from pydantic_ai.models.instrumented import InstrumentationSettings

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent and InstrumentationSettings are imported from pydantic_ai at module load time, so import opik.integrations.pydantic_ai raises ModuleNotFoundError on installs without the optional dependency even when track_pydantic_ai() is never called — should we move those imports inside track_pydantic_ai(), use TYPE_CHECKING/string annotations for type hints, and raise a clear actionable error only when the function is invoked without the dependency?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/src/opik/integrations/pydantic_ai/opik_tracker.py around lines 1-5, remove
the module-level imports of `Agent` and `InstrumentationSettings` from `pydantic_ai`.
Move those imports inside `track_pydantic_ai()` (lazy import), and catch
`ModuleNotFoundError` to raise a clear, actionable error (e.g. "pydantic-ai is required
for this integration — install it with `pip install pydantic-ai`") only when the
function is called. Update any type hints that reference `Agent` or
`InstrumentationSettings` to use `TYPE_CHECKING` guards or string annotations so they
don't require `pydantic_ai` at runtime. Also check
sdks/python/src/opik/integrations/pydantic_ai/__init__.py to ensure `track_pydantic_ai`
is re-exported without eagerly importing `pydantic_ai`, so that `import
opik.integrations.pydantic_ai` succeeds even when the optional dependency is absent.
Ensure `track_pydantic_ai()` continues to work identically once the dependency is
available.

Comment on lines +120 to +123
try:
self._handle_start(span)
except Exception: # per OTel contract on_start must not raise
LOGGER.debug("Failed to start Opik span for Pydantic AI", exc_info=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracing failures are hidden

on_start() and on_end() catch every exception and log at LOGGER.debug(...), so span-processing failures stay invisible in normal deployments while tracing data is dropped for that run — should we raise these to warning or error?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/src/opik/integrations/pydantic_ai/opik_span_processor.py around lines
120-123 in `on_start()` and lines 318-323 in `on_end()`, the code catches all exceptions
and logs them only with `LOGGER.debug(...)`, which makes span-processing failures
effectively invisible and can drop spans/trace data silently. Refactor by changing these
log levels to `warning` or `error` (and ensure `exc_info=True` is preserved), so
operators can see when the integration fails to emit spans. Keep the “must not
raise” behavior for OTEL callbacks, but make the failure observable via the higher
severity logs.

Comment on lines +155 to +162
client = opik.get_global_client()
if client.config.log_start_trace_span:
client.__internal_api__span__(**result.span_data.as_start_parameters)

if result.trace_data is not None:
context_storage.set_trace_data(result.trace_data)
if client.config.log_start_trace_span:
client.__internal_api__trace__(**result.trace_data.as_start_parameters)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaked span context on start failure

context_storage.add_span_data(result.span_data) runs before the synchronous client.__internal_api__span__ / __internal_api__trace__ calls, and if either export raises on_start() swallows the error so _handle_start() never rolls back the pushed state, leaving stale trace context behind for later spans — can we wrap the start-path bookkeeping in a try/finally or otherwise remove it on failure?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/src/opik/integrations/pydantic_ai/opik_span_processor.py around lines
149-168 in the _handle_start() method, the code calls
context_storage.add_span_data(result.span_data) and optionally
context_storage.set_trace_data(result.trace_data) before the synchronous
client.__internal_api__span__ / __internal_api__trace__ exports. If either export
raises, on_start() swallows the exception and the span is never added to self._span_map,
so on_end() can’t pop the shared context—leaving stale trace/span state for later
spans. Refactor _handle_start() to ensure atomic start-path bookkeeping: wrap the client
export calls in try/except (or try/finally) and, on failure, immediately remove any
already-pushed span/trace state via context_storage.pop_span_data(ensure_id=...) /
context_storage.pop_trace_data(ensure_id=...) and do not write to self._span_map unless
the start exports succeeded.

Comment on lines +242 to +243
if metadata.get("opik.provider"):
result["provider"] = metadata["opik.provider"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mismatched override disables pricing

metadata["opik.provider"] is copied into result["provider"] and later fed to validate_and_parse_usage(..., provider=provider), so a mismatched override like "openai" on an Anthropic/Bedrock-shaped payload can take the wrong parser path and leave the span with misleading provider data and no correct cost attribution — should we keep the override as display metadata only, or validate/normalize it before storing it as the cost-tracking provider?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/integrations/pydantic_ai/opik_span_processor.py` around lines
235-252, in `_extract_agent_run_data()`, the code copies `metadata[

Comment on lines +246 to +247
if metadata.get("opik.metadata"):
result["opik_metadata"] = metadata["opik.metadata"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Malformed metadata drops the run

metadata["opik.metadata"] is copied into result["opik_metadata"], so span_data.update(metadata=...)/trace_data.update(metadata=...) pass non-dict values into data_helpers.merge_metadata(), where dict_utils.deepmerge() calls new_data.items() and _handle_end() aborts before client.__internal_api__span__ or client.__internal_api__trace__ runs — should we validate opik.metadata is a dict first?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/src/opik/integrations/pydantic_ai/opik_span_processor.py around lines
235-252 in the `_extract_agent_run_data` logic, the code assigns
`result["opik_metadata"] = metadata["opik.metadata"]` without verifying that
`metadata["opik.metadata"]` is a mapping/dict. Update this branch to check
`isinstance(metadata["opik.metadata"], dict)` (or Mapping) before assigning; if it’s
not a dict, either ignore it or coerce to an empty dict. Then ensure `_handle_end` can
always proceed so `span_data.init_end_time().update(... metadata=...)` and
`trace_data.init_end_time().update(... metadata=...)` never receive a non-mapping value
that would make `merge_metadata()`/`deepmerge()` raise.

Comment on lines +265 to +269
if msg.get("role") == "user":
text = _text_from_parts(msg.get("parts", []))
if text:
result["input"] = {"prompt": text}
break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale prompt captured from history

_extract_agent_run_data() picks the first role == "user" from pydantic_ai.all_messages, so history-carrying runs can store an earlier prompt in trace.input/span.input while the recorded output belongs to the latest turn — should we use the current-run user message, or the last user message in all_messages, instead?

Severity web_search

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/src/opik/integrations/pydantic_ai/opik_span_processor.py around lines
235-284, in the `_extract_agent_run_data` logic that derives `result['input']` from
`pydantic_ai.all_messages`, you currently iterate messages from the start and `break` on
the first `role == 'user'`. Because `all_messages` can include history from prior runs,
this incorrectly captures an earlier prompt as the completed run’s
`trace.input`/`span.input` (while output is taken from the latest model/assistant
messages). Refactor this section to select the current-run user message
instead—preferably by iterating the messages in reverse and taking the first user
message encountered (last user in the list), or if `new_messages` is available in the
span attrs, use that list for the user-input extraction—so `result['input']` matches
the run that just finished.

Comment on lines +148 to +155
prompt = opik.Prompt(name="assistant", prompt="Be concise, reply with one sentence.")

result = agent.run_sync(
'Where does "hello world" come from?',
metadata={
"opik.metadata": {"experiment": "baseline"},
"opik.prompts": [prompt.__internal_api__to_info_dict__()],
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prompt.__internal_api__to_info_dict__() is a private hook, so the user-facing opik.prompts guide depends on an unstable API that will drift when the name changes — should we add a public prompt-info helper and update this page and the duplicate call in apps/opik-documentation/documentation/fern/docs-v2/integrations/pydantic-ai.mdx, as apps/opik-documentation/AGENTS.md suggests?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-documentation/documentation/fern/docs/tracing/integrations/pydantic-ai.mdx
around lines 148-155, the "Enriching traces with metadata and prompts" example uses
prompt.__internal_api__to_info_dict__(), which is a private/internal API. Add a public
prompt-info helper in the Opik SDK (e.g., a method on the Prompt type or a small utility
function) that returns the public "prompt info" representation needed for the
opik.prompts metadata, then update this doc to call that new public helper instead of
the internal method. Alternatively, switch to the documented prompt serialization path
or link to the autogenerated prompt-library docs. Also apply the same fix to the
duplicate call in
apps/opik-documentation/documentation/fern/docs-v2/integrations/pydantic-ai.mdx to keep
both guides consistent, and remove any other occurrences of
__internal_api__to_info_dict__ across the documentation to comply with AGENTS.md.

Comment on lines +151 to +154
metadata={
"opik.metadata": {"experiment": "baseline"},
"opik.prompts": [prompt.__internal_api__to_info_dict__()],
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internal prompt API leaked in docs

The docs example tells users to build opik.prompts with prompt.__internal_api__to_info_dict__(), so it teaches an internal SDK hook instead of a public prompt-to-dict API — should we expose a helper on Prompt or track_pydantic_ai first and point the docs at that, as apps/opik-documentation/AGENTS.md suggests?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-documentation/documentation/fern/docs-v2/integrations/pydantic-ai.mdx around
lines 151-154 (within “Enriching traces with metadata and prompts”), the example
builds `metadata["opik.prompts"]` using the internal
`prompt.__internal_api__to_info_dict__()` serialization hook. Replace this with a
public, user-facing helper by first adding an SDK method/function (e.g.,
`Prompt.to_info_dict()` or a top-level `opik.prompts.to_info_dict(prompt)`) that returns
the same dict format required by the ingestion logic. Then update the docs example to
call the new public helper and confirm the generated dict shape matches what
`track_pydantic_ai`/OTEL ingestion expects. Also search the documentation for any other
`__internal_api__*` usages related to prompt serialization and update them to the public
helper.

@alexkuzmik alexkuzmik left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @aduverger! The integration PR looks very promising, please see some comments I left.

@@ -0,0 +1,41 @@
from opik.integrations.pydantic_ai.opik_span_processor import _token_usage, _try_json

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Even though it's close to a classic unit test, please put it in the tests/library_integration/pydantic_ai for consistency with the other integrations and cleaner dependencies managements.
  2. Avoid accessing private functions/attributes in tests. Either test the existing public API, or, if you need to cover a specific piece of logic, extract it to a separate module. If it deserves to be tested directly, it deserves to be in a more specialized module.

self._span_map: Dict[int, Tuple[SpanData, Optional[TraceData]]] = {}

@staticmethod
def _classify_span(name: str) -> SpanType:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's re-arrange the logic, the class handles too much. We could at least split it into span_parsers.py (or smth like that) and opik_span_processor.py. No need to put all this logic into this class; it reduces cohesion and increases the information context.

return None


def _token_usage(attrs: Dict[str, Any]) -> Optional[Dict[str, int]]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please take a look at opik.llm_usage, let's convert tokens to this format instead of the anthropic one.



def track_pydantic_ai(
agent: Optional[Any] = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use strict types whenever possible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation Python SDK python Pull requests that update Python code 🔴 size/XL tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants