Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Examples for teams using a specific framework who want to add Opik.
| [integrations/google_adk/agentic_rag](integrations/google_adk/agentic_rag/) | Trace a Google ADK Agentic RAG router with Opik |
| [integrations/otel/offline_evaluation](integrations/otel/offline_evaluation/) | OTel tracing alongside Opik's offline evaluation workflow |
| [integrations/otel/distributed_tracing](integrations/otel/distributed_tracing/) | Stitch out-of-process tool call spans into a single trace |
| [integrations/pydantic_ai](integrations/pydantic_ai/) | Trace Pydantic AI agent and model spans to Opik with Logfire/OpenTelemetry |

## Guides

Expand Down
1 change: 1 addition & 0 deletions integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ Examples for adding Opik to a specific framework or library. Each folder covers
|---|---|
| [google_adk/](./google_adk/) | Google ADK — Trace an Agentic RAG router with Opik |
| [otel/](./otel/) | OpenTelemetry — send OTel spans to Opik via OTLP |
| [pydantic_ai/](./pydantic_ai/) | Pydantic AI - Trace agent and model spans to Opik with Logfire/OpenTelemetry |
10 changes: 10 additions & 0 deletions integrations/pydantic_ai/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Opik (Comet-hosted). Leave OPIK_API_KEY/OPIK_WORKSPACE unset to run in DRY_RUN.
OPIK_API_KEY=your_opik_api_key_here
OPIK_WORKSPACE=your_workspace

# Self-hosted / enterprise only; defaults to Opik Cloud if unset.
# OPIK_OTLP_ENDPOINT=https://your-opik-host/opik/api/v1/private/otel

# Pydantic AI model configuration.
OPENAI_API_KEY=your_openai_api_key_here
# PYDANTIC_AI_MODEL=openai:gpt-4o-mini
8 changes: 8 additions & 0 deletions integrations/pydantic_ai/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.env
.venv
__pycache__/
*.pyc
.tmp/
*.log
.ruff_cache/
uv.lock
62 changes: 62 additions & 0 deletions integrations/pydantic_ai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Pydantic AI + Opik

Trace a Pydantic AI agent run to Opik using Logfire's OpenTelemetry instrumentation.

## What this does

