Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
af73a61
feat: add gen_ai.agent.name attribute to agent run logging
bitnahian Sep 28, 2025
6454dd4
feat: update span naming convention for agent invocations in v3 instr…
bitnahian Sep 28, 2025
785a928
refactor: extract default instrumentation version into a constant and…
bitnahian Sep 28, 2025
723809f
feat: add instrumentation version field to RunContext for tracking se…
bitnahian Sep 28, 2025
42268f0
feat: add default instrumentation version constant and update RunCont…
bitnahian Sep 28, 2025
14eedd4
feat: add instrumentation version to run context using settings or de…
bitnahian Sep 28, 2025
16e764e
refactor: move DEFAULT_INSTRUMENTATION_VERSION constant to _instrumen…
bitnahian Sep 28, 2025
59d04cc
feat: add instrumentation version to tool execution tracing
bitnahian Sep 28, 2025
233f496
feat: add InstrumentationConfig to manage versioned span names and at…
bitnahian Sep 28, 2025
f375a3c
fix: add gen_ai.agent.name attribute to fallback model test snapshots
bitnahian Sep 28, 2025
7fe2c18
fix: add gen_ai.agent.name field to logfire test assertions
bitnahian Sep 28, 2025
09db4de
feat: add support for version 3 instrumentation settings in logfire t…
bitnahian Sep 28, 2025
eb1229f
fix: add name field to SpanSummary and update test fixtures with span…
bitnahian Sep 29, 2025
872fe1a
feat: add output tool span configuration and improve logfire schema a…
bitnahian Sep 29, 2025
85d4a5d
refactor: consolidate version 2 and 3 test assertions into shared logic
bitnahian Sep 29, 2025
926258d
test: add trace assertions for different instrumentation versions in …
bitnahian Sep 29, 2025
7e3f652
refactor: revert logfire test by removing version parameter
bitnahian Sep 29, 2025
dd4b03c
refactor: consolidate output function test cases and add v2/v3 instru…
bitnahian Sep 29, 2025
c887a8e
fix: add missing gen_ai tool fields and json schema to logfire attrib…
bitnahian Sep 29, 2025
cb3163e
fix: remove redundant version check in test_instructions_with_structu…
bitnahian Sep 29, 2025
037de25
Merge branch 'main' into bitnahian-update-otel-attrs-and-span-names
bitnahian Sep 29, 2025
903410a
refactor: rename InstrumentationConfig class to InstrumentationNames …
bitnahian Oct 1, 2025
2aeb162
refactor: replace next() with list comprehension for output function …
bitnahian Oct 1, 2025
e686564
fix: same
bitnahian Oct 1, 2025
1e04c9a
refactor: rename instrumentation_config variable to instrumentation_n…
bitnahian Oct 1, 2025
62fa945
Merge branch 'main' into bitnahian-update-otel-attrs-and-span-names
alexmojaki Oct 1, 2025
fb7ef44
fix: correct typo in output_function_attributes variable name
bitnahian Oct 1, 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
4 changes: 4 additions & 0 deletions pydantic_ai_slim/pydantic_ai/_agent_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from typing_extensions import TypeVar, assert_never

from pydantic_ai._function_schema import _takes_ctx as is_takes_ctx # type: ignore
from pydantic_ai._instrumentation import DEFAULT_INSTRUMENTATION_VERSION
from pydantic_ai._tool_manager import ToolManager
from pydantic_ai._utils import dataclasses_no_defaults_repr, get_union_args, is_async_callable, run_in_executor
from pydantic_ai.builtin_tools import AbstractBuiltinTool
Expand Down Expand Up @@ -704,6 +705,9 @@ def build_run_context(ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT
tracer=ctx.deps.tracer,
trace_include_content=ctx.deps.instrumentation_settings is not None
and ctx.deps.instrumentation_settings.include_content,
instrumentation_version=ctx.deps.instrumentation_settings.version
if ctx.deps.instrumentation_settings
else DEFAULT_INSTRUMENTATION_VERSION,
run_step=ctx.state.run_step,
tool_call_approved=ctx.state.run_step == 0,
)
Expand Down
95 changes: 95 additions & 0 deletions pydantic_ai_slim/pydantic_ai/_instrumentation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing_extensions import Self

