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
10 changes: 5 additions & 5 deletions docs/logfire.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,17 +278,17 @@ Note that the OpenTelemetry Semantic Conventions are still experimental and are

### Setting OpenTelemetry SDK providers

By default, the global `TracerProvider` and `EventLoggerProvider` are used. These are set automatically by `logfire.configure()`. They can also be set by the `set_tracer_provider` and `set_event_logger_provider` functions in the OpenTelemetry Python SDK. You can set custom providers with [`InstrumentationSettings`][pydantic_ai.models.instrumented.InstrumentationSettings].
By default, the global `TracerProvider` and `LoggerProvider` are used. These are set automatically by `logfire.configure()`. They can also be set by the `set_tracer_provider` and `set_logger_provider` functions in the OpenTelemetry Python SDK. You can set custom providers with [`InstrumentationSettings`][pydantic_ai.models.instrumented.InstrumentationSettings].

```python {title="instrumentation_settings_providers.py"}
from opentelemetry.sdk._events import EventLoggerProvider
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk.trace import TracerProvider

from pydantic_ai import Agent, InstrumentationSettings

instrumentation_settings = InstrumentationSettings(
tracer_provider=TracerProvider(),
event_logger_provider=EventLoggerProvider(),
logger_provider=LoggerProvider(),
)

agent = Agent('openai:gpt-5', instrument=instrumentation_settings)
Expand Down Expand Up @@ -321,9 +321,9 @@ Agent.instrument_all(instrumentation_settings)

### Excluding prompts and completions

For privacy and security reasons, you may want to monitor your agent's behavior and performance without exposing sensitive user data or proprietary prompts in your observability platform. Pydantic AI allows you to exclude the actual content from instrumentation events while preserving the structural information needed for debugging and monitoring.
For privacy and security reasons, you may want to monitor your agent's behavior and performance without exposing sensitive user data or proprietary prompts in your observability platform. Pydantic AI allows you to exclude the actual content from telemetry while preserving the structural information needed for debugging and monitoring.

When `include_content=False` is set, Pydantic AI will exclude sensitive content from OpenTelemetry events, including user prompts and model completions, tool call arguments and responses, and any other message content.
When `include_content=False` is set, Pydantic AI will exclude sensitive content from telemetry, including user prompts and model completions, tool call arguments and responses, and any other message content.

```python {title="excluding_sensitive_content.py"}
from pydantic_ai import Agent
Expand Down
37 changes: 20 additions & 17 deletions pydantic_ai_slim/pydantic_ai/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import pydantic
import pydantic_core
from genai_prices import calc_price, types as genai_types
from opentelemetry._events import Event # pyright: ignore[reportPrivateImportUsage]
from opentelemetry._logs import LogRecord # pyright: ignore[reportPrivateImportUsage]
from typing_extensions import deprecated

from . import _otel_messages, _utils
Expand Down Expand Up @@ -89,9 +89,9 @@ class SystemPromptPart:
part_kind: Literal['system-prompt'] = 'system-prompt'
"""Part type identifier, this is available on all parts as a discriminator."""

def otel_event(self, settings: InstrumentationSettings) -> Event:
return Event(
'gen_ai.system.message',
def otel_event(self, settings: InstrumentationSettings) -> LogRecord:
return LogRecord(
attributes={'event.name': 'gen_ai.system.message'},
body={'role': 'system', **({'content': self.content} if settings.include_content else {})},
)

Expand Down Expand Up @@ -742,8 +742,8 @@ class UserPromptPart:
part_kind: Literal['user-prompt'] = 'user-prompt'
"""Part type identifier, this is available on all parts as a discriminator."""

def otel_event(self, settings: InstrumentationSettings) -> Event:
content = [{'kind': part.pop('type'), **part} for part in self.otel_message_parts(settings)]
def otel_event(self, settings: InstrumentationSettings) -> LogRecord:
content: Any = [{'kind': part.pop('type'), **part} for part in self.otel_message_parts(settings)]
for part in content:
if part['kind'] == 'binary' and 'content' in part:
part['binary_content'] = part.pop('content')
Expand All @@ -752,7 +752,7 @@ def otel_event(self, settings: InstrumentationSettings) -> Event:
]
if content in ([{'kind': 'text'}], [self.content]):
content = content[0]
return Event('gen_ai.user.message', body={'content': content, 'role': 'user'})
return LogRecord(attributes={'event.name': 'gen_ai.user.message'}, body={'content': content, 'role': 'user'})

