Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
81cce2f
Scrubbing prompts and completions, sensitive data from logs
adtyavrdhn Jun 18, 2025
5989ad2
Removing comment
adtyavrdhn Jun 18, 2025
c7cd7b8
Adding test for all events
adtyavrdhn Jun 18, 2025
810b1db
Adding example to docs
adtyavrdhn Jun 18, 2025
8ebee59
renaming to include content
adtyavrdhn Jun 19, 2025
16a9920
typo
adtyavrdhn Jun 19, 2025
1d2ba54
Removing content key altogether + refactoring to remove duplicated code
adtyavrdhn Jun 19, 2025
15384f3
Adding test for binary_content as well
adtyavrdhn Jun 19, 2025
94e08dd
Retaining content for the kinds of data
adtyavrdhn Jun 20, 2025
08189b1
Removing args from tool calls when include_content is false
adtyavrdhn Jun 20, 2025
3aa29dc
Merge branch 'main' into scrubbing_sensitive_content
adtyavrdhn Jun 20, 2025
77e58d6
Mistakes in docs
adtyavrdhn Jun 21, 2025
58297f5
Merge branch 'scrubbing_sensitive_content' of https://github.com/adty…
adtyavrdhn Jun 21, 2025
1031dab
Adding test to check include_content behaviour in the logfire spans
adtyavrdhn Jun 21, 2025
c9a83e5
Adding test to check include_content behaviour in the logfire spans
adtyavrdhn Jun 21, 2025
ac4c40a
Adding desc in logs
adtyavrdhn Jun 21, 2025
fb5361a
Resolving comments
adtyavrdhn Jun 23, 2025
c330c86
Asserting only one such span exists
adtyavrdhn Jun 23, 2025
1c75cfc
Adding that tool calls and args will also be excluded from the
adtyavrdhn Jun 23, 2025
4f3ecd1
Update pydantic_ai_slim/pydantic_ai/models/instrumented.py
adtyavrdhn Jun 25, 2025
7e81361
Update tests/test_logfire.py
adtyavrdhn Jun 25, 2025
f592149
Merge branch 'main' into scrubbing_sensitive_content
adtyavrdhn Jun 25, 2025
70a4161
Fix
adtyavrdhn Jun 25, 2025
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
12 changes: 12 additions & 0 deletions docs/logfire.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,15 @@ agent = Agent('gpt-4o', instrument=instrumentation_settings)
# or to instrument all agents:
Agent.instrument_all(instrumentation_settings)
```

### Excluding sensitive content

```python {title="excluding_sensitive_content.py.py"}
from pydantic_ai.agent import Agent, InstrumentationSettings

instrumentation_settings = InstrumentationSettings(include_content=False)

