From 55d9a218a7b81f9dfa71aeb42e514a2d7ccae2d2 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Jul 2026 15:55:31 +0200 Subject: [PATCH] fix(tracing): Skip child span creation in streaming path when no current span (AI agents) When span streaming is enabled and there is no current span, AI agent integrations should not create new root segments for child-span operations. This adds a guard check using `sentry_sdk.traces.get_current_span()` before creating spans in the streaming path. Affected integrations: openai_agents, pydantic_ai. Co-Authored-By: Claude Opus 4.6 --- .../openai_agents/spans/agent_workflow.py | 9 +- .../openai_agents/spans/ai_client.py | 23 +-- .../openai_agents/spans/execute_tool.py | 28 +-- .../openai_agents/spans/handoff.py | 3 + .../openai_agents/spans/invoke_agent.py | 107 ++++++------ .../pydantic_ai/spans/ai_client.py | 37 ++-- .../pydantic_ai/spans/execute_tool.py | 41 ++--- .../pydantic_ai/spans/invoke_agent.py | 159 +++++++++--------- 8 files changed, 215 insertions(+), 192 deletions(-) diff --git a/sentry_sdk/integrations/openai_agents/spans/agent_workflow.py b/sentry_sdk/integrations/openai_agents/spans/agent_workflow.py index 758f06db8d..5355722fe2 100644 --- a/sentry_sdk/integrations/openai_agents/spans/agent_workflow.py +++ b/sentry_sdk/integrations/openai_agents/spans/agent_workflow.py @@ -18,9 +18,12 @@ def agent_workflow_span( # Create a transaction or a span if an transaction is already active span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: - span = sentry_sdk.traces.start_span( - name=f"{agent.name} workflow", attributes={"sentry.origin": SPAN_ORIGIN} - ) + span = None + if sentry_sdk.traces.get_current_span() is not None: + span = sentry_sdk.traces.start_span( + name=f"{agent.name} workflow", + attributes={"sentry.origin": SPAN_ORIGIN}, + ) return span diff --git a/sentry_sdk/integrations/openai_agents/spans/ai_client.py b/sentry_sdk/integrations/openai_agents/spans/ai_client.py index f4f02cb674..0de8384aff 100644 --- a/sentry_sdk/integrations/openai_agents/spans/ai_client.py +++ b/sentry_sdk/integrations/openai_agents/spans/ai_client.py @@ -32,14 +32,16 @@ def ai_client_span( span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: - span = sentry_sdk.traces.start_span( - name=f"chat {model_name}", - attributes={ - "sentry.op": OP.GEN_AI_CHAT, - "sentry.origin": SPAN_ORIGIN, - SPANDATA.GEN_AI_OPERATION_NAME: "chat", - }, - ) + span = None + if sentry_sdk.traces.get_current_span() is not None: + span = sentry_sdk.traces.start_span( + name=f"chat {model_name}", + attributes={ + "sentry.op": OP.GEN_AI_CHAT, + "sentry.origin": SPAN_ORIGIN, + SPANDATA.GEN_AI_OPERATION_NAME: "chat", + }, + ) else: span = sentry_sdk.start_span( op=OP.GEN_AI_CHAT, @@ -49,8 +51,9 @@ def ai_client_span( # TODO-anton: remove hardcoded stuff and replace something that also works for embedding and so on span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") - _set_agent_data(span, agent) - _set_input_data(span, get_response_kwargs) + if span is not None: + _set_agent_data(span, agent) + _set_input_data(span, get_response_kwargs) return span diff --git a/sentry_sdk/integrations/openai_agents/spans/execute_tool.py b/sentry_sdk/integrations/openai_agents/spans/execute_tool.py index fd3a430951..93f5ae71a5 100644 --- a/sentry_sdk/integrations/openai_agents/spans/execute_tool.py +++ b/sentry_sdk/integrations/openai_agents/spans/execute_tool.py @@ -20,18 +20,20 @@ def execute_tool_span( ) -> "Union[sentry_sdk.tracing.Span, StreamedSpan]": span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: - span = sentry_sdk.traces.start_span( - name=f"execute_tool {tool.name}", - attributes={ - "sentry.op": OP.GEN_AI_EXECUTE_TOOL, - "sentry.origin": SPAN_ORIGIN, - SPANDATA.GEN_AI_OPERATION_NAME: "execute_tool", - SPANDATA.GEN_AI_TOOL_NAME: tool.name, - SPANDATA.GEN_AI_TOOL_DESCRIPTION: tool.description, - }, - ) - - set_on_span = span.set_attribute + span = None + if sentry_sdk.traces.get_current_span() is not None: + span = sentry_sdk.traces.start_span( + name=f"execute_tool {tool.name}", + attributes={ + "sentry.op": OP.GEN_AI_EXECUTE_TOOL, + "sentry.origin": SPAN_ORIGIN, + SPANDATA.GEN_AI_OPERATION_NAME: "execute_tool", + SPANDATA.GEN_AI_TOOL_NAME: tool.name, + SPANDATA.GEN_AI_TOOL_DESCRIPTION: tool.description, + }, + ) + + set_on_span = span.set_attribute if span is not None else None else: span = sentry_sdk.start_span( op=OP.GEN_AI_EXECUTE_TOOL, @@ -46,7 +48,7 @@ def execute_tool_span( set_on_span = span.set_data - if should_send_default_pii(): + if span is not None and should_send_default_pii(): input = args[1] set_on_span(SPANDATA.GEN_AI_TOOL_INPUT, input) diff --git a/sentry_sdk/integrations/openai_agents/spans/handoff.py b/sentry_sdk/integrations/openai_agents/spans/handoff.py index ea91464afb..4d1c987de5 100644 --- a/sentry_sdk/integrations/openai_agents/spans/handoff.py +++ b/sentry_sdk/integrations/openai_agents/spans/handoff.py @@ -15,6 +15,9 @@ def handoff_span( ) -> None: span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: + if sentry_sdk.traces.get_current_span() is None: + return + with sentry_sdk.traces.start_span( name=f"handoff from {from_agent.name} to {to_agent_name}", attributes={ diff --git a/sentry_sdk/integrations/openai_agents/spans/invoke_agent.py b/sentry_sdk/integrations/openai_agents/spans/invoke_agent.py index c21145ac4a..2d1c65d38b 100644 --- a/sentry_sdk/integrations/openai_agents/spans/invoke_agent.py +++ b/sentry_sdk/integrations/openai_agents/spans/invoke_agent.py @@ -30,14 +30,16 @@ def invoke_agent_span( ) -> "Union[sentry_sdk.tracing.Span, StreamedSpan]": span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: - span = sentry_sdk.traces.start_span( - name=f"invoke_agent {agent.name}", - attributes={ - "sentry.op": OP.GEN_AI_INVOKE_AGENT, - "sentry.origin": SPAN_ORIGIN, - SPANDATA.GEN_AI_OPERATION_NAME: "invoke_agent", - }, - ) + span = None + if sentry_sdk.traces.get_current_span() is not None: + span = sentry_sdk.traces.start_span( + name=f"invoke_agent {agent.name}", + attributes={ + "sentry.op": OP.GEN_AI_INVOKE_AGENT, + "sentry.origin": SPAN_ORIGIN, + SPANDATA.GEN_AI_OPERATION_NAME: "invoke_agent", + }, + ) else: start_span_function = get_start_span_function() span = start_span_function( @@ -49,53 +51,54 @@ def invoke_agent_span( span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") - if should_send_default_pii(): - messages = [] - if agent.instructions: - message = ( - agent.instructions - if isinstance(agent.instructions, str) - else safe_serialize(agent.instructions) - ) - messages.append( - { - "content": [{"text": message, "type": "text"}], - "role": "system", - } - ) - - original_input = kwargs.get("original_input") - if original_input is not None: - message = ( - original_input - if isinstance(original_input, str) - else safe_serialize(original_input) - ) - messages.append( - { - "content": [{"text": message, "type": "text"}], - "role": "user", - } - ) + if span is not None: + if should_send_default_pii(): + messages = [] + if agent.instructions: + message = ( + agent.instructions + if isinstance(agent.instructions, str) + else safe_serialize(agent.instructions) + ) + messages.append( + { + "content": [{"text": message, "type": "text"}], + "role": "system", + } + ) - if len(messages) > 0: - normalized_messages = normalize_message_roles(messages) - client = sentry_sdk.get_client() - scope = sentry_sdk.get_current_scope() - messages_data = ( - truncate_and_annotate_messages(normalized_messages, span, scope) - if should_truncate_gen_ai_input(client.options) - else normalized_messages - ) - if messages_data is not None: - set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - messages_data, - unpack=False, + original_input = kwargs.get("original_input") + if original_input is not None: + message = ( + original_input + if isinstance(original_input, str) + else safe_serialize(original_input) + ) + messages.append( + { + "content": [{"text": message, "type": "text"}], + "role": "user", + } ) - _set_agent_data(span, agent) + if len(messages) > 0: + normalized_messages = normalize_message_roles(messages) + client = sentry_sdk.get_client() + scope = sentry_sdk.get_current_scope() + messages_data = ( + truncate_and_annotate_messages(normalized_messages, span, scope) + if should_truncate_gen_ai_input(client.options) + else normalized_messages + ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + _set_agent_data(span, agent) return span diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 27deb0c55c..21aff52575 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -282,15 +282,17 @@ def ai_client_span( span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: - span = sentry_sdk.traces.start_span( - name=f"chat {model_name}", - attributes={ - "sentry.op": OP.GEN_AI_CHAT, - "sentry.origin": SPAN_ORIGIN, - SPANDATA.GEN_AI_OPERATION_NAME: "chat", - SPANDATA.GEN_AI_RESPONSE_STREAMING: get_is_streaming(), - }, - ) + span = None + if sentry_sdk.traces.get_current_span() is not None: + span = sentry_sdk.traces.start_span( + name=f"chat {model_name}", + attributes={ + "sentry.op": OP.GEN_AI_CHAT, + "sentry.origin": SPAN_ORIGIN, + SPANDATA.GEN_AI_OPERATION_NAME: "chat", + SPANDATA.GEN_AI_RESPONSE_STREAMING: get_is_streaming(), + }, + ) else: span = sentry_sdk.start_span( op=OP.GEN_AI_CHAT, @@ -302,16 +304,17 @@ def ai_client_span( # Set streaming flag from contextvar span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, get_is_streaming()) - _set_agent_data(span, agent) - _set_model_data(span, model, model_settings) + if span is not None: + _set_agent_data(span, agent) + _set_model_data(span, model, model_settings) - # Add available tools if agent is available - agent_obj = agent or get_current_agent() - _set_available_tools(span, agent_obj) + # Add available tools if agent is available + agent_obj = agent or get_current_agent() + _set_available_tools(span, agent_obj) - # Set input messages (full conversation history) - if messages: - _set_input_messages(span, messages) + # Set input messages (full conversation history) + if messages: + _set_input_messages(span, messages) return span diff --git a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py index 7648c1418a..edc00906f2 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py @@ -31,17 +31,19 @@ def execute_tool_span( """ span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: - span = sentry_sdk.traces.start_span( - name=f"execute_tool {tool_name}", - attributes={ - "sentry.op": OP.GEN_AI_EXECUTE_TOOL, - "sentry.origin": SPAN_ORIGIN, - SPANDATA.GEN_AI_OPERATION_NAME: "execute_tool", - SPANDATA.GEN_AI_TOOL_NAME: tool_name, - }, - ) - - set_on_span = span.set_attribute + span = None + if sentry_sdk.traces.get_current_span() is not None: + span = sentry_sdk.traces.start_span( + name=f"execute_tool {tool_name}", + attributes={ + "sentry.op": OP.GEN_AI_EXECUTE_TOOL, + "sentry.origin": SPAN_ORIGIN, + SPANDATA.GEN_AI_OPERATION_NAME: "execute_tool", + SPANDATA.GEN_AI_TOOL_NAME: tool_name, + }, + ) + + set_on_span = span.set_attribute if span is not None else None else: span = sentry_sdk.start_span( op=OP.GEN_AI_EXECUTE_TOOL, @@ -54,16 +56,17 @@ def execute_tool_span( set_on_span = span.set_data - if tool_definition is not None and hasattr(tool_definition, "description"): - set_on_span( - SPANDATA.GEN_AI_TOOL_DESCRIPTION, - tool_definition.description, - ) + if span is not None: + if tool_definition is not None and hasattr(tool_definition, "description"): + set_on_span( + SPANDATA.GEN_AI_TOOL_DESCRIPTION, + tool_definition.description, + ) - _set_agent_data(span, agent) + _set_agent_data(span, agent) - if _should_send_prompts() and tool_args is not None: - set_on_span(SPANDATA.GEN_AI_TOOL_INPUT, safe_serialize(tool_args)) + if _should_send_prompts() and tool_args is not None: + set_on_span(SPANDATA.GEN_AI_TOOL_INPUT, safe_serialize(tool_args)) return span diff --git a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py index f0c68e85ba..73bba0c86a 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py @@ -51,14 +51,16 @@ def invoke_agent_span( span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: - span = sentry_sdk.traces.start_span( - name=f"invoke_agent {name}", - attributes={ - "sentry.op": OP.GEN_AI_INVOKE_AGENT, - "sentry.origin": SPAN_ORIGIN, - SPANDATA.GEN_AI_OPERATION_NAME: "invoke_agent", - }, - ) + span = None + if sentry_sdk.traces.get_current_span() is not None: + span = sentry_sdk.traces.start_span( + name=f"invoke_agent {name}", + attributes={ + "sentry.op": OP.GEN_AI_INVOKE_AGENT, + "sentry.origin": SPAN_ORIGIN, + SPANDATA.GEN_AI_OPERATION_NAME: "invoke_agent", + }, + ) else: span = get_start_span_function()( op=OP.GEN_AI_INVOKE_AGENT, @@ -68,85 +70,86 @@ def invoke_agent_span( span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") - _set_agent_data(span, agent) - _set_model_data(span, model, model_settings) - _set_available_tools(span, agent) - - # Add user prompt and system prompts if available and prompts are enabled - if _should_send_prompts(): - messages = [] - - # Add system prompts (both instructions and system_prompt) - system_texts = [] - - if agent: - # Check for system_prompt - system_prompts = getattr(agent, "_system_prompts", None) or [] - for prompt in system_prompts: - if isinstance(prompt, str): - system_texts.append(prompt) - - # Check for instructions (stored in _instructions) - instructions = getattr(agent, "_instructions", None) - if instructions: - if isinstance(instructions, str): - system_texts.append(instructions) - elif isinstance(instructions, (list, tuple)): - for instr in instructions: - if isinstance(instr, str): - system_texts.append(instr) - elif callable(instr): - # Skip dynamic/callable instructions - pass - - # Add all system texts as system messages - for system_text in system_texts: - messages.append( - { - "content": [{"text": system_text, "type": "text"}], - "role": "system", - } - ) - - # Add user prompt - if user_prompt: - if isinstance(user_prompt, str): + if span is not None: + _set_agent_data(span, agent) + _set_model_data(span, model, model_settings) + _set_available_tools(span, agent) + + # Add user prompt and system prompts if available and prompts are enabled + if _should_send_prompts(): + messages = [] + + # Add system prompts (both instructions and system_prompt) + system_texts = [] + + if agent: + # Check for system_prompt + system_prompts = getattr(agent, "_system_prompts", None) or [] + for prompt in system_prompts: + if isinstance(prompt, str): + system_texts.append(prompt) + + # Check for instructions (stored in _instructions) + instructions = getattr(agent, "_instructions", None) + if instructions: + if isinstance(instructions, str): + system_texts.append(instructions) + elif isinstance(instructions, (list, tuple)): + for instr in instructions: + if isinstance(instr, str): + system_texts.append(instr) + elif callable(instr): + # Skip dynamic/callable instructions + pass + + # Add all system texts as system messages + for system_text in system_texts: messages.append( { - "content": [{"text": user_prompt, "type": "text"}], - "role": "user", + "content": [{"text": system_text, "type": "text"}], + "role": "system", } ) - elif isinstance(user_prompt, list): - # Handle list of user content - content = [] - for item in user_prompt: - if isinstance(item, str): - content.append({"text": item, "type": "text"}) - 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)) - if content: + + # Add user prompt + if user_prompt: + if isinstance(user_prompt, str): messages.append( { - "content": content, + "content": [{"text": user_prompt, "type": "text"}], "role": "user", } ) - - if messages: - normalized_messages = normalize_message_roles(messages) - client = sentry_sdk.get_client() - scope = sentry_sdk.get_current_scope() - messages_data = ( - truncate_and_annotate_messages(normalized_messages, span, scope) - if should_truncate_gen_ai_input(client.options) - else normalized_messages - ) - set_data_normalized( - span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False - ) + elif isinstance(user_prompt, list): + # Handle list of user content + content = [] + for item in user_prompt: + if isinstance(item, str): + content.append({"text": item, "type": "text"}) + 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)) + if content: + messages.append( + { + "content": content, + "role": "user", + } + ) + + if messages: + normalized_messages = normalize_message_roles(messages) + client = sentry_sdk.get_client() + scope = sentry_sdk.get_current_scope() + messages_data = ( + truncate_and_annotate_messages(normalized_messages, span, scope) + if should_truncate_gen_ai_input(client.options) + else normalized_messages + ) + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False + ) return span