def otel_message_parts(self, settings: InstrumentationSettings) -> list[_otel_messages.MessagePart]:
parts: list[_otel_messages.MessagePart] = []
Expand Down Expand Up @@ -829,9 +829,9 @@ def model_response_object(self) -> dict[str, Any]:
else:
return {'return_value': json_content}

def otel_event(self, settings: InstrumentationSettings) -> Event:
return Event(
'gen_ai.tool.message',
def otel_event(self, settings: InstrumentationSettings) -> LogRecord:
return LogRecord(
attributes={'event.name': 'gen_ai.tool.message'},
body={
**({'content': self.content} if settings.include_content else {}),
'role': 'tool',
Expand Down Expand Up @@ -947,12 +947,15 @@ def model_response(self) -> str:
)
return f'{description}\n\nFix the errors and try again.'

def otel_event(self, settings: InstrumentationSettings) -> Event:
def otel_event(self, settings: InstrumentationSettings) -> LogRecord:
if self.tool_name is None:
return Event('gen_ai.user.message', body={'content': self.model_response(), 'role': 'user'})
return LogRecord(
attributes={'event.name': 'gen_ai.user.message'},
body={'content': self.model_response(), 'role': 'user'},
)
else:
return Event(
'gen_ai.tool.message',
return LogRecord(
attributes={'event.name': 'gen_ai.tool.message'},
body={
**({'content': self.model_response()} if settings.include_content else {}),
'role': 'tool',
Expand Down Expand Up @@ -1341,13 +1344,13 @@ def cost(self) -> genai_types.PriceCalculation:
genai_request_timestamp=self.timestamp,
)

def otel_events(self, settings: InstrumentationSettings) -> list[Event]:
def otel_events(self, settings: InstrumentationSettings) -> list[LogRecord]:
"""Return OpenTelemetry events for the response."""
result: list[Event] = []
result: list[LogRecord] = []

def new_event_body():
new_body: dict[str, Any] = {'role': 'assistant'}
ev = Event('gen_ai.assistant.message', body=new_body)
ev = LogRecord(attributes={'event.name': 'gen_ai.assistant.message'}, body=new_body)
result.append(ev)
return new_body

Expand Down
48 changes: 24 additions & 24 deletions pydantic_ai_slim/pydantic_ai/models/instrumented.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
from urllib.parse import urlparse

from genai_prices.types import PriceCalculation
from opentelemetry._events import (
Event, # pyright: ignore[reportPrivateImportUsage]
EventLogger, # pyright: ignore[reportPrivateImportUsage]
EventLoggerProvider, # pyright: ignore[reportPrivateImportUsage]
get_event_logger_provider, # pyright: ignore[reportPrivateImportUsage]
from opentelemetry._logs import (
Logger, # pyright: ignore [reportPrivateImportUsage]
LoggerProvider, # pyright: ignore [reportPrivateImportUsage]
LogRecord, # pyright: ignore [reportPrivateImportUsage]
get_logger_provider, # pyright: ignore [reportPrivateImportUsage]
)
from opentelemetry.metrics import MeterProvider, get_meter_provider
from opentelemetry.trace import Span, Tracer, TracerProvider, get_tracer_provider
Expand Down Expand Up @@ -88,7 +88,7 @@ class InstrumentationSettings:
"""

tracer: Tracer = field(repr=False)
event_logger: EventLogger = field(repr=False)
logger: Logger = field(repr=False)
event_mode: Literal['attributes', 'logs'] = 'attributes'
include_binary_content: bool = True
include_content: bool = True
Expand All @@ -103,7 +103,7 @@ def __init__(
include_content: bool = True,
version: Literal[1, 2, 3] = DEFAULT_INSTRUMENTATION_VERSION,
event_mode: Literal['attributes', 'logs'] = 'attributes',
event_logger_provider: EventLoggerProvider | None = None,
logger_provider: LoggerProvider | None = None,
):
"""Create instrumentation options.

Expand All @@ -120,28 +120,28 @@ def __init__(
version: Version of the data format. This is unrelated to the Pydantic AI package version.
Version 1 is based on the legacy event-based OpenTelemetry GenAI spec
and will be removed in a future release.
The parameters `event_mode` and `event_logger_provider` are only relevant for version 1.
The parameters `event_mode` and `logger_provider` are only relevant for version 1.
Version 2 uses the newer OpenTelemetry GenAI spec and stores messages in the following attributes:
- `gen_ai.system_instructions` for instructions passed to the agent.
- `gen_ai.input.messages` and `gen_ai.output.messages` on model request spans.
- `pydantic_ai.all_messages` on agent run spans.
event_mode: The mode for emitting events in version 1.
If `'attributes'`, events are attached to the span as attributes.
If `'logs'`, events are emitted as OpenTelemetry log-based events.
event_logger_provider: The OpenTelemetry event logger provider to use.
If not provided, the global event logger provider is used.
Calling `logfire.configure()` sets the global event logger provider, so most users don't need this.
logger_provider: The OpenTelemetry logger provider to use.
If not provided, the global logger provider is used.
Calling `logfire.configure()` sets the global logger provider, so most users don't need this.
This is only used if `event_mode='logs'` and `version=1`.
"""
from pydantic_ai import __version__

tracer_provider = tracer_provider or get_tracer_provider()
meter_provider = meter_provider or get_meter_provider()
event_logger_provider = event_logger_provider or get_event_logger_provider()
logger_provider = logger_provider or get_logger_provider()
scope_name = 'pydantic-ai'
self.tracer = tracer_provider.get_tracer(scope_name, __version__)
self.meter = meter_provider.get_meter(scope_name, __version__)
self.event_logger = event_logger_provider.get_event_logger(scope_name, __version__)
self.logger = logger_provider.get_logger(scope_name, __version__)
self.event_mode = event_mode
self.include_binary_content = include_binary_content
self.include_content = include_content
Expand Down Expand Up @@ -180,7 +180,7 @@ def __init__(

def messages_to_otel_events(
self, messages: list[ModelMessage], parameters: ModelRequestParameters | None = None
) -> list[Event]:
) -> list[LogRecord]:
"""Convert a list of model messages to OpenTelemetry events.

Args:
Expand All @@ -190,18 +190,18 @@ def messages_to_otel_events(
Returns:
A list of OpenTelemetry events.
"""
events: list[Event] = []
events: list[LogRecord] = []
instructions = InstrumentedModel._get_instructions(messages, parameters) # pyright: ignore [reportPrivateUsage]
if instructions is not None:
events.append(
Event(
'gen_ai.system.message',
LogRecord(
attributes={'event.name': 'gen_ai.system.message'},
body={**({'content': instructions} if self.include_content else {}), 'role': 'system'},
)
)

for message_index, message in enumerate(messages):
message_events: list[Event] = []
message_events: list[LogRecord] = []
if isinstance(message, ModelRequest):
for part in message.parts:
if hasattr(part, 'otel_event'):
Expand Down Expand Up @@ -250,8 +250,8 @@ def handle_messages(
events = self.messages_to_otel_events(input_messages, parameters)
for event in self.messages_to_otel_events([response], parameters):
events.append(
Event(
'gen_ai.choice',
LogRecord(
attributes={'event.name': 'gen_ai.choice'},
body={
'index': 0,
'message': event.body,
Expand Down Expand Up @@ -299,10 +299,10 @@ def system_instructions_attributes(self, instructions: str | None) -> dict[str,
}
return {}

def _emit_events(self, span: Span, events: list[Event]) -> None:
def _emit_events(self, span: Span, events: list[LogRecord]) -> None:
if self.event_mode == 'logs':
for event in events:
self.event_logger.emit(event)
self.logger.emit(event)
else:
attr_name = 'events'
span.set_attributes(
Expand Down Expand Up @@ -511,11 +511,11 @@ def model_request_parameters_attributes(
return {'model_request_parameters': json.dumps(InstrumentedModel.serialize_any(model_request_parameters))}

@staticmethod
def event_to_dict(event: Event) -> dict[str, Any]:
def event_to_dict(event: LogRecord) -> dict[str, Any]:
if not event.body:
body = {} # pragma: no cover
elif isinstance(event.body, Mapping):
body = event.body # type: ignore
body = event.body
else:
body = {'body': event.body}
return {**body, **(event.attributes or {})}
Expand Down
2 changes: 1 addition & 1 deletion pydantic_ai_slim/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ dependencies = [

[tool.hatch.metadata.hooks.uv-dynamic-versioning.optional-dependencies]
# WARNING if you add optional groups, please update docs/install.md
logfire = ["logfire[httpx]>=3.14.1"]
logfire = ["logfire[httpx]>=4.16.0"]
# Models
openai = ["openai>=1.107.2"]
cohere = ["cohere>=5.18.0; platform_system != 'Emscripten'"]
Expand Down
1 change: 1 addition & 0 deletions tests/models/test_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ def test_all_failed_instrumented(capfire: CaptureLogfire) -> None:
'gen_ai.agent.name': 'agent',
'logfire.msg': 'agent run',
'logfire.span_type': 'span',
'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000',
'pydantic_ai.all_messages': [{'role': 'user', 'parts': [{'type': 'text', 'content': 'hello'}]}],
'logfire.json_schema': {
'type': 'object',
Expand Down
Loading
Loading