Skip to content

Commit 87ad3d5

Browse files
alexmojakiCopilot
andauthored
Replace OTel events with logs (#3641)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent ad6e799 commit 87ad3d5

File tree

7 files changed

+114
-130
lines changed

7 files changed

+114
-130
lines changed

docs/logfire.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,17 +278,17 @@ Note that the OpenTelemetry Semantic Conventions are still experimental and are
278278

279279
### Setting OpenTelemetry SDK providers
280280

281-
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].
281+
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].
282282

283283
```python {title="instrumentation_settings_providers.py"}
284-
from opentelemetry.sdk._events import EventLoggerProvider
284+
from opentelemetry.sdk._logs import LoggerProvider
285285
from opentelemetry.sdk.trace import TracerProvider
286286

287287
from pydantic_ai import Agent, InstrumentationSettings
288288

289289
instrumentation_settings = InstrumentationSettings(
290290
tracer_provider=TracerProvider(),
291-
event_logger_provider=EventLoggerProvider(),
291+
logger_provider=LoggerProvider(),
292292
)
293293

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

322322
### Excluding prompts and completions
323323

324-
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.
324+
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.
325325

326-
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.
326+
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.
327327

328328
```python {title="excluding_sensitive_content.py"}
329329
from pydantic_ai import Agent

pydantic_ai_slim/pydantic_ai/messages.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import pydantic
1515
import pydantic_core
1616
from genai_prices import calc_price, types as genai_types
17-
from opentelemetry._events import Event # pyright: ignore[reportPrivateImportUsage]
17+
from opentelemetry._logs import LogRecord # pyright: ignore[reportPrivateImportUsage]
1818
from typing_extensions import deprecated
1919

2020
from . import _otel_messages, _utils
@@ -92,9 +92,9 @@ class SystemPromptPart:
9292
part_kind: Literal['system-prompt'] = 'system-prompt'
9393
"""Part type identifier, this is available on all parts as a discriminator."""
9494

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

@@ -746,8 +746,8 @@ class UserPromptPart:
746746
part_kind: Literal['user-prompt'] = 'user-prompt'
747747
"""Part type identifier, this is available on all parts as a discriminator."""
748748

749-
def otel_event(self, settings: InstrumentationSettings) -> Event:
750-
content = [{'kind': part.pop('type'), **part} for part in self.otel_message_parts(settings)]
749+
def otel_event(self, settings: InstrumentationSettings) -> LogRecord:
750+
content: Any = [{'kind': part.pop('type'), **part} for part in self.otel_message_parts(settings)]
751751
for part in content:
752752
if part['kind'] == 'binary' and 'content' in part:
753753
part['binary_content'] = part.pop('content')
@@ -756,7 +756,7 @@ def otel_event(self, settings: InstrumentationSettings) -> Event:
756756
]
757757
if content in ([{'kind': 'text'}], [self.content]):
758758
content = content[0]
759-
return Event('gen_ai.user.message', body={'content': content, 'role': 'user'})
759+
return LogRecord(attributes={'event.name': 'gen_ai.user.message'}, body={'content': content, 'role': 'user'})
760760

761761
def otel_message_parts(self, settings: InstrumentationSettings) -> list[_otel_messages.MessagePart]:
762762
parts: list[_otel_messages.MessagePart] = []
@@ -833,9 +833,9 @@ def model_response_object(self) -> dict[str, Any]:
833833
else:
834834
return {'return_value': json_content}
835835