agent = Agent('gpt-4o', instrument=instrumentation_settings)
# or to instrument all agents:
Agent.instrument_all(instrumentation_settings)
```
8 changes: 7 additions & 1 deletion pydantic_ai_slim/pydantic_ai/_agent_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

if TYPE_CHECKING:
from .mcp import MCPServer
from .models.instrumented import InstrumentationSettings

__all__ = (
'GraphAgentState',
Expand Down Expand Up @@ -112,6 +113,7 @@ class GraphAgentDeps(Generic[DepsT, OutputDataT]):
default_retries: int

tracer: Tracer
instrumentation_settings: InstrumentationSettings | None = None

prepare_tools: ToolsPrepareFunc[DepsT] | None = None

Expand Down Expand Up @@ -696,6 +698,10 @@ async def process_function_tools( # noqa C901

user_parts: list[_messages.UserPromptPart] = []

include_tool_args = (
ctx.deps.instrumentation_settings is not None and ctx.deps.instrumentation_settings.include_content
)

# Run all tool tasks in parallel
results_by_index: dict[int, _messages.ModelRequestPart] = {}
with ctx.deps.tracer.start_as_current_span(
Expand All @@ -706,7 +712,7 @@ async def process_function_tools( # noqa C901
},
):
tasks = [
asyncio.create_task(tool.run(call, run_context, ctx.deps.tracer), name=call.tool_name)
asyncio.create_task(tool.run(call, run_context, ctx.deps.tracer, include_tool_args), name=call.tool_name)
for tool, call in calls_to_run
]

Expand Down
1 change: 1 addition & 0 deletions pydantic_ai_slim/pydantic_ai/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None:
tracer=tracer,
prepare_tools=self._prepare_tools,
get_instructions=get_instructions,
instrumentation_settings=instrumentation_settings,
)
start_node = _agent_graph.UserPromptNode[AgentDepsT](
user_prompt=user_prompt,
Expand Down
31 changes: 20 additions & 11 deletions pydantic_ai_slim/pydantic_ai/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,11 @@ 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', body={'content': self.content, 'role': 'system'})
def otel_event(self, settings: InstrumentationSettings) -> Event:
return Event(
'gen_ai.system.message',
body={'role': 'system', **({'content': self.content} if settings.include_content else {})},
)

__repr__ = _utils.dataclasses_no_defaults_repr

Expand Down Expand Up @@ -362,12 +365,12 @@ def otel_event(self, settings: InstrumentationSettings) -> Event:
content = []
for part in self.content:
if isinstance(part, str):
content.append(part)
content.append(part if settings.include_content else {'kind': 'text'})
elif isinstance(part, (ImageUrl, AudioUrl, DocumentUrl, VideoUrl)):
content.append({'kind': part.kind, 'url': part.url})
content.append({'kind': part.kind, **({'url': part.url} if settings.include_content else {})})
elif isinstance(part, BinaryContent):
converted_part = {'kind': part.kind, 'media_type': part.media_type}
if settings.include_binary_content:
if settings.include_content and settings.include_binary_content:
converted_part['binary_content'] = base64.b64encode(part.data).decode()
content.append(converted_part)
else:
Expand Down Expand Up @@ -414,10 +417,15 @@ def model_response_object(self) -> dict[str, Any]:
else:
return {'return_value': tool_return_ta.dump_python(self.content, mode='json')}

def otel_event(self, _settings: InstrumentationSettings) -> Event:
def otel_event(self, settings: InstrumentationSettings) -> Event:
return Event(
'gen_ai.tool.message',
body={'content': self.content, 'role': 'tool', 'id': self.tool_call_id, 'name': self.tool_name},
body={
**({'content': self.content} if settings.include_content else {}),
'role': 'tool',
'id': self.tool_call_id,
'name': self.tool_name,
},
)

__repr__ = _utils.dataclasses_no_defaults_repr
Expand Down Expand Up @@ -473,14 +481,14 @@ def model_response(self) -> str:
description = f'{len(self.content)} validation errors: {json_errors.decode()}'
return f'{description}\n\nFix the errors and try again.'

def otel_event(self, _settings: InstrumentationSettings) -> Event:
def otel_event(self, settings: InstrumentationSettings) -> Event:
if self.tool_name is None:
return Event('gen_ai.user.message', body={'content': self.model_response(), 'role': 'user'})
else:
return Event(
'gen_ai.tool.message',
body={
'content': self.model_response(),
**({'content': self.model_response()} if settings.include_content else {}),
'role': 'tool',
'id': self.tool_call_id,
'name': self.tool_name,
Expand Down Expand Up @@ -657,7 +665,7 @@ class ModelResponse:
vendor_id: str | None = None
"""Vendor ID as specified by the model provider. This can be used to track the specific request to the model."""

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

Expand All @@ -683,7 +691,8 @@ def new_event_body():
elif isinstance(part, TextPart):
if body.get('content'):
body = new_event_body()
body['content'] = part.content
if settings.include_content:
body['content'] = part.content
Comment on lines 692 to +695
Copy link
Contributor

Choose a reason for hiding this comment

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

This implies that the number of events produced will depend on whether content is included if multiple tool calls are made in a single message.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Trying to understand what you mean exactly

Would you say the below test captures what you are trying to imply:


def test_otel_events_consistency_with_include_content():
    """Test that the number of OpenTelemetry events is consistent regardless of include_content setting."""

    # Create a response with multiple tool calls followed by text
    response = ModelResponse(parts=[
        ToolCallPart('tool1', {'arg1': 'value1'}, 'call_1'),
        ToolCallPart('tool2', {'arg2': 'value2'}, 'call_2'),
        TextPart('Some text response')
    ])

    settings_with_content = InstrumentationSettings(include_content=True)
    events_with_content = response.otel_events(settings_with_content)

    settings_without_content = InstrumentationSettings(include_content=False)
    events_without_content = response.otel_events(settings_without_content)

    assert len(events_with_content) == len(events_without_content), (
        f"Event count differs: with_content={len(events_with_content)}, "
        f"without_content={len(events_without_content)}"
    )

Copy link
Contributor

@alexmojaki alexmojaki Jun 23, 2025

Choose a reason for hiding this comment

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

sorry, i didn't think this through properly. the difference would be if a message had multiple text parts. but i don't think this happens in practice, so it isn't worth worrying about.

Copy link

Choose a reason for hiding this comment

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

the difference would be if a message had multiple text parts. but i don't think this happens in practice

I'm not caught up on this whole conversation, but I do think a message with multiple text parts could be realistic. For example, multiple text parts could be used to separate different inputs:

  • text part: compare these two paragraphs, which is better?
  • text part: paragraph 1
  • text part: paragraph 2

Of course this could be done by concatenating all text into a single part.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, I get it. Something like this would cause this issue.

    response = ModelResponse(parts=[
        TextPart('Some text response'),
        ToolCallPart('tool2', {'arg2': 'value2'}, 'call_2'),
        TextPart('Some more text response'),
        ToolCallPart('tool1', {'arg1': 'value1'}, 'call_1'),
        TextPart('Even more text response'),
    ])


return result

Expand Down
5 changes: 4 additions & 1 deletion pydantic_ai_slim/pydantic_ai/models/instrumented.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def __init__(
meter_provider: MeterProvider | None = None,
event_logger_provider: EventLoggerProvider | None = None,
include_binary_content: bool = True,
include_content: bool = True,
):
"""Create instrumentation options.

Expand All @@ -109,6 +110,7 @@ def __init__(
Calling `logfire.configure()` sets the global event logger provider, so most users don't need this.
This is only used if `event_mode='logs'`.
include_binary_content: Whether to include binary content in the instrumentation events.
include_content: Whether to include prompt and completion messages in the instrumentation events.
"""
from pydantic_ai import __version__

Expand All @@ -121,6 +123,7 @@ def __init__(
self.event_logger = event_logger_provider.get_event_logger(scope_name, __version__)
self.event_mode = event_mode
self.include_binary_content = include_binary_content
self.include_content = include_content

# As specified in the OpenTelemetry GenAI metrics spec:
# https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/#metric-gen_aiclienttokenusage
Expand Down Expand Up @@ -161,7 +164,7 @@ def messages_to_otel_events(self, messages: list[ModelMessage]) -> list[Event]:
if hasattr(part, 'otel_event'):
message_events.append(part.otel_event(self))
elif isinstance(message, ModelResponse): # pragma: no branch
message_events = message.otel_events()
message_events = message.otel_events(self)
for event in message_events:
event.attributes = {
'gen_ai.message.index': message_index,
Expand Down
5 changes: 3 additions & 2 deletions pydantic_ai_slim/pydantic_ai/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ async def run(
message: _messages.ToolCallPart,
run_context: RunContext[AgentDepsT],
tracer: Tracer,
include_tool_args: bool = False,
) -> _messages.ToolReturnPart | _messages.RetryPromptPart:
"""Run the tool function asynchronously.

Expand All @@ -383,14 +384,14 @@ async def run(
'gen_ai.tool.name': self.name,
# NOTE: this means `gen_ai.tool.call.id` will be included even if it was generated by pydantic-ai
'gen_ai.tool.call.id': message.tool_call_id,
'tool_arguments': message.args_as_json_str(),
**({'tool_arguments': message.args_as_json_str()} if include_tool_args else {}),
'logfire.msg': f'running tool: {self.name}',
# add the JSON schema so these attributes are formatted nicely in Logfire
'logfire.json_schema': json.dumps(
{
'type': 'object',
'properties': {
'tool_arguments': {'type': 'object'},
**({'tool_arguments': {'type': 'object'}} if include_tool_args else {}),
'gen_ai.tool.name': {},
'gen_ai.tool.call.id': {},
},
Expand Down
85 changes: 85 additions & 0 deletions tests/models/test_instrumented.py
Original file line number Diff line number Diff line change
Expand Up @@ -827,3 +827,88 @@ def test_messages_to_otel_events_without_binary_content(document_content: Binary
}
]
)


def test_messages_without_content(document_content: BinaryContent):
messages: list[ModelMessage] = [
ModelRequest(parts=[SystemPromptPart('system_prompt')]),
ModelResponse(parts=[TextPart('text1')]),
ModelRequest(
parts=[
UserPromptPart(
content=[
'user_prompt1',
VideoUrl('https://example.com/video.mp4'),
ImageUrl('https://example.com/image.png'),
AudioUrl('https://example.com/audio.mp3'),
DocumentUrl('https://example.com/document.pdf'),
document_content,
]
)
]
),
ModelResponse(parts=[TextPart('text2'), ToolCallPart(tool_name='my_tool', args={'a': 13, 'b': 4})]),
ModelRequest(parts=[ToolReturnPart('tool', 'tool_return_content', 'tool_call_1')]),
ModelRequest(parts=[RetryPromptPart('retry_prompt', tool_name='tool', tool_call_id='tool_call_2')]),
ModelRequest(parts=[UserPromptPart(content=['user_prompt2', document_content])]),
]
settings = InstrumentationSettings(include_content=False)
assert [InstrumentedModel.event_to_dict(e) for e in settings.messages_to_otel_events(messages)] == snapshot(
[
{
'role': 'system',
'gen_ai.message.index': 0,
'event.name': 'gen_ai.system.message',
},
{
'role': 'assistant',
'gen_ai.message.index': 1,
'event.name': 'gen_ai.assistant.message',
},
{
'content': [
{'kind': 'text'},
{'kind': 'video-url'},
{'kind': 'image-url'},
{'kind': 'audio-url'},
{'kind': 'document-url'},
{'kind': 'binary', 'media_type': 'application/pdf'},
],
'role': 'user',
'gen_ai.message.index': 2,
'event.name': 'gen_ai.user.message',
},
{
'role': 'assistant',
'tool_calls': [
{
'id': IsStr(),
'type': 'function',
'function': {'name': 'my_tool', 'arguments': {'a': 13, 'b': 4}},
}
],
'gen_ai.message.index': 3,
'event.name': 'gen_ai.assistant.message',
},
{
'role': 'tool',
'id': 'tool_call_1',
'name': 'tool',
'gen_ai.message.index': 4,
'event.name': 'gen_ai.tool.message',
},
{
'role': 'tool',
'id': 'tool_call_2',
'name': 'tool',
'gen_ai.message.index': 5,
'event.name': 'gen_ai.tool.message',
},
{
'content': [{'kind': 'text'}, {'kind': 'binary', 'media_type': 'application/pdf'}],
'role': 'user',
'gen_ai.message.index': 6,
'event.name': 'gen_ai.user.message',
},
]
)