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
98 changes: 71 additions & 27 deletions sentry_sdk/integrations/pydantic_ai/spans/ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
)

if TYPE_CHECKING:
from typing import Any, Dict, List, Union
from typing import Any, Dict, List, Optional, Union

from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore

Expand Down Expand Up @@ -61,6 +61,22 @@
ImageUrl = None


def _nonempty_content(value: "Any") -> "Optional[str]":
if value is None:
return None
return str(value) or None


def _tool_call_arguments(args: "Any") -> "Any":
"""Prefer structured args for OTEL tool_call parts; keep raw string if not JSON."""
if not isinstance(args, str) or not args.strip():
return args
try:
return json.loads(args)
except ValueError:
return args


def _transform_system_instructions(
permanent_instructions: "list[SystemPromptPart]",
current_instructions: "list[str]",
Expand Down Expand Up @@ -171,7 +187,10 @@ def _set_input_messages(
tool_call_id = part.tool_name
if hasattr(part, "content"):
content.append({"type": "text", "text": str(part.content)})
# Handle regular content
elif ThinkingPart and isinstance(part, ThinkingPart):
reasoning = _nonempty_content(part.content)
if reasoning is not None:
content.append({"type": "reasoning", "text": reasoning})
elif hasattr(part, "content"):
if isinstance(part.content, str):
content.append({"type": "text", "text": part.content})
Expand Down Expand Up @@ -231,31 +250,56 @@ def _set_output_data(
set_on_span(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_name)

try:
# Extract text from ModelResponse
if hasattr(response, "parts"):
texts = []
tool_calls = []

for part in response.parts:
if TextPart and isinstance(part, TextPart) and hasattr(part, "content"):
texts.append(part.content)
elif BaseToolCallPart and isinstance(part, BaseToolCallPart):
tool_call_data = {
"type": "function",
}
if hasattr(part, "tool_name"):
tool_call_data["name"] = part.tool_name
if hasattr(part, "args"):
tool_call_data["arguments"] = safe_serialize(part.args)
tool_calls.append(tool_call_data)

if texts:
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, texts)

if tool_calls:
set_on_span(
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(tool_calls)
)
# OTEL gen_ai.output.messages (text + reasoning + tool_call).
if not hasattr(response, "parts"):
return

message_parts = [] # type: List[Dict[str, Any]]

for part in response.parts:
if ThinkingPart and isinstance(part, ThinkingPart):
reasoning = _nonempty_content(getattr(part, "content", None))
if reasoning is not None:
message_parts.append({"type": "reasoning", "content": reasoning})
continue

if TextPart and isinstance(part, TextPart) and hasattr(part, "content"):
text = _nonempty_content(part.content)
if text is not None:
message_parts.append({"type": "text", "content": text})
continue

if not (BaseToolCallPart and isinstance(part, BaseToolCallPart)):
continue

name = getattr(part, "tool_name", None)
if not name:
continue

otel_tool_call = {"type": "tool_call", "name": name} # type: Dict[str, Any]
tool_call_id = getattr(part, "tool_call_id", None) or getattr(
part, "id", None
)
if tool_call_id:
otel_tool_call["id"] = tool_call_id
if hasattr(part, "args"):
otel_tool_call["arguments"] = _tool_call_arguments(part.args)
message_parts.append(otel_tool_call)

if message_parts:
output_message = {
"role": "assistant",
"parts": message_parts,
} # type: Dict[str, Any]
finish_reason = getattr(response, "finish_reason", None)
if finish_reason is not None:
output_message["finish_reason"] = str(finish_reason)
set_data_normalized(
span,
SPANDATA.GEN_AI_OUTPUT_MESSAGES,
[output_message],
unpack=False,
)

except Exception:
# If we fail to format output, just skip it
Expand Down
11 changes: 9 additions & 2 deletions sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,17 @@ def update_invoke_agent_span(
# Extract output from result
output = getattr(result, "output", None)

# Set response text if prompts are enabled
if _should_send_prompts() and output:
set_data_normalized(
span, SPANDATA.GEN_AI_RESPONSE_TEXT, str(output), unpack=False
span,
SPANDATA.GEN_AI_OUTPUT_MESSAGES,
[
{
"role": "assistant",
"parts": [{"type": "text", "content": str(output)}],
}
],
unpack=False,
)

# Set model name from response if available
Expand Down
25 changes: 24 additions & 1 deletion sentry_sdk/integrations/pydantic_ai/spans/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from sentry_sdk.traces import StreamedSpan

if TYPE_CHECKING:
from typing import Any, Dict, Union
from typing import Any, Dict, Optional, Union

from pydantic_ai.usage import RequestUsage, RunUsage # type: ignore

Expand Down Expand Up @@ -85,3 +85,26 @@ def _set_usage_data(

if hasattr(usage, "total_tokens") and usage.total_tokens is not None:
set_on_span(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage.total_tokens)

reasoning_tokens = _reasoning_token_count(usage)
if reasoning_tokens is not None:
set_on_span(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, reasoning_tokens)


def _reasoning_token_count(usage: "Any") -> "Optional[int]":
"""Provider-specific reasoning token counts live on usage.details or the object itself."""
details = getattr(usage, "details", None)
if isinstance(details, dict):
for key in (
"reasoning_tokens",
"thinking_tokens",
"thoughts_token_count",
"thoughts_tokens",
"output_tokens.reasoning",
):
value = details.get(key)
if isinstance(value, int) and value > 0:
return value

value = getattr(usage, "reasoning_tokens", None)
return value if isinstance(value, int) and value > 0 else None
Loading
Loading