DEFAULT_INSTRUMENTATION_VERSION = 2
"""Default instrumentation version for `InstrumentationSettings`."""


@dataclass(frozen=True)
class InstrumentationConfig:
"""Configuration for instrumentation span names and attributes based on version."""

# Agent run span configuration
agent_run_span_name: str
agent_name_attr: str

# Tool execution span configuration
tool_span_name: str
tool_arguments_attr: str
tool_result_attr: str

# Output Tool execution span configuration
output_tool_span_name: str

@classmethod
def for_version(cls, version: int) -> Self:
"""Create instrumentation configuration for a specific version.

Args:
version: The instrumentation version (1, 2, or 3+)

Returns:
InstrumentationConfig instance with version-appropriate settings
"""
if version <= 2:
return cls(
agent_run_span_name='agent run',
agent_name_attr='agent_name',
tool_span_name='running tool',
tool_arguments_attr='tool_arguments',
tool_result_attr='tool_response',
output_tool_span_name='running output function',
)
else:
return cls(
agent_run_span_name='invoke_agent',
agent_name_attr='gen_ai.agent.name',
tool_span_name='execute_tool', # Will be formatted with tool name
tool_arguments_attr='gen_ai.tool.call.arguments',
tool_result_attr='gen_ai.tool.call.result',
output_tool_span_name='execute_tool',
)

def get_agent_run_span_name(self, agent_name: str) -> str:
"""Get the formatted agent span name.

Args:
agent_name: Name of the agent being executed

Returns:
Formatted span name
"""
if self.agent_run_span_name == 'invoke_agent':
return f'invoke_agent {agent_name}'
return self.agent_run_span_name

def get_tool_span_name(self, tool_name: str) -> str:
"""Get the formatted tool span name.

Args:
tool_name: Name of the tool being executed

Returns:
Formatted span name
"""
if self.tool_span_name == 'execute_tool':
return f'execute_tool {tool_name}'
return self.tool_span_name

def get_output_tool_span_name(self, tool_name: str) -> str:
"""Get the formatted output tool span name.

Args:
tool_name: Name of the tool being executed

Returns:
Formatted span name
"""
if self.output_tool_span_name == 'execute_tool':
return f'execute_tool {tool_name}'
return self.output_tool_span_name
38 changes: 26 additions & 12 deletions pydantic_ai_slim/pydantic_ai/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from pydantic_core import SchemaValidator, to_json
from typing_extensions import Self, TypedDict, TypeVar, assert_never

from pydantic_ai._instrumentation import InstrumentationConfig

