diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 3cfae61546..dbeeff6f43 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -747,3 +747,40 @@ def set_conversation_id(conversation_id: str) -> None: """ scope = sentry_sdk.get_current_scope() scope.set_conversation_id(conversation_id) + + +import contextvars + +_gen_ai_agent_stack: "contextvars.ContextVar[Optional[list[Optional[str]]]]" = ( + contextvars.ContextVar("gen_ai_agent_stack", default=None) +) + + +def push_agent_name(agent_name: "Optional[str]") -> None: + """Push an agent name onto the stack.""" + stack = _gen_ai_agent_stack.get() + if stack is None: + stack = [] + else: + stack = stack.copy() + stack.append(agent_name) + _gen_ai_agent_stack.set(stack) + + +def pop_agent_name() -> "Optional[str]": + """Pop an agent name from the stack and return it.""" + stack = _gen_ai_agent_stack.get() + if stack: + stack = stack.copy() + agent_name = stack.pop() + _gen_ai_agent_stack.set(stack) + return agent_name + return None + + +def get_current_agent_name() -> "Optional[str]": + """Get the current agent name (top of stack) without removing it.""" + stack = _gen_ai_agent_stack.get() + if stack: + return stack[-1] + return None diff --git a/sentry_sdk/integrations/langchain.py b/sentry_sdk/integrations/langchain.py index d19d9bbdd5..1d87be89b5 100644 --- a/sentry_sdk/integrations/langchain.py +++ b/sentry_sdk/integrations/langchain.py @@ -650,6 +650,7 @@ def on_tool_start( span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "execute_tool") span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name) + span.set_data(SPANDATA.GEN_AI_TOOL_TYPE, "function") tool_description = serialized.get("description") if tool_description is not None: diff --git a/sentry_sdk/integrations/langgraph.py b/sentry_sdk/integrations/langgraph.py index e5ea12b90a..9927db47ce 100644 --- a/sentry_sdk/integrations/langgraph.py +++ b/sentry_sdk/integrations/langgraph.py @@ -6,6 +6,8 @@ set_data_normalized, normalize_message_roles, truncate_and_annotate_messages, + push_agent_name, + pop_agent_name, ) from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration @@ -168,36 +170,39 @@ def new_invoke(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": if graph_name: span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, graph_name) span.set_data(SPANDATA.GEN_AI_AGENT_NAME, graph_name) - - span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") - - # Store input messages to later compare with output - input_messages = None - if ( - len(args) > 0 - and should_send_default_pii() - and integration.include_prompts - ): - input_messages = _parse_langgraph_messages(args[0]) - if input_messages: - normalized_input_messages = normalize_message_roles(input_messages) - scope = sentry_sdk.get_current_scope() - messages_data = truncate_and_annotate_messages( - normalized_input_messages, span, scope - ) - if messages_data is not None: - set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - messages_data, - unpack=False, + push_agent_name(graph_name) + + try: + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + + # Store input messages to later compare with output + input_messages = None + if ( + len(args) > 0 + and should_send_default_pii() + and integration.include_prompts + ): + input_messages = _parse_langgraph_messages(args[0]) + if input_messages: + normalized_input_messages = normalize_message_roles(input_messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_input_messages, span, scope ) - - result = f(self, *args, **kwargs) - - _set_response_attributes(span, input_messages, result, integration) - - return result + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + result = f(self, *args, **kwargs) + _set_response_attributes(span, input_messages, result, integration) + return result + finally: + if graph_name: + pop_agent_name() return new_invoke @@ -222,35 +227,38 @@ async def new_ainvoke(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": if graph_name: span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, graph_name) span.set_data(SPANDATA.GEN_AI_AGENT_NAME, graph_name) - - span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") - - input_messages = None - if ( - len(args) > 0 - and should_send_default_pii() - and integration.include_prompts - ): - input_messages = _parse_langgraph_messages(args[0]) - if input_messages: - normalized_input_messages = normalize_message_roles(input_messages) - scope = sentry_sdk.get_current_scope() - messages_data = truncate_and_annotate_messages( - normalized_input_messages, span, scope - ) - if messages_data is not None: - set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - messages_data, - unpack=False, + push_agent_name(graph_name) + + try: + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + + input_messages = None + if ( + len(args) > 0 + and should_send_default_pii() + and integration.include_prompts + ): + input_messages = _parse_langgraph_messages(args[0]) + if input_messages: + normalized_input_messages = normalize_message_roles(input_messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_input_messages, span, scope ) - - result = await f(self, *args, **kwargs) - - _set_response_attributes(span, input_messages, result, integration) - - return result + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + result = await f(self, *args, **kwargs) + _set_response_attributes(span, input_messages, result, integration) + return result + finally: + if graph_name: + pop_agent_name() return new_ainvoke diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index a5556b8776..c64eef9f8c 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -12,6 +12,7 @@ normalize_message_roles, truncate_and_annotate_messages, truncate_and_annotate_embedding_inputs, + get_current_agent_name, ) from sentry_sdk.ai._openai_completions_api import ( _is_system_instruction as _is_system_instruction_completions, @@ -226,6 +227,10 @@ def _commmon_set_input_data( # Input attributes: Common set_data_normalized(span, SPANDATA.GEN_AI_SYSTEM, "openai") + agent_name = get_current_agent_name() + if agent_name: + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_name) + # Input attributes: Optional kwargs_keys_to_attributes = { "model": SPANDATA.GEN_AI_REQUEST_MODEL, diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index dc95acad45..c7087f67af 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -20,9 +20,8 @@ get_is_streaming, ) from .utils import ( - _serialize_binary_content_item, - _serialize_image_url_item, _set_usage_data, + _format_messages, ) from typing import TYPE_CHECKING @@ -35,24 +34,16 @@ try: from pydantic_ai.messages import ( BaseToolCallPart, - BaseToolReturnPart, SystemPromptPart, UserPromptPart, TextPart, - ThinkingPart, - BinaryContent, - ImageUrl, ) except ImportError: # Fallback if these classes are not available BaseToolCallPart = None - BaseToolReturnPart = None SystemPromptPart = None UserPromptPart = None TextPart = None - ThinkingPart = None - BinaryContent = None - ImageUrl = None def _transform_system_instructions( @@ -116,70 +107,7 @@ def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> Non ) try: - formatted_messages = [] - - for msg in messages: - if hasattr(msg, "parts"): - for part in msg.parts: - role = "user" - # Use isinstance checks with proper base classes - if SystemPromptPart and isinstance(part, SystemPromptPart): - continue - elif ( - (TextPart and isinstance(part, TextPart)) - or (ThinkingPart and isinstance(part, ThinkingPart)) - or (BaseToolCallPart and isinstance(part, BaseToolCallPart)) - ): - role = "assistant" - elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): - role = "tool" - - content: "List[Dict[str, Any] | str]" = [] - tool_calls = None - tool_call_id = None - - # Handle ToolCallPart (assistant requesting tool use) - if BaseToolCallPart and isinstance(part, BaseToolCallPart): - tool_call_data = {} - if hasattr(part, "tool_name"): - tool_call_data["name"] = part.tool_name - if hasattr(part, "args"): - tool_call_data["arguments"] = safe_serialize(part.args) - if tool_call_data: - tool_calls = [tool_call_data] - # Handle ToolReturnPart (tool result) - elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): - if hasattr(part, "tool_name"): - tool_call_id = part.tool_name - if hasattr(part, "content"): - content.append({"type": "text", "text": str(part.content)}) - # Handle regular content - elif hasattr(part, "content"): - if isinstance(part.content, str): - content.append({"type": "text", "text": part.content}) - elif isinstance(part.content, list): - for item in part.content: - if isinstance(item, str): - content.append({"type": "text", "text": item}) - elif ImageUrl and isinstance(item, ImageUrl): - content.append(_serialize_image_url_item(item)) - elif BinaryContent and isinstance(item, BinaryContent): - content.append(_serialize_binary_content_item(item)) - else: - content.append(safe_serialize(item)) - else: - content.append({"type": "text", "text": str(part.content)}) - # Add message if we have content or tool calls - if content or tool_calls: - message: "Dict[str, Any]" = {"role": role} - if content: - message["content"] = content - if tool_calls: - message["tool_calls"] = tool_calls - if tool_call_id: - message["tool_call_id"] = tool_call_id - formatted_messages.append(message) - + formatted_messages = _format_messages(messages) if formatted_messages: normalized_messages = normalize_message_roles(formatted_messages) scope = sentry_sdk.get_current_scope() diff --git a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py index ee08ca7036..a16afe6731 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py @@ -18,6 +18,7 @@ _serialize_binary_content_item, _serialize_image_url_item, _set_usage_data, + _format_messages, ) from typing import TYPE_CHECKING @@ -142,10 +143,29 @@ def update_invoke_agent_span(span: "sentry_sdk.tracing.Span", result: "Any") -> 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 - ) + if _should_send_prompts(): + messages = None + if hasattr(result, "new_messages") and callable(result.new_messages): + try: + messages = result.new_messages() + except Exception: + pass + elif hasattr(result, "all_messages") and callable(result.all_messages): + try: + messages = result.all_messages() + except Exception: + pass + + formatted_messages = _format_messages(messages) if messages else [] + + if formatted_messages: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, formatted_messages, unpack=False + ) + elif output: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, str(output), unpack=False + ) # Set token usage data if available if hasattr(result, "usage") and callable(result.usage): diff --git a/sentry_sdk/integrations/pydantic_ai/spans/utils.py b/sentry_sdk/integrations/pydantic_ai/spans/utils.py index 70e47dc034..cb488cc6b1 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/utils.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/utils.py @@ -6,6 +6,25 @@ from sentry_sdk.consts import SPANDATA from ..consts import DATA_URL_BASE64_REGEX +try: + from pydantic_ai.messages import ( + BaseToolCallPart, + BaseToolReturnPart, + SystemPromptPart, + TextPart, + ThinkingPart, + BinaryContent, + ImageUrl, + ) +except ImportError: + BaseToolCallPart = None + BaseToolReturnPart = None + SystemPromptPart = None + TextPart = None + ThinkingPart = None + BinaryContent = None + ImageUrl = None + from typing import TYPE_CHECKING @@ -81,3 +100,78 @@ def _set_usage_data( if hasattr(usage, "total_tokens") and usage.total_tokens is not None: span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage.total_tokens) + + +def _format_messages(messages): + """Format PydanticAI messages into Sentry's standardized format.""" + formatted_messages = [] + from sentry_sdk.utils import safe_serialize + + if not messages: + return formatted_messages + + try: + # Handle single message if passed as non-list + messages_list = messages if isinstance(messages, list) else [messages] + + for msg in messages_list: + if hasattr(msg, "parts"): + for part in msg.parts: + role = "user" + if SystemPromptPart and isinstance(part, SystemPromptPart): + continue + elif ( + (TextPart and isinstance(part, TextPart)) + or (ThinkingPart and isinstance(part, ThinkingPart)) + or (BaseToolCallPart and isinstance(part, BaseToolCallPart)) + ): + role = "assistant" + elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): + role = "tool" + + content = [] + tool_calls = None + tool_call_id = None + + if BaseToolCallPart and isinstance(part, BaseToolCallPart): + tool_call_data = {} + if hasattr(part, "tool_name"): + tool_call_data["name"] = part.tool_name + if hasattr(part, "args"): + tool_call_data["arguments"] = safe_serialize(part.args) + if tool_call_data: + tool_calls = [tool_call_data] + elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): + if hasattr(part, "tool_name"): + tool_call_id = part.tool_name + if hasattr(part, "content"): + content.append({"type": "text", "text": str(part.content)}) + elif hasattr(part, "content"): + if isinstance(part.content, str): + content.append({"type": "text", "text": part.content}) + elif isinstance(part.content, list): + for item in part.content: + if isinstance(item, str): + content.append({"type": "text", "text": item}) + elif ImageUrl and isinstance(item, ImageUrl): + content.append(_serialize_image_url_item(item)) + elif BinaryContent and isinstance(item, BinaryContent): + content.append(_serialize_binary_content_item(item)) + else: + content.append(safe_serialize(item)) + else: + content.append({"type": "text", "text": str(part.content)}) + + if content or tool_calls: + message = {"role": role} + if content: + message["content"] = content + if tool_calls: + message["tool_calls"] = tool_calls + if tool_call_id: + message["tool_call_id"] = tool_call_id + formatted_messages.append(message) + except Exception: + pass + + return formatted_messages