836-
def otel_event(self, settings: InstrumentationSettings) -> Event:
837-
return Event(
838-
'gen_ai.tool.message',
836+
def otel_event(self, settings: InstrumentationSettings) -> LogRecord:
837+
return LogRecord(
838+
attributes={'event.name': 'gen_ai.tool.message'},
839839
body={
840840
**({'content': self.content} if settings.include_content else {}),
841841
'role': 'tool',
@@ -951,12 +951,15 @@ def model_response(self) -> str:
951951
)
952952
return f'{description}\n\nFix the errors and try again.'
953953

954-
def otel_event(self, settings: InstrumentationSettings) -> Event:
954+
def otel_event(self, settings: InstrumentationSettings) -> LogRecord:
955955
if self.tool_name is None:
956-
return Event('gen_ai.user.message', body={'content': self.model_response(), 'role': 'user'})
956+
return LogRecord(
957+
attributes={'event.name': 'gen_ai.user.message'},
958+
body={'content': self.model_response(), 'role': 'user'},
959+
)
957960
else:
958-
return Event(
959-
'gen_ai.tool.message',
961+
return LogRecord(
962+
attributes={'event.name': 'gen_ai.tool.message'},
960963
body={
961964
**({'content': self.model_response()} if settings.include_content else {}),
962965
'role': 'tool',
@@ -1359,13 +1362,13 @@ def cost(self) -> genai_types.PriceCalculation:
13591362
genai_request_timestamp=self.timestamp,
13601363
)
13611364

1362-
def otel_events(self, settings: InstrumentationSettings) -> list[Event]:
1365+
def otel_events(self, settings: InstrumentationSettings) -> list[LogRecord]:
13631366
"""Return OpenTelemetry events for the response."""
1364-
result: list[Event] = []
1367+
result: list[LogRecord] = []
13651368

13661369
def new_event_body():
13671370
new_body: dict[str, Any] = {'role': 'assistant'}
1368-
ev = Event('gen_ai.assistant.message', body=new_body)
1371+
ev = LogRecord(attributes={'event.name': 'gen_ai.assistant.message'}, body=new_body)
13691372
result.append(ev)
13701373
return new_body
13711374

pydantic_ai_slim/pydantic_ai/models/instrumented.py

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
from urllib.parse import urlparse
1111

1212
from genai_prices.types import PriceCalculation
13-
from opentelemetry._events import (
14-
Event, # pyright: ignore[reportPrivateImportUsage]
15-
EventLogger, # pyright: ignore[reportPrivateImportUsage]
16-
EventLoggerProvider, # pyright: ignore[reportPrivateImportUsage]
17-
get_event_logger_provider, # pyright: ignore[reportPrivateImportUsage]
13+
from opentelemetry._logs import (
14+
Logger, # pyright: ignore [reportPrivateImportUsage]
15+
LoggerProvider, # pyright: ignore [reportPrivateImportUsage]
16+
LogRecord, # pyright: ignore [reportPrivateImportUsage]
17+
get_logger_provider, # pyright: ignore [reportPrivateImportUsage]
1818
)
1919
from opentelemetry.metrics import MeterProvider, get_meter_provider
2020
from opentelemetry.trace import Span, Tracer, TracerProvider, get_tracer_provider
@@ -88,7 +88,7 @@ class InstrumentationSettings:
8888
"""
8989

9090
tracer: Tracer = field(repr=False)
91-
event_logger: EventLogger = field(repr=False)
91+
logger: Logger = field(repr=False)
9292
event_mode: Literal['attributes', 'logs'] = 'attributes'
9393
include_binary_content: bool = True
9494
include_content: bool = True
@@ -103,7 +103,7 @@ def __init__(
103103
include_content: bool = True,
104104
version: Literal[1, 2, 3] = DEFAULT_INSTRUMENTATION_VERSION,
105105
event_mode: Literal['attributes', 'logs'] = 'attributes',
106-
event_logger_provider: EventLoggerProvider | None = None,
106+
logger_provider: LoggerProvider | None = None,
107107
):
108108
"""Create instrumentation options.
109109
@@ -120,28 +120,28 @@ def __init__(
120120
version: Version of the data format. This is unrelated to the Pydantic AI package version.
121121
Version 1 is based on the legacy event-based OpenTelemetry GenAI spec
122122
and will be removed in a future release.
123-
The parameters `event_mode` and `event_logger_provider` are only relevant for version 1.
123+
The parameters `event_mode` and `logger_provider` are only relevant for version 1.
124124
Version 2 uses the newer OpenTelemetry GenAI spec and stores messages in the following attributes:
125125
- `gen_ai.system_instructions` for instructions passed to the agent.
126126
- `gen_ai.input.messages` and `gen_ai.output.messages` on model request spans.
127127
- `pydantic_ai.all_messages` on agent run spans.
128128
event_mode: The mode for emitting events in version 1.
129129
If `'attributes'`, events are attached to the span as attributes.
130130
If `'logs'`, events are emitted as OpenTelemetry log-based events.
131-
event_logger_provider: The OpenTelemetry event logger provider to use.
132-
If not provided, the global event logger provider is used.
133-
Calling `logfire.configure()` sets the global event logger provider, so most users don't need this.
131+
logger_provider: The OpenTelemetry logger provider to use.
132+
If not provided, the global logger provider is used.
133+
Calling `logfire.configure()` sets the global logger provider, so most users don't need this.
134134
This is only used if `event_mode='logs'` and `version=1`.
135135
"""
136136
from pydantic_ai import __version__
137137

138138
tracer_provider = tracer_provider or get_tracer_provider()
139139
meter_provider = meter_provider or get_meter_provider()
140-
event_logger_provider = event_logger_provider or get_event_logger_provider()
140+
logger_provider = logger_provider or get_logger_provider()
141141
scope_name = 'pydantic-ai'
142142
self.tracer = tracer_provider.get_tracer(scope_name, __version__)
143143
self.meter = meter_provider.get_meter(scope_name, __version__)
144-
self.event_logger = event_logger_provider.get_event_logger(scope_name, __version__)
144+
self.logger = logger_provider.get_logger(scope_name, __version__)
145145
self.event_mode = event_mode
146146
self.include_binary_content = include_binary_content
147147
self.include_content = include_content
@@ -180,7 +180,7 @@ def __init__(
180180

181181
def messages_to_otel_events(
182182
self, messages: list[ModelMessage], parameters: ModelRequestParameters | None = None
183-
) -> list[Event]:
183+
) -> list[LogRecord]:
184184
"""Convert a list of model messages to OpenTelemetry events.
185185
186186
Args:
@@ -190,18 +190,18 @@ def messages_to_otel_events(
190190
Returns:
191191
A list of OpenTelemetry events.
192192
"""
193-
events: list[Event] = []
193+
events: list[LogRecord] = []
194194
instructions = InstrumentedModel._get_instructions(messages, parameters) # pyright: ignore [reportPrivateUsage]
195195
if instructions is not None:
196196
events.append(
197-
Event(
198-
'gen_ai.system.message',
197+
LogRecord(
198+
attributes={'event.name': 'gen_ai.system.message'},
199199
body={**({'content': instructions} if self.include_content else {}), 'role': 'system'},
200200
)
201201
)
202202

203203
for message_index, message in enumerate(messages):
204-
message_events: list[Event] = []
204+
message_events: list[LogRecord] = []
205205
if isinstance(message, ModelRequest):
206206
for part in message.parts:
207207
if hasattr(part, 'otel_event'):
@@ -250,8 +250,8 @@ def handle_messages(
250250
events = self.messages_to_otel_events(input_messages, parameters)
251251
for event in self.messages_to_otel_events([response], parameters):
252252
events.append(
253-
Event(
254-
'gen_ai.choice',
253+
LogRecord(
254+
attributes={'event.name': 'gen_ai.choice'},
255255
body={
256256
'index': 0,
257257
'message': event.body,
@@ -299,10 +299,10 @@ def system_instructions_attributes(self, instructions: str | None) -> dict[str,
299299
}
300300
return {}
301301

302-
def _emit_events(self, span: Span, events: list[Event]) -> None:
302+
def _emit_events(self, span: Span, events: list[LogRecord]) -> None:
303303
if self.event_mode == 'logs':
304304
for event in events:
305-
self.event_logger.emit(event)
305+
self.logger.emit(event)
306306
else:
307307
attr_name = 'events'
308308
span.set_attributes(
@@ -511,11 +511,11 @@ def model_request_parameters_attributes(
511511
return {'model_request_parameters': json.dumps(InstrumentedModel.serialize_any(model_request_parameters))}
512512

513513
@staticmethod
514-
def event_to_dict(event: Event) -> dict[str, Any]:
514+
def event_to_dict(event: LogRecord) -> dict[str, Any]:
515515
if not event.body:
516516
body = {} # pragma: no cover
517517
elif isinstance(event.body, Mapping):
518-
body = event.body # type: ignore
518+
body = event.body
519519
else:
520520
body = {'body': event.body}
521521
return {**body, **(event.attributes or {})}

pydantic_ai_slim/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ dependencies = [
6565

6666
[tool.hatch.metadata.hooks.uv-dynamic-versioning.optional-dependencies]
6767
# WARNING if you add optional groups, please update docs/install.md
68-
logfire = ["logfire[httpx]>=3.14.1"]
68+
logfire = ["logfire[httpx]>=4.16.0"]
6969
# Models
7070
openai = ["openai>=2.11.0"]
7171
cohere = ["cohere>=5.18.0; platform_system != 'Emscripten'"]

tests/models/test_fallback.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ def test_all_failed_instrumented(capfire: CaptureLogfire) -> None:
416416
'gen_ai.agent.name': 'agent',
417417
'logfire.msg': 'agent run',
418418
'logfire.span_type': 'span',
419+
'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000',
419420
'pydantic_ai.all_messages': [{'role': 'user', 'parts': [{'type': 'text', 'content': 'hello'}]}],
420421
'logfire.json_schema': {
421422
'type': 'object',

0 commit comments

Comments
 (0)