from . import _function_schema, _utils, messages as _messages
from ._run_context import AgentDepsT, RunContext
from .exceptions import ModelRetry, ToolRetryError, UserError
Expand Down Expand Up @@ -94,6 +96,7 @@ async def execute_traced_output_function(
ToolRetryError: When wrap_validation_errors is True and a ModelRetry is caught
ModelRetry: When wrap_validation_errors is False and a ModelRetry occurs
"""
instrumentation_config = InstrumentationConfig.for_version(run_context.instrumentation_version)
# Set up span attributes
tool_name = run_context.tool_name or getattr(function_schema.function, '__name__', 'output_function')
attributes = {
Expand All @@ -103,18 +106,29 @@ async def execute_traced_output_function(
if run_context.tool_call_id:
attributes['gen_ai.tool.call.id'] = run_context.tool_call_id
if run_context.trace_include_content:
attributes['tool_arguments'] = to_json(args).decode()
attributes['logfire.json_schema'] = json.dumps(
{
'type': 'object',
'properties': {
'tool_arguments': {'type': 'object'},
'tool_response': {'type': 'object'},
},
}
)
attributes[instrumentation_config.tool_arguments_attr] = to_json(args).decode()

attributes['logfire.json_schema'] = json.dumps(
{
'type': 'object',
'properties': {
**(
{
instrumentation_config.tool_arguments_attr: {'type': 'object'},
instrumentation_config.tool_result_attr: {'type': 'object'},
}
if run_context.trace_include_content
else {}
),
'gen_ai.tool.name': {},
**({'gen_ai.tool.call.id': {}} if run_context.tool_call_id else {}),
},
}
)

with run_context.tracer.start_as_current_span('running output function', attributes=attributes) as span:
with run_context.tracer.start_as_current_span(
instrumentation_config.get_output_tool_span_name(tool_name), attributes=attributes
) as span:
try:
output = await function_schema.call(args, run_context)
except ModelRetry as r:
Expand All @@ -134,7 +148,7 @@ async def execute_traced_output_function(
from .models.instrumented import InstrumentedModel

span.set_attribute(
'tool_response',
instrumentation_config.tool_result_attr,
output if isinstance(output, str) else json.dumps(InstrumentedModel.serialize_any(output)),
)

Expand Down
4 changes: 4 additions & 0 deletions pydantic_ai_slim/pydantic_ai/_run_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from opentelemetry.trace import NoOpTracer, Tracer
from typing_extensions import TypeVar

from pydantic_ai._instrumentation import DEFAULT_INSTRUMENTATION_VERSION

from . import _utils, messages as _messages

if TYPE_CHECKING:
Expand Down Expand Up @@ -36,6 +38,8 @@ class RunContext(Generic[AgentDepsT]):
"""The tracer to use for tracing the run."""
trace_include_content: bool = False
"""Whether to include the content of the messages in the trace."""
instrumentation_version: int = DEFAULT_INSTRUMENTATION_VERSION
"""Instrumentation settings version, if instrumentation is enabled."""
retries: dict[str, int] = field(default_factory=dict)
"""Number of retries for each tool so far."""
tool_call_id: str | None = None
Expand Down
22 changes: 15 additions & 7 deletions pydantic_ai_slim/pydantic_ai/_tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing_extensions import assert_never

from . import messages as _messages
from ._instrumentation import InstrumentationConfig
from ._run_context import AgentDepsT, RunContext
from .exceptions import ModelRetry, ToolRetryError, UnexpectedModelBehavior
from .messages import ToolCallPart
Expand Down Expand Up @@ -115,6 +116,7 @@ async def handle_call(
wrap_validation_errors,
self.ctx.tracer,
self.ctx.trace_include_content,
self.ctx.instrumentation_version,
usage_limits,
)

Expand Down Expand Up @@ -203,15 +205,18 @@ async def _call_tool_traced(
allow_partial: bool,
wrap_validation_errors: bool,
tracer: Tracer,
include_content: bool = False,
include_content: bool,
instrumentation_version: int,
usage_limits: UsageLimits | None = None,
) -> Any:
"""See <https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#execute-tool-span>."""
instrumentation_config = InstrumentationConfig.for_version(instrumentation_version)

span_attributes = {
'gen_ai.tool.name': call.tool_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': call.tool_call_id,
**({'tool_arguments': call.args_as_json_str()} if include_content else {}),
**({instrumentation_config.tool_arguments_attr: call.args_as_json_str()} if include_content else {}),
'logfire.msg': f'running tool: {call.tool_name}',
# add the JSON schema so these attributes are formatted nicely in Logfire
'logfire.json_schema': json.dumps(
Expand All @@ -220,8 +225,8 @@ async def _call_tool_traced(
'properties': {
**(
{
'tool_arguments': {'type': 'object'},
'tool_response': {'type': 'object'},
instrumentation_config.tool_arguments_attr: {'type': 'object'},
instrumentation_config.tool_result_attr: {'type': 'object'},
}
if include_content
else {}
Expand All @@ -232,18 +237,21 @@ async def _call_tool_traced(
}
),
}
with tracer.start_as_current_span('running tool', attributes=span_attributes) as span:
with tracer.start_as_current_span(
instrumentation_config.get_tool_span_name(call.tool_name),
attributes=span_attributes,
) as span:
try:
tool_result = await self._call_tool(call, allow_partial, wrap_validation_errors, usage_limits)
except ToolRetryError as e:
part = e.tool_retry
if include_content and span.is_recording():
span.set_attribute('tool_response', part.model_response())
span.set_attribute(instrumentation_config.tool_result_attr, part.model_response())
raise e

if include_content and span.is_recording():
span.set_attribute(
'tool_response',
instrumentation_config.tool_result_attr,
tool_result
if isinstance(tool_result, str)
else _messages.tool_return_ta.dump_json(tool_result).decode(),
Expand Down
8 changes: 7 additions & 1 deletion pydantic_ai_slim/pydantic_ai/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from pydantic.json_schema import GenerateJsonSchema
from typing_extensions import Self, TypeVar, deprecated

from pydantic_ai._instrumentation import DEFAULT_INSTRUMENTATION_VERSION, InstrumentationConfig
from pydantic_graph import Graph

from .. import (
Expand Down Expand Up @@ -644,11 +645,16 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None:
)

agent_name = self.name or 'agent'
instrumentation_config = InstrumentationConfig.for_version(
instrumentation_settings.version if instrumentation_settings else DEFAULT_INSTRUMENTATION_VERSION
)

run_span = tracer.start_span(
'agent run',
instrumentation_config.get_agent_run_span_name(agent_name),
attributes={
'model_name': model_used.model_name if model_used else 'no-model',
'agent_name': agent_name,
'gen_ai.agent.name': agent_name,
'logfire.msg': f'{agent_name} run',
},
)
Expand Down
6 changes: 4 additions & 2 deletions pydantic_ai_slim/pydantic_ai/models/instrumented.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from opentelemetry.util.types import AttributeValue
from pydantic import TypeAdapter

from pydantic_ai._instrumentation import DEFAULT_INSTRUMENTATION_VERSION

from .. import _otel_messages
from .._run_context import RunContext
from ..messages import (
Expand Down Expand Up @@ -90,7 +92,7 @@ class InstrumentationSettings:
event_mode: Literal['attributes', 'logs'] = 'attributes'
include_binary_content: bool = True
include_content: bool = True
version: Literal[1, 2] = 1
version: Literal[1, 2, 3] = DEFAULT_INSTRUMENTATION_VERSION

def __init__(
self,
Expand All @@ -99,7 +101,7 @@ def __init__(
meter_provider: MeterProvider | None = None,
include_binary_content: bool = True,
include_content: bool = True,
version: Literal[1, 2] = 2,
version: Literal[1, 2, 3] = DEFAULT_INSTRUMENTATION_VERSION,
event_mode: Literal['attributes', 'logs'] = 'attributes',
event_logger_provider: EventLoggerProvider | None = None,
):
Expand Down
3 changes: 3 additions & 0 deletions tests/models/test_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def test_first_failed_instrumented(capfire: CaptureLogfire) -> None:
'attributes': {
'model_name': 'fallback:function:failure_response:,function:success_response:',
'agent_name': 'agent',
'gen_ai.agent.name': 'agent',
'logfire.msg': 'agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 51,
Expand Down Expand Up @@ -269,6 +270,7 @@ async def test_first_failed_instrumented_stream(capfire: CaptureLogfire) -> None
'attributes': {
'model_name': 'fallback:function::failure_response_stream,function::success_response_stream',
'agent_name': 'agent',
'gen_ai.agent.name': 'agent',
'logfire.msg': 'agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 50,
Expand Down Expand Up @@ -376,6 +378,7 @@ def test_all_failed_instrumented(capfire: CaptureLogfire) -> None:
'attributes': {
'model_name': 'fallback:function:failure_response:,function:failure_response:',
'agent_name': 'agent',
'gen_ai.agent.name': 'agent',
'logfire.msg': 'agent run',
'logfire.span_type': 'span',
'pydantic_ai.all_messages': [{'role': 'user', 'parts': [{'type': 'text', 'content': 'hello'}]}],
Expand Down
Loading