This example runs a minimal [Pydantic AI](https://ai.pydantic.dev/) agent and sends the agent, model,
and wrapper spans to Opik. It configures Logfire's Pydantic AI instrumentation, registers Opik's
OpenTelemetry span processor, and wraps the agent call in `@opik.track` so the trace has a clean
application entrypoint.

## Prerequisites

This is a `uv` project - dependencies live in `pyproject.toml`.

```bash
uv sync
```

Or, with `pip`:

```bash
pip install opik pydantic-ai "logfire[httpx]"
```

| Environment variable | Required | Description |
|---|---|---|
| `OPIK_API_KEY` | for a live run | Opik API key from [comet.com/opik](https://www.comet.com/opik). Unset -> DRY_RUN. |
| `OPIK_WORKSPACE` | for a live run | Your Opik workspace. Unset -> DRY_RUN. |
| `OPENAI_API_KEY` | for the default live run | Provider key for the default `openai:gpt-4o-mini` model. Unset -> DRY_RUN. |
| `PYDANTIC_AI_MODEL` | no | Pydantic AI model name (default `openai:gpt-4o-mini`). |
| `OPIK_PROJECT_NAME` | no | Project traces are logged to (default `pydantic-ai`). |
| `OPIK_OTLP_ENDPOINT` | no | OTLP endpoint for self-hosted or enterprise Opik. |
| `OTEL_EXPORTER_OTLP_HEADERS` | no | Override OTLP headers when you need custom auth or workspace routing. |

## Running it

```bash
# Dry-run first - no credentials needed.
uv run pydantic-ai-opik --dry-run

# Full run - set credentials, then the same command calls the model and logs to Opik.
export OPIK_API_KEY="<your-key>"
export OPIK_WORKSPACE="<your-workspace>"
export OPENAI_API_KEY="<your-openai-key>"

uv run pydantic-ai-opik

# or run it the way CI does:
bash run.sh
```

## How it works

1. **Configure OTLP** - `main.py` sets the Opik OTLP endpoint, headers, and project name from
environment variables when you run live.
2. **Instrument Pydantic AI** - `logfire.instrument_pydantic_ai()` creates spans for the agent run
and model call.
3. **Merge spans into one Opik trace** - `OpikSpanProcessor` links Logfire/OpenTelemetry spans to
the active `@opik.track` trace so the Opik UI shows one nested trace for the full call.
4. **Stay safe by default** - missing Opik or provider credentials switch the script into DRY_RUN,
which prints the planned model, project, thread ID, and question without making network calls.
126 changes: 126 additions & 0 deletions integrations/pydantic_ai/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Trace a Pydantic AI agent run to Opik."""

import argparse
import os
import sys

OPIK_API_KEY = os.environ.get("OPIK_API_KEY")
OPIK_WORKSPACE = os.environ.get("OPIK_WORKSPACE")
OPIK_PROJECT_NAME = os.environ.get("OPIK_PROJECT_NAME", "pydantic-ai")
OPIK_OTLP_ENDPOINT = os.environ.get(
"OPIK_OTLP_ENDPOINT",
"https://www.comet.com/opik/api/v1/private/otel",
)

DEFAULT_MODEL = os.environ.get("PYDANTIC_AI_MODEL", "openai:gpt-4o-mini")
DEFAULT_QUESTION = "Where does the phrase hello world come from?"

PROVIDER_KEY_ENV = {
"anthropic": "ANTHROPIC_API_KEY",
"google-gla": "GEMINI_API_KEY",
"google-vertex": "GOOGLE_APPLICATION_CREDENTIALS",
"groq": "GROQ_API_KEY",
"mistral": "MISTRAL_API_KEY",
"openai": "OPENAI_API_KEY",
}


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--question", default=DEFAULT_QUESTION, help="Question to send to the Pydantic AI agent."
)
parser.add_argument(
"--model", default=DEFAULT_MODEL, help="Pydantic AI model name, such as openai:gpt-4o-mini."
)
parser.add_argument(
"--thread-id", default="pydantic-ai-demo", help="Thread ID attached to the Opik trace."
)
parser.add_argument(
"--dry-run", action="store_true", help="Print what would happen; do not call an LLM or Opik."
)
return parser


def provider_key_env(model: str) -> str | None:
provider = model.split(":", maxsplit=1)[0]
return PROVIDER_KEY_ENV.get(provider)


def missing_live_inputs(model: str) -> list[str]:
missing = []
if not OPIK_API_KEY:
missing.append("OPIK_API_KEY")
if not OPIK_WORKSPACE:
missing.append("OPIK_WORKSPACE")

provider_env = provider_key_env(model)
if provider_env and not os.environ.get(provider_env):
missing.append(provider_env)

return missing


def configure_opik_otlp() -> None:
os.environ.setdefault("OTEL_EXPORTER_OTLP_ENDPOINT", OPIK_OTLP_ENDPOINT)
os.environ.setdefault("OTEL_METRICS_EXPORTER", "none")
os.environ.setdefault(
"OTEL_EXPORTER_OTLP_HEADERS",
f"Authorization={OPIK_API_KEY},Comet-Workspace={OPIK_WORKSPACE},projectName={OPIK_PROJECT_NAME}",
)


def run_dry(question: str, model: str, thread_id: str, missing: list[str]) -> None:
print("[DRY RUN] would trace a Pydantic AI agent call to Opik")
print(f" model: {model}")
print(f" project: {OPIK_PROJECT_NAME}")
print(f" thread_id: {thread_id}")
print(f" question: {question}")
if missing:
print(f" missing for live run: {', '.join(missing)}")


def run_live(question: str, model: str, thread_id: str) -> str:
import logfire
import opik
from opik.integrations.otel import OpikSpanProcessor
from pydantic_ai import Agent

configure_opik_otlp()
logfire.configure(
send_to_logfire=False,
additional_span_processors=[OpikSpanProcessor()],
)
logfire.instrument_pydantic_ai()

agent = Agent(
model,
instructions="Be concise and answer in one sentence.",
)

@opik.track(project_name=OPIK_PROJECT_NAME)
def answer_question(user_question: str) -> str:
with logfire.span("pydantic_ai_example", thread_id=thread_id):
result = agent.run_sync(user_question)
return result.output

return answer_question(question)


def main() -> int:
args = build_parser().parse_args()
missing = missing_live_inputs(args.model)

if args.dry_run or missing:
if missing and not args.dry_run:
print("Required credentials are not set - running in DRY_RUN.", file=sys.stderr)
run_dry(args.question, args.model, args.thread_id, missing)
return 0

print(run_live(args.question, args.model, args.thread_id))
return 0


if __name__ == "__main__":
raise SystemExit(main())
34 changes: 34 additions & 0 deletions integrations/pydantic_ai/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[project]
name = "pydantic-ai-opik"
version = "0.1.0"
description = "Trace Pydantic AI agent calls to Opik with Logfire and OpenTelemetry."
readme = "README.md"
requires-python = ">=3.12,<3.14"
dependencies = [
"logfire[httpx]",
"opik>=2.0",
"pydantic-ai",
]

[project.scripts]
pydantic-ai-opik = "main:main"

[dependency-groups]
dev = [
"ruff",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

# WHY: single top-level module (not a src/ package) - point hatchling at the one file.
[tool.hatch.build.targets.wheel]
include = ["main.py"]

[tool.ruff]
line-length = 110
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
8 changes: 8 additions & 0 deletions integrations/pydantic_ai/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -e

# Entry point CI runs for this example. With no Opik/provider credentials it falls
# back to DRY_RUN and exits 0; with credentials set it logs a trace to Opik.
uv sync
export OPIK_PROJECT_NAME="pydantic-ai"
uv run pydantic-ai-opik