From 392c9131de972a84f61a65b7934dfe328e4bfd0e Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Thu, 22 May 2025 23:58:35 +0530 Subject: [PATCH 01/16] correct min version for instrumentation --- agentops/instrumentation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentops/instrumentation/__init__.py b/agentops/instrumentation/__init__.py index 70017743b..99b088d1f 100644 --- a/agentops/instrumentation/__init__.py +++ b/agentops/instrumentation/__init__.py @@ -169,7 +169,7 @@ class InstrumentorConfig(TypedDict): "agents": { "module_name": "agentops.instrumentation.openai_agents", "class_name": "OpenAIAgentsInstrumentor", - "min_version": "0.1.0", + "min_version": "0.0.1", }, } From 9c02c3421dbcd84b434dc410051ff8f44b6287b1 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 01:20:59 +0530 Subject: [PATCH 02/16] get tool calls in the agents sdk --- .../openai_agents/attributes/common.py | 46 +++++++++++++++---- .../openai_agents/instrumentor.py | 46 +++++++++++++++---- 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index b06691021..022e8e039 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -7,8 +7,8 @@ from typing import Any from agentops.logging import logger -from agentops.semconv import AgentAttributes, WorkflowAttributes, SpanAttributes, InstrumentationAttributes - +from agentops.semconv import AgentAttributes, WorkflowAttributes, SpanAttributes, InstrumentationAttributes, ToolAttributes, AgentOpsSpanKindValues, ToolStatus + from agentops.instrumentation.common import AttributeMap, _extract_attributes_from_mapping from agentops.instrumentation.common.attributes import get_common_attributes from agentops.instrumentation.common.objects import get_uploaded_object_attributes @@ -31,12 +31,15 @@ } -# Attribute mapping for FunctionSpanData -FUNCTION_SPAN_ATTRIBUTES: AttributeMap = { - AgentAttributes.AGENT_NAME: "name", - WorkflowAttributes.WORKFLOW_INPUT: "input", - WorkflowAttributes.FINAL_OUTPUT: "output", - AgentAttributes.FROM_AGENT: "from_agent", +# Attribute mapping for FunctionSpanData - Corrected for Tool Semantics +# FunctionSpanData has: name (str), input (Any), output (Any), from_agent (str, optional) +CORRECTED_FUNCTION_TOOL_ATTRIBUTES: AttributeMap = { + ToolAttributes.TOOL_NAME: "name", + ToolAttributes.TOOL_PARAMETERS: "input", # Will be serialized + ToolAttributes.TOOL_RESULT: "output", # Will be serialized + # 'from_agent' could be mapped to a custom attribute if needed, or ignored for pure tool spans + # For now, let's focus on standard tool attributes. + # AgentAttributes.FROM_AGENT: "from_agent", # Example if we wanted to keep it } @@ -126,8 +129,33 @@ def get_function_span_attributes(span_data: Any) -> AttributeMap: Returns: Dictionary of attributes for function span """ - attributes = _extract_attributes_from_mapping(span_data, FUNCTION_SPAN_ATTRIBUTES) + attributes = _extract_attributes_from_mapping(span_data, CORRECTED_FUNCTION_TOOL_ATTRIBUTES) attributes.update(get_common_attributes()) + attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.TOOL.value + + # Determine tool status based on presence of error in span_data (if available) or assume success + # The main SDK's Span object has an 'error' field. If span_data itself doesn't, + # this status might be better set by the exporter which has access to the full SDK Span. + # For now, assuming success if no error attribute is directly on span_data. + # A more robust way would be to check the OTel span's status if this handler is called *after* error processing. + if hasattr(span_data, 'error') and span_data.error: + attributes[ToolAttributes.TOOL_STATUS] = ToolStatus.FAILED.value + else: + # This might be premature if output isn't available yet (on_span_start) + # but the exporter handles separate start/end, so this is for the full attribute set. + if hasattr(span_data, 'output') and span_data.output is not None: + attributes[ToolAttributes.TOOL_STATUS] = ToolStatus.SUCCEEDED.value + else: + # If called on start, or if output is None without error, it's executing or status unknown yet. + # The exporter should ideally set this based on the OTel span status later. + # For now, we won't set a default status here if output is not yet available. + pass + + + # If from_agent is available on span_data, add it. + if hasattr(span_data, 'from_agent') and span_data.from_agent: + attributes[f"{AgentAttributes.AGENT}.calling_tool.name"] = str(span_data.from_agent) + return attributes diff --git a/agentops/instrumentation/openai_agents/instrumentor.py b/agentops/instrumentation/openai_agents/instrumentor.py index 2684bb50d..87275becc 100644 --- a/agentops/instrumentation/openai_agents/instrumentor.py +++ b/agentops/instrumentation/openai_agents/instrumentor.py @@ -21,10 +21,14 @@ """ from typing import Collection +from opentelemetry import trace # Needed for tracer from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore from agentops.logging import logger from agentops.instrumentation.openai_agents.processor import OpenAIAgentsProcessor from agentops.instrumentation.openai_agents.exporter import OpenAIAgentsExporter +# For Runner wrappers +from agentops.instrumentation.common.wrappers import wrap, unwrap +from agentops.instrumentation.openai_agents.runner_wrappers import AGENT_RUNNER_WRAP_CONFIGS class OpenAIAgentsInstrumentor(BaseInstrumentor): @@ -41,24 +45,37 @@ def instrumentation_dependencies(self) -> Collection[str]: def _instrument(self, **kwargs): """Instrument the OpenAI Agents SDK.""" tracer_provider = kwargs.get("tracer_provider") + # Obtain a tracer instance. The instrumentor name can be more specific. + # The version should ideally come from the package version. + self._tracer = trace.get_tracer( + "agentops.instrumentation.openai_agents", # More specific tracer name + "0.1.0" # Placeholder version, ideally package version + ) + try: - self._exporter = OpenAIAgentsExporter(tracer_provider=tracer_provider) + # 1. Setup Processor for internal SDK traces + self._exporter = OpenAIAgentsExporter(tracer_provider=tracer_provider) # Exporter uses the tracer_provider self._processor = OpenAIAgentsProcessor( exporter=self._exporter, ) - - # Replace the default processor with our processor - from agents import set_trace_processors # type: ignore - from agents.tracing.processors import default_processor # type: ignore - - # Store reference to default processor for later restoration + from agents import set_trace_processors # type: ignore + from agents.tracing.processors import default_processor # type: ignore self._default_processor = default_processor() set_trace_processors([self._processor]) logger.debug("Replaced default processor with OpenAIAgentsProcessor in OpenAI Agents SDK") + # 2. Apply Runner method wrappers for Agent Run/Turn parent spans + for config in AGENT_RUNNER_WRAP_CONFIGS: + try: + logger.debug(f"Applying wrapper for {config.package}.{config.class_name}.{config.method_name}") + wrap(config, self._tracer) # Use the instrumentor's tracer + except Exception as e_wrap: + logger.error(f"Failed to apply wrapper for {config}: {e_wrap}", exc_info=True) + logger.info("Applied Runner method wrappers for AgentOps.") + except Exception as e: - logger.warning(f"Failed to instrument OpenAI Agents SDK: {e}") + logger.warning(f"Failed to instrument OpenAI Agents SDK: {e}", exc_info=True) def _uninstrument(self, **kwargs): """Remove instrumentation from OpenAI Agents SDK.""" @@ -76,8 +93,17 @@ def _uninstrument(self, **kwargs): set_trace_processors([self._default_processor]) self._default_processor = None self._processor = None - self._exporter = None + self._exporter = None # type: ignore + + # Remove Runner method wrappers + for config in AGENT_RUNNER_WRAP_CONFIGS: + try: + logger.debug(f"Removing wrapper for {config.package}.{config.class_name}.{config.method_name}") + unwrap(config) + except Exception as e_unwrap: + logger.error(f"Failed to remove wrapper for {config}: {e_unwrap}", exc_info=True) + logger.info("Removed Runner method wrappers for AgentOps.") logger.info("Successfully removed OpenAI Agents SDK instrumentation") except Exception as e: - logger.warning(f"Failed to uninstrument OpenAI Agents SDK: {e}") + logger.warning(f"Failed to uninstrument OpenAI Agents SDK: {e}", exc_info=True) From 92d9a862acb85104f2e349256ba03506a2e39df8 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 01:23:37 +0530 Subject: [PATCH 03/16] record all the entities and the llm prompts --- .../openai_agents/attributes/common.py | 231 ++++++++- .../instrumentation/openai_agents/context.py | 6 + .../instrumentation/openai_agents/exporter.py | 85 ++-- .../openai_agents/instrumentor.py | 456 ++++++++++++++++-- 4 files changed, 699 insertions(+), 79 deletions(-) create mode 100644 agentops/instrumentation/openai_agents/context.py diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index 022e8e039..f357972a2 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -7,13 +7,25 @@ from typing import Any from agentops.logging import logger -from agentops.semconv import AgentAttributes, WorkflowAttributes, SpanAttributes, InstrumentationAttributes, ToolAttributes, AgentOpsSpanKindValues, ToolStatus - +from agentops.semconv import ( + AgentAttributes, + WorkflowAttributes, + SpanAttributes, + InstrumentationAttributes, + ToolAttributes, + AgentOpsSpanKindValues, + ToolStatus, +) +from agentops.helpers import safe_serialize # Import safe_serialize + from agentops.instrumentation.common import AttributeMap, _extract_attributes_from_mapping from agentops.instrumentation.common.attributes import get_common_attributes from agentops.instrumentation.common.objects import get_uploaded_object_attributes from agentops.instrumentation.openai.attributes.response import get_response_response_attributes from agentops.instrumentation.openai_agents import LIBRARY_NAME, LIBRARY_VERSION + +# Import full_prompt_contextvar from the new context module +from ..context import full_prompt_contextvar from agentops.instrumentation.openai_agents.attributes.model import ( get_model_attributes, get_model_config_attributes, @@ -35,8 +47,8 @@ # FunctionSpanData has: name (str), input (Any), output (Any), from_agent (str, optional) CORRECTED_FUNCTION_TOOL_ATTRIBUTES: AttributeMap = { ToolAttributes.TOOL_NAME: "name", - ToolAttributes.TOOL_PARAMETERS: "input", # Will be serialized - ToolAttributes.TOOL_RESULT: "output", # Will be serialized + ToolAttributes.TOOL_PARAMETERS: "input", # Will be serialized + ToolAttributes.TOOL_RESULT: "output", # Will be serialized # 'from_agent' could be mapped to a custom attribute if needed, or ignored for pure tool spans # For now, let's focus on standard tool attributes. # AgentAttributes.FROM_AGENT: "from_agent", # Example if we wanted to keep it @@ -82,6 +94,79 @@ WorkflowAttributes.WORKFLOW_INPUT: "input", } +from typing import List, Dict, Optional # Ensure these are imported if not already at the top + +# (Make sure logger, safe_serialize, MessageAttributes are imported at the top of the file) + + +def _get_llm_messages_attributes(messages: Optional[List[Dict]], attribute_base: str) -> AttributeMap: + """ + Extracts attributes from a list of message dictionaries (e.g., prompts or completions). + Uses the attribute_base to format the specific attribute keys. + """ + attributes: AttributeMap = {} + if not messages: + logger.debug( + f"[_get_llm_messages_attributes] No messages provided for base: {attribute_base}. Returning empty attributes." + ) + return attributes + if not isinstance(messages, list): + logger.warning( + f"[_get_llm_messages_attributes] Expected a list of messages for base '{attribute_base}', got {type(messages)}. Value: {safe_serialize(messages)}. Returning empty." + ) + return attributes + + for i, msg_dict in enumerate(messages): + if isinstance(msg_dict, dict): + role = msg_dict.get("role") + content = msg_dict.get("content") + name = msg_dict.get("name") # For named messages if ever used + tool_calls = msg_dict.get("tool_calls") # For assistant messages with tool calls + tool_call_id = msg_dict.get("tool_call_id") # For tool_call_output messages + + # Common role and content + if role: + attributes[f"{attribute_base}.{i}.role"] = str(role) + if content is not None: # Ensure content can be an empty string but not None without being set + attributes[f"{attribute_base}.{i}.content"] = safe_serialize(content) + + # Optional name for some roles + if name: + attributes[f"{attribute_base}.{i}.name"] = str(name) + + # Tool calls (specific to assistant messages) + if tool_calls and isinstance(tool_calls, list): + for tc_idx, tc_dict in enumerate(tool_calls): + if isinstance(tc_dict, dict): + tc_id = tc_dict.get("id") + tc_type = tc_dict.get("type") # e.g., "function" + tc_function_data = tc_dict.get("function") + + if tc_function_data and isinstance(tc_function_data, dict): + tc_func_name = tc_function_data.get("name") + tc_func_args = tc_function_data.get("arguments") + + base_tool_call_key_formatted = f"{attribute_base}.{i}.tool_calls.{tc_idx}" + if tc_id: + attributes[f"{base_tool_call_key_formatted}.id"] = str(tc_id) + if tc_type: + attributes[f"{base_tool_call_key_formatted}.type"] = str(tc_type) + if tc_func_name: + attributes[f"{base_tool_call_key_formatted}.function.name"] = str(tc_func_name) + if tc_func_args is not None: # Arguments can be an empty string + attributes[f"{base_tool_call_key_formatted}.function.arguments"] = safe_serialize( + tc_func_args + ) + + # Tool call ID (specific to tool_call_output messages) + if tool_call_id: # This is for the result of a tool call + attributes[f"{attribute_base}.{i}.tool_call_id"] = str(tool_call_id) + else: + # If a message is not a dict, serialize its representation + attributes[f"{attribute_base}.{i}.content"] = safe_serialize(msg_dict) + + return attributes + def get_common_instrumentation_attributes() -> AttributeMap: """Get common instrumentation attributes for the OpenAI Agents instrumentation. @@ -138,12 +223,12 @@ def get_function_span_attributes(span_data: Any) -> AttributeMap: # this status might be better set by the exporter which has access to the full SDK Span. # For now, assuming success if no error attribute is directly on span_data. # A more robust way would be to check the OTel span's status if this handler is called *after* error processing. - if hasattr(span_data, 'error') and span_data.error: + if hasattr(span_data, "error") and span_data.error: attributes[ToolAttributes.TOOL_STATUS] = ToolStatus.FAILED.value else: # This might be premature if output isn't available yet (on_span_start) # but the exporter handles separate start/end, so this is for the full attribute set. - if hasattr(span_data, 'output') and span_data.output is not None: + if hasattr(span_data, "output") and span_data.output is not None: attributes[ToolAttributes.TOOL_STATUS] = ToolStatus.SUCCEEDED.value else: # If called on start, or if output is None without error, it's executing or status unknown yet. @@ -151,12 +236,10 @@ def get_function_span_attributes(span_data: Any) -> AttributeMap: # For now, we won't set a default status here if output is not yet available. pass - # If from_agent is available on span_data, add it. - if hasattr(span_data, 'from_agent') and span_data.from_agent: + if hasattr(span_data, "from_agent") and span_data.from_agent: attributes[f"{AgentAttributes.AGENT}.calling_tool.name"] = str(span_data.from_agent) - return attributes @@ -198,9 +281,67 @@ def get_response_span_attributes(span_data: Any) -> AttributeMap: attributes = _extract_attributes_from_mapping(span_data, RESPONSE_SPAN_ATTRIBUTES) attributes.update(get_common_attributes()) - if span_data.response: - attributes.update(get_response_response_attributes(span_data.response)) + prompt_attributes_set = False + + # Read full prompt from contextvar (set by instrumentor's wrapper) + full_prompt_from_context = full_prompt_contextvar.get() + if full_prompt_from_context: + logger.debug( + f"[get_response_span_attributes] Found full_prompt_from_context: {safe_serialize(full_prompt_from_context)}" + ) + attributes.update(_get_llm_messages_attributes(full_prompt_from_context, "gen_ai.prompt")) + prompt_attributes_set = True + else: + logger.debug("[get_response_span_attributes] full_prompt_contextvar is None.") + # Fallback to SDK's request_messages if contextvar wasn't available or didn't have prompt + if ( + span_data.response + and hasattr(span_data.response, "request_messages") + and span_data.response.request_messages + ): + prompt_messages_from_sdk = span_data.response.request_messages + logger.debug( + f"[get_response_span_attributes] Using agents.Response.request_messages: {safe_serialize(prompt_messages_from_sdk)}" + ) + attributes.update(_get_llm_messages_attributes(prompt_messages_from_sdk, "gen_ai.prompt")) + prompt_attributes_set = True + else: + logger.debug( + "[get_response_span_attributes] No prompt source: neither contextvar nor SDK request_messages available/sufficient." + ) + # Process response (completion, usage, model etc.) using the existing get_response_response_attributes + # This function handles the `span_data.response` object which is an `agents.Response` + if span_data.response: + # get_response_response_attributes from openai instrumentation expects an OpenAIObject-like response. + # We need to ensure it can handle agents.Response or adapt. + # For now, let's assume it primarily extracts completion and usage, and we've handled prompt. + + # Call the original function to get completion, usage, model, etc. + # It might also try to set prompt attributes, which we might need to reconcile. + openai_style_response_attrs = get_response_response_attributes(span_data.response) + + # If we've already set prompt attributes from our preferred source (wrapper context), + # remove any prompt attributes that get_response_response_attributes might have set, + # to avoid conflicts or overwriting with potentially less complete data. + if prompt_attributes_set: + keys_to_remove = [ + k for k in openai_style_response_attrs if k.startswith("gen_ai.prompt") + ] # e.g. "gen_ai.prompt" + if keys_to_remove: + for key in keys_to_remove: + if key in openai_style_response_attrs: # Check if key exists before deleting + del openai_style_response_attrs[key] + + # Remove gen_ai.request.tools if present, as it's not appropriate for the LLM response span itself + # The LLM span should reflect the LLM's output (completion, tool_calls selected), not the definition of tools it was given. + if "gen_ai.request.tools" in openai_style_response_attrs: + del openai_style_response_attrs["gen_ai.request.tools"] + + attributes.update(openai_style_response_attrs) + + # Ensure LLM span kind is set + attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.LLM.value return attributes @@ -221,9 +362,70 @@ def get_generation_span_attributes(span_data: Any) -> AttributeMap: Returns: Dictionary of attributes for generation span """ - attributes = _extract_attributes_from_mapping(span_data, GENERATION_SPAN_ATTRIBUTES) + logger.debug( + f"[get_generation_span_attributes] Called for span_data.id: {getattr(span_data, 'id', 'N/A')}, name: {getattr(span_data, 'name', 'N/A')}, type: {span_data.__class__.__name__}" + ) + attributes = _extract_attributes_from_mapping( + span_data, GENERATION_SPAN_ATTRIBUTES + ) # This might set gen_ai.prompt from span_data.input attributes.update(get_common_attributes()) + prompt_attributes_set = False + # Read full prompt from contextvar (set by instrumentor's wrapper) + full_prompt_from_context = full_prompt_contextvar.get() + + if full_prompt_from_context: + logger.debug( + f"[get_generation_span_attributes] Found full_prompt_from_context: {safe_serialize(full_prompt_from_context)}" + ) + # Clear any prompt set by _extract_attributes_from_mapping from span_data.input + prompt_keys_to_clear = [k for k in attributes if k.startswith("gen_ai.prompt")] + if SpanAttributes.LLM_PROMPTS in attributes: + prompt_keys_to_clear.append(SpanAttributes.LLM_PROMPTS) + for key in set(prompt_keys_to_clear): + if key in attributes: + del attributes[key] + + attributes.update(_get_llm_messages_attributes(full_prompt_from_context, "gen_ai.prompt")) + prompt_attributes_set = True + elif SpanAttributes.LLM_PROMPTS in attributes: # Fallback to span_data.input if contextvar is empty + logger.debug( + "[get_generation_span_attributes] full_prompt_contextvar is None. Using LLM_PROMPTS from span_data.input." + ) + raw_prompt_input = attributes.pop(SpanAttributes.LLM_PROMPTS) + formatted_prompt_for_llm = [] + if isinstance(raw_prompt_input, str): + formatted_prompt_for_llm.append({"role": "user", "content": raw_prompt_input}) + elif isinstance(raw_prompt_input, list): + temp_formatted_list = [] + all_strings_or_dicts = True + for item in raw_prompt_input: + if isinstance(item, str): + temp_formatted_list.append({"role": "user", "content": item}) + elif isinstance(item, dict): + temp_formatted_list.append(item) + else: + all_strings_or_dicts = False + break + if all_strings_or_dicts: + formatted_prompt_for_llm = temp_formatted_list + else: + logger.warning( + f"[get_generation_span_attributes] span_data.input was a list with mixed/unexpected content: {safe_serialize(raw_prompt_input)}" + ) + + if formatted_prompt_for_llm: + attributes.update(_get_llm_messages_attributes(formatted_prompt_for_llm, "gen_ai.prompt")) + prompt_attributes_set = True + else: + logger.debug( + "[get_generation_span_attributes] No prompt data from span_data.input or it was not formattable." + ) + else: + logger.debug( + "[get_generation_span_attributes] No prompt source: contextvar is None and no LLM_PROMPTS in attributes from span_data.input." + ) + if span_data.model: attributes.update(get_model_attributes(span_data.model)) @@ -235,6 +437,11 @@ def get_generation_span_attributes(span_data: Any) -> AttributeMap: if span_data.model_config: attributes.update(get_model_config_attributes(span_data.model_config)) + # Ensure LLM span kind is set + attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.LLM.value + logger.debug( + f"[get_generation_span_attributes] Returning attributes for span_data.id: {getattr(span_data, 'id', 'N/A')}. agentops.span.kind: {attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND)}" + ) return attributes diff --git a/agentops/instrumentation/openai_agents/context.py b/agentops/instrumentation/openai_agents/context.py new file mode 100644 index 000000000..690d915ff --- /dev/null +++ b/agentops/instrumentation/openai_agents/context.py @@ -0,0 +1,6 @@ +""" +Context variables for OpenAI Agents instrumentation. +""" +import contextvars + +full_prompt_contextvar = contextvars.ContextVar("agentops_full_prompt_context", default=None) diff --git a/agentops/instrumentation/openai_agents/exporter.py b/agentops/instrumentation/openai_agents/exporter.py index 598f81c18..e9b314095 100644 --- a/agentops/instrumentation/openai_agents/exporter.py +++ b/agentops/instrumentation/openai_agents/exporter.py @@ -37,6 +37,8 @@ from agentops.instrumentation.openai_agents.attributes.common import ( get_span_attributes, ) +# Removed: from agentops.helpers import safe_serialize +# full_prompt_contextvar will be imported and used by attributes.common, not directly here. def log_otel_trace_id(span_type): @@ -303,84 +305,101 @@ def export_span(self, span: Any) -> None: trace_id = getattr(span, "trace_id", "unknown") parent_id = getattr(span, "parent_id", None) + # logger.debug(f"[Exporter] export_span called for SDK span_id: {span_id}, name: {getattr(span_data, 'name', span_type)}, type: {span_type}, trace_id: {trace_id}, parent_id: {parent_id}") + # Check if this is a span end event is_end_event = hasattr(span, "status") and span.status == StatusCode.OK.name # Unique lookup key for this span span_lookup_key = _get_span_lookup_key(trace_id, span_id) - attributes = get_base_span_attributes(span) - span_attributes = get_span_attributes(span_data) - attributes.update(span_attributes) + + # span_data augmentation with _full_prompt_from_wrapper_context is removed. + # attributes/common.py will now directly get the contextvar. + + # Log content of span_data.response before getting attributes + # if hasattr(span_data, 'response') and span_data.response is not None: + # logger.debug(f"[Exporter] SDK span_id: {span_id} - span_data.response before get_span_attributes: {safe_serialize(vars(span_data.response)) if hasattr(span_data.response, '__dict__') else safe_serialize(span_data.response)}") + # elif span_type in ["ResponseSpanData", "GenerationSpanData"]: # Only log absence for relevant types + # logger.debug(f"[Exporter] SDK span_id: {span_id} - span_data.response is None or not present before get_span_attributes for {span_type}.") + + attributes = get_base_span_attributes(span) # Basic attributes from the SDK span object + span_specific_attributes = get_span_attributes(span_data) # Type-specific attributes from SpanData + attributes.update(span_specific_attributes) + + # logger.debug(f"[Exporter] SDK span_id: {span_id} - Combined attributes before OTel span creation/update: {safe_serialize(attributes)}") if is_end_event: - # Update all attributes for end events - attributes.update(span_attributes) + # For end events, ensure all attributes from span_data are captured + # get_span_attributes should ideally get everything needed based on the final state of span_data + pass # Attributes are already updated above # Log the trace ID for debugging and correlation with AgentOps API log_otel_trace_id(span_type) # For start events, create a new span and store it (don't end it) if not is_end_event: - # Process the span based on its type - # TODO span_name should come from the attributes module - span_name = get_span_name(span) - span_kind = get_span_kind(span) + span_name = get_span_name(span) # Get OTel span name + span_kind = get_span_kind(span) # Get OTel span kind - # Get parent context for proper nesting parent_span_ctx = self._get_parent_context(trace_id, span_id, parent_id) - # Create the span with proper parent context otel_span = self._create_span_with_parent( name=span_name, kind=span_kind, attributes=attributes, parent_ctx=parent_span_ctx ) + # logger.debug(f"[Exporter] SDK span_id: {span_id} (START event) - CREATED OTel span with OTel_span_id: {otel_span.context.span_id if otel_span else 'None'}") - # Store the span for later reference if not isinstance(otel_span, NonRecordingSpan): self._span_map[span_lookup_key] = otel_span - self._active_spans[span_id] = { + self._active_spans[span_id] = { # Use SDK span_id as key for _active_spans "span": otel_span, "span_type": span_type, - "trace_id": trace_id, - "parent_id": parent_id, + "trace_id": trace_id, # SDK trace_id + "parent_id": parent_id, # SDK parent_id } + # logger.debug(f"[Exporter] SDK span_id: {span_id} (START event) - Stored OTel span in _span_map and _active_spans.") - # Handle any error information - self._handle_span_error(span, otel_span) - - # DO NOT end the span for start events - we want to keep it open for updates + self._handle_span_error(span, otel_span) # Handle error for start event too return # For end events, check if we already have the span if span_lookup_key in self._span_map: existing_span = self._span_map[span_lookup_key] + # logger.debug(f"[Exporter] SDK span_id: {span_id} (END event) - Found existing OTel span in _span_map with OTel_span_id: {existing_span.context.span_id}") - # Check if span is already ended span_is_ended = False if isinstance(existing_span, Span) and hasattr(existing_span, "_end_time"): span_is_ended = existing_span._end_time is not None + # logger.debug(f"[Exporter] SDK span_id: {span_id} (END event) - Existing OTel span was_ended: {span_is_ended}") if not span_is_ended: - # Update and end the existing span - for key, value in attributes.items(): + for key, value in attributes.items(): # Update with final attributes existing_span.set_attribute(key, value) + # logger.debug(f"[Exporter] SDK span_id: {span_id} (END event) - Updated attributes on existing OTel span.") - # Set status and handle any error information - existing_span.set_status(Status(StatusCode.OK if span.status == "OK" else StatusCode.ERROR)) + existing_span.set_status( + Status(StatusCode.OK if getattr(span, "status", "OK") == "OK" else StatusCode.ERROR) + ) self._handle_span_error(span, existing_span) - existing_span.end() - else: - # Create a new span with the complete data (already ended state) - self.create_span(span, span_type, attributes) - else: - # No existing span found, create a new one with all data - self.create_span(span, span_type, attributes) + # logger.debug(f"[Exporter] SDK span_id: {span_id} (END event) - ENDED existing OTel span.") + else: # Span already ended, create a new one (should be rare if logic is correct) + logger.warning( + f"[Exporter] SDK span_id: {span_id} (END event) - Attempting to end an ALREADY ENDED OTel span: {span_lookup_key}. Creating a new one instead." + ) + self.create_span(span, span_type, attributes, is_already_ended=True) + else: # No existing span found for end event, create a new one + logger.warning( + f"[Exporter] SDK span_id: {span_id} (END event) - No active OTel span found for end event: {span_lookup_key}. Creating a new one." + ) + self.create_span(span, span_type, attributes, is_already_ended=True) - # Clean up our tracking resources self._active_spans.pop(span_id, None) self._span_map.pop(span_lookup_key, None) + # logger.debug(f"[Exporter] SDK span_id: {span_id} (END event) - Popped from _active_spans and _span_map.") - def create_span(self, span: Any, span_type: str, attributes: Dict[str, Any]) -> None: + def create_span( + self, span: Any, span_type: str, attributes: Dict[str, Any], is_already_ended: bool = False + ) -> None: """Create a new span with the provided data and end it immediately. This method creates a span using the appropriate parent context, applies diff --git a/agentops/instrumentation/openai_agents/instrumentor.py b/agentops/instrumentation/openai_agents/instrumentor.py index 87275becc..d56adbe8d 100644 --- a/agentops/instrumentation/openai_agents/instrumentor.py +++ b/agentops/instrumentation/openai_agents/instrumentor.py @@ -20,15 +20,73 @@ that here as well. """ -from typing import Collection -from opentelemetry import trace # Needed for tracer +from typing import Collection, Tuple, Dict, Any, Optional +import functools # For functools.wraps + +from opentelemetry import trace # Needed for tracer +from opentelemetry.trace import SpanKind as OtelSpanKind, Status, StatusCode # Renamed to avoid conflict from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore +import wrapt # For wrapping +# Remove local contextvars import, will import from .context +# import contextvars + from agentops.logging import logger from agentops.instrumentation.openai_agents.processor import OpenAIAgentsProcessor from agentops.instrumentation.openai_agents.exporter import OpenAIAgentsExporter -# For Runner wrappers -from agentops.instrumentation.common.wrappers import wrap, unwrap -from agentops.instrumentation.openai_agents.runner_wrappers import AGENT_RUNNER_WRAP_CONFIGS +from .context import full_prompt_contextvar # Import from .context +from agentops.instrumentation.common.wrappers import WrapConfig # Keep WrapConfig + +# Remove wrap, unwrap from common.wrappers as we'll use wrapt directly for custom wrapper +# from agentops.instrumentation.common.wrappers import wrap, unwrap +from agentops.instrumentation.common.attributes import AttributeMap # For type hinting +from agentops.helpers import safe_serialize + +# Semantic conventions from AgentOps (Copied from runner_wrappers.py for use in new handler logic) +from agentops.semconv import ( + AgentAttributes, + MessageAttributes, + SpanAttributes, + CoreAttributes, + AgentOpsSpanKindValues, + WorkflowAttributes, +) + +# Removed local definition of full_prompt_contextvar + +# Define AGENT_RUNNER_WRAP_CONFIGS locally (adapted from runner_wrappers.py) +# Handler field is removed as the new wrapper incorporates this logic. +_OPENAI_AGENTS_RUNNER_MODULE = "agents.run" +_OPENAI_AGENTS_RUNNER_CLASS = "Runner" + +AGENT_RUNNER_WRAP_CONFIGS = [ + WrapConfig( + trace_name=AgentOpsSpanKindValues.AGENT.value, # This will be the OTel span name + package=_OPENAI_AGENTS_RUNNER_MODULE, + class_name=_OPENAI_AGENTS_RUNNER_CLASS, + method_name="run", + handler=None, + is_async=True, + span_kind=OtelSpanKind.INTERNAL, + ), + WrapConfig( + trace_name=AgentOpsSpanKindValues.AGENT.value, + package=_OPENAI_AGENTS_RUNNER_MODULE, + class_name=_OPENAI_AGENTS_RUNNER_CLASS, + method_name="run_sync", + handler=None, + is_async=False, + span_kind=OtelSpanKind.INTERNAL, + ), + WrapConfig( + trace_name=AgentOpsSpanKindValues.AGENT.value, + package=_OPENAI_AGENTS_RUNNER_MODULE, + class_name=_OPENAI_AGENTS_RUNNER_CLASS, + method_name="run_streamed", + handler=None, + is_async=True, + span_kind=OtelSpanKind.INTERNAL, + ), +] class OpenAIAgentsInstrumentor(BaseInstrumentor): @@ -37,6 +95,230 @@ class OpenAIAgentsInstrumentor(BaseInstrumentor): _processor = None _exporter = None _default_processor = None + # _is_instrumented_flag_for_instance = {} # Using instance member instead + + def __init__(self): # OTel BaseInstrumentor __init__ takes no args + super().__init__() + self._tracer = None # Ensure _tracer is initialized + self._is_instrumented_instance_flag = False # Instance-specific flag + logger.debug( + f"OpenAIAgentsInstrumentor (id: {id(self)}) created. Initial _is_instrumented_instance_flag: {self._is_instrumented_instance_flag}" + ) + + # Removed property for _is_instrumented, will use direct instance member _is_instrumented_instance_flag + + def _extract_agent_runner_attributes_and_set_contextvar( + self, + otel_span: trace.Span, + args: Optional[Tuple] = None, + kwargs: Optional[Dict] = None, + return_value: Optional[Any] = None, + exception: Optional[Exception] = None, + ) -> None: + """ + Helper to extract attributes for an 'Agent Run/Turn' span and manage contextvar. + Logic is derived from the original agent_run_turn_attribute_handler. + Sets attributes directly on the provided otel_span. + """ + attributes: AttributeMap = {} + attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.AGENT.value + + agent_obj: Any = None + sdk_input: Any = None + + # The `args` passed to this function are the direct *args of the wrapped method (e.g., Runner.run), + # so args[0] is 'agent', args[1] is 'input'. + # `kwargs` are the direct **kwargs of the wrapped method. + if args: + if len(args) > 0: + agent_obj = args[0] + if len(args) > 1: + sdk_input = args[1] + + # Allow kwargs to override or provide if not in args + if kwargs: + if "agent" in kwargs and kwargs["agent"] is not None: # Check for None explicitly + agent_obj = kwargs["agent"] + if "input" in kwargs and kwargs["input"] is not None: # Check for None explicitly + sdk_input = kwargs["input"] + + current_full_prompt_for_llm = [] + + if agent_obj: + if hasattr(agent_obj, "name") and agent_obj.name: + attributes[AgentAttributes.AGENT_NAME] = str(agent_obj.name) + if hasattr(agent_obj, "model") and agent_obj.model: + attributes[SpanAttributes.LLM_REQUEST_MODEL] = str(agent_obj.model) + if hasattr(agent_obj, "instructions") and agent_obj.instructions: + instructions = str(agent_obj.instructions) + attributes[SpanAttributes.LLM_REQUEST_INSTRUCTIONS] = instructions + current_full_prompt_for_llm.append({"role": "system", "content": instructions}) + if hasattr(agent_obj, "tools") and agent_obj.tools: + attributes[AgentAttributes.AGENT_TOOLS] = [str(getattr(t, "name", t)) for t in agent_obj.tools] + if hasattr(agent_obj, "handoffs") and agent_obj.handoffs: + attributes[AgentAttributes.HANDOFFS] = [str(getattr(h, "name", h)) for h in agent_obj.handoffs] + if hasattr(agent_obj, "output_type") and agent_obj.output_type: + attributes["gen_ai.output.type"] = str(agent_obj.output_type) + + if sdk_input: + if isinstance(sdk_input, str): + attributes[MessageAttributes.PROMPT_CONTENT.format(i=0)] = sdk_input + attributes[MessageAttributes.PROMPT_ROLE.format(i=0)] = "user" + current_full_prompt_for_llm.append({"role": "user", "content": sdk_input}) + elif isinstance(sdk_input, list): + for i, msg in enumerate(sdk_input): + if isinstance(msg, dict): + role = msg.get("role") + content = msg.get("content") + if role: + attributes[MessageAttributes.PROMPT_ROLE.format(i=i)] = str(role) + if content is not None: # Allow empty string for content + serialized_content = safe_serialize(content) + attributes[MessageAttributes.PROMPT_CONTENT.format(i=i)] = serialized_content + if role: # Add to full_prompt only if role and content exist + current_full_prompt_for_llm.append({"role": str(role), "content": serialized_content}) + else: + attributes[WorkflowAttributes.WORKFLOW_INPUT] = safe_serialize(sdk_input) + + if current_full_prompt_for_llm: + full_prompt_contextvar.set(current_full_prompt_for_llm) + else: + full_prompt_contextvar.set(None) # Ensure reset if no prompt + + if return_value: + if type(return_value).__name__ == "RunResultStreaming": + attributes[SpanAttributes.LLM_REQUEST_STREAMING] = True + + if exception: + attributes[CoreAttributes.ERROR_TYPE] = type(exception).__name__ + # Contextvar is reset in the finally block of the main wrapper + + # Set all extracted attributes on the span + for key, value in attributes.items(): + otel_span.set_attribute(key, value) + + def _create_agent_runner_wrapper( + self, wrapped_method_to_call, is_async: bool, trace_name: str, span_kind: OtelSpanKind + ): # Renamed original_method for clarity + """ + Creates a wrapper for an OpenAI Agents Runner method (run, run_sync, run_streamed). + This wrapper starts an OTel span, extracts attributes, manages contextvar for full prompt, + calls the original method, and ensures the span is ended and contextvar is reset. + """ + otel_tracer = self._tracer # Use the instrumentor's tracer + + if is_async: + # Corrected wrapper signature for wrapt: (wrapped, instance, args, kwargs) + @functools.wraps(wrapped_method_to_call) # Use the passed original method for functools.wraps + async def wrapper_async(wrapped, instance, args, kwargs): + # 'wrapped' is the original unbound method (e.g., Runner.run) + # 'instance' is the Runner class (if called as Runner.run) or Runner instance + # 'args' and 'kwargs' are the arguments passed to Runner.run + + span_attributes_args = args # These are the direct args to Runner.run + span_attributes_kwargs = kwargs + + otel_span = otel_tracer.start_span(name=trace_name, kind=span_kind) + with trace.use_span(otel_span, end_on_exit=False): + exception_obj = None + res = None + try: + # _extract_agent_runner_attributes_and_set_contextvar expects args and kwargs + # as they are passed to the *original method call*. + self._extract_agent_runner_attributes_and_set_contextvar(otel_span, args=args, kwargs=kwargs) + logger.debug( + f"wrapper_async: About to call wrapped method. instance type: {type(instance)}, args: {safe_serialize(args)}, kwargs: {safe_serialize(kwargs)}" + ) + + # How to call 'wrapped' depends on whether 'instance' is None (e.g. staticmethod called on class) + # or if 'instance' is the class itself (e.g. classmethod or instance method called on class) + # or if 'instance' is an actual object instance. + # Given the example call `Runner.run(agent, input, ...)` and `Runner.run` being an instance method, + # `instance` will be the `Runner` class. `wrapped` is the unbound method. + # We need to call it as `wrapped(instance, *args, **kwargs)` if it's an instance method + # and `instance` is the actual instance. + # If `instance` is the class, and `wrapped` is an instance method, this is like `Runner.run(Runner, agent, input, ...)` + # which was causing "5 arguments" error. + # The example `Runner.run(agent, input, ...)` implies the SDK handles this. + # So, the call should likely be `wrapped(*args, **kwargs)` if `instance` is the class and `wrapped` is a static/class method + # or if the SDK's `run` method descriptor handles being called from the class. + # If `wrapped` is an instance method, it needs an instance. + # The `main.py` calls `Runner.run(current_agent, input_items, context=context)` + # This means `args` = (current_agent, input_items), `kwargs` = {context: ...} + # The `Runner.run` signature is `run(self, agent, input, ...)` + # The most direct way to replicate the call from main.py is `wrapped(*args, **kwargs)` + # assuming `wrapped` is what `Runner.run` resolves to. + + # If `wrapped` is the unbound instance method `run(self, agent, input, ...)` + # and `instance` is the class `Runner`, then `wrapped(instance, *args, **kwargs)` + # becomes `run(Runner, agent_arg, input_arg, ...)`. This is 3 positional. + # The error "5 were given" for this call is the core puzzle. + + # The error "4 were given" for `wrapped(*args, **kwargs)` means `run(agent_arg, input_arg, ...)` + # was missing `self`. + + # Let's stick to the wrapt convention: the `wrapped` callable should be invoked + # appropriately based on `instance`. + # If `instance` is not None, it's typically `wrapped(instance, *args, **kwargs)`. + # If `instance` is None (e.g. for a static method called via class), it's `wrapped(*args, **kwargs)`. + # Since `Runner.run` is an instance method, and `instance` is `Runner` (the class) + # when called as `Runner.run()`, this is an unbound call. + # The correct way to call an unbound instance method is `MethodType(wrapped, instance_obj)(*args, **kwargs)` + # OR `wrapped(instance_obj, *args, **kwargs)`. + # Here, `instance` is the class. The SDK must handle `Runner.run(agent, input)` by creating an instance. + # So, we should call `wrapped` as it was called in the user's code. + # The `args` and `kwargs` are what the user supplied to `Runner.run`. + # `wrapped` is `Runner.run`. So, `wrapped(*args, **kwargs)` is `Runner.run(*args, **kwargs)`. + + res = await wrapped(*args, **kwargs) # This replicates the original call structure + + self._extract_agent_runner_attributes_and_set_contextvar(otel_span, return_value=res) + otel_span.set_status(Status(StatusCode.OK)) + except Exception as e: + exception_obj = e + self._extract_agent_runner_attributes_and_set_contextvar(otel_span, exception=e) + otel_span.set_status(Status(StatusCode.ERROR, description=str(e))) + otel_span.record_exception(e) + raise # Re-raise the exception + finally: + full_prompt_contextvar.set(None) # Reset contextvar + otel_span.end() + return res + + return wrapper_async + else: # Synchronous wrapper + # Corrected wrapper signature for wrapt: (wrapped, instance, args, kwargs) + @functools.wraps(wrapped_method_to_call) # Use the passed original method for functools.wraps + def wrapper_sync(wrapped, instance, args, kwargs): + span_attributes_args = args + span_attributes_kwargs = kwargs + + otel_span = otel_tracer.start_span(name=trace_name, kind=span_kind) + with trace.use_span(otel_span, end_on_exit=False): + exception_obj = None + res = None + try: + self._extract_agent_runner_attributes_and_set_contextvar(otel_span, args=args, kwargs=kwargs) + logger.debug( + f"wrapper_sync: About to call wrapped method. instance type: {type(instance)}, args: {safe_serialize(args)}, kwargs: {safe_serialize(kwargs)}" + ) + + res = wrapped(*args, **kwargs) # Replicates original call structure + + self._extract_agent_runner_attributes_and_set_contextvar(otel_span, return_value=res) + otel_span.set_status(Status(StatusCode.OK)) + except Exception as e: + exception_obj = e + self._extract_agent_runner_attributes_and_set_contextvar(otel_span, exception=e) + otel_span.set_status(Status(StatusCode.ERROR, description=str(e))) + otel_span.record_exception(e) + raise + finally: + full_prompt_contextvar.set(None) + otel_span.end() + return res + + return wrapper_sync def instrumentation_dependencies(self) -> Collection[str]: """Return packages required for instrumentation.""" @@ -44,66 +326,172 @@ def instrumentation_dependencies(self) -> Collection[str]: def _instrument(self, **kwargs): """Instrument the OpenAI Agents SDK.""" - tracer_provider = kwargs.get("tracer_provider") - # Obtain a tracer instance. The instrumentor name can be more specific. - # The version should ideally come from the package version. - self._tracer = trace.get_tracer( - "agentops.instrumentation.openai_agents", # More specific tracer name - "0.1.0" # Placeholder version, ideally package version + logger.debug( + f"OpenAIAgentsInstrumentor (id: {id(self)}) _instrument START. Current _is_instrumented_instance_flag: {self._is_instrumented_instance_flag}" ) + if self._is_instrumented_instance_flag: + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) already instrumented. Skipping.") + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) _instrument END (skipped)") + return + tracer_provider = kwargs.get("tracer_provider") + if self._tracer is None: + self._tracer = trace.get_tracer("agentops.instrumentation.openai_agents", "0.1.0") + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) using tracer: {self._tracer}") try: - # 1. Setup Processor for internal SDK traces - self._exporter = OpenAIAgentsExporter(tracer_provider=tracer_provider) # Exporter uses the tracer_provider + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) creating exporter and processor.") + self._exporter = OpenAIAgentsExporter(tracer_provider=tracer_provider) self._processor = OpenAIAgentsProcessor( exporter=self._exporter, ) - from agents import set_trace_processors # type: ignore - from agents.tracing.processors import default_processor # type: ignore - self._default_processor = default_processor() + from agents import set_trace_processors + from agents.tracing.processors import default_processor + + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) getting default processor...") + if getattr(self, "_default_processor", None) is None: # Check if already stored by this instance + self._default_processor = default_processor() + logger.debug( + f"OpenAIAgentsInstrumentor (id: {id(self)}) Stored original default processor: {self._default_processor}" + ) + + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) setting trace processors to: {self._processor}") set_trace_processors([self._processor]) - logger.debug("Replaced default processor with OpenAIAgentsProcessor in OpenAI Agents SDK") + logger.debug( + f"OpenAIAgentsInstrumentor (id: {id(self)}) Replaced default processor with OpenAIAgentsProcessor." + ) - # 2. Apply Runner method wrappers for Agent Run/Turn parent spans + logger.debug( + f"OpenAIAgentsInstrumentor (id: {id(self)}) Applying Runner method wrappers using new custom wrapper..." + ) for config in AGENT_RUNNER_WRAP_CONFIGS: try: - logger.debug(f"Applying wrapper for {config.package}.{config.class_name}.{config.method_name}") - wrap(config, self._tracer) # Use the instrumentor's tracer + module_path, class_name, method_name = config.package, config.class_name, config.method_name + # Ensure the module is imported correctly to find the class + # __import__ returns the top-level package, so need to getattr down + # For "agents.run", __import__("agents.run", fromlist=["Runner"]) + # module = __import__(module_path, fromlist=[class_name]) # This might not work for nested modules correctly + + # A more robust way to get the class + parts = module_path.split(".") + current_module = __import__(parts[0]) + for part in parts[1:]: + current_module = getattr(current_module, part) + + cls_to_wrap = getattr(current_module, class_name) + original_method = getattr(cls_to_wrap, method_name) + + # Create the specific wrapper for this method + custom_wrapper = self._create_agent_runner_wrapper( + original_method, + is_async=config.is_async, + trace_name=config.trace_name, + span_kind=config.span_kind, + ) + + # Apply the wrapper using wrapt + # wrapt.wrap_function_wrapper expects module as string, name as string + # For class methods, name is 'ClassName.method_name' + wrapt.wrap_function_wrapper( + module_path, # Module name as string + f"{class_name}.{method_name}", # 'ClassName.method_name' + custom_wrapper, + ) + logger.info( + f"OpenAIAgentsInstrumentor (id: {id(self)}) Applied custom wrapper for {class_name}.{method_name}" + ) except Exception as e_wrap: - logger.error(f"Failed to apply wrapper for {config}: {e_wrap}", exc_info=True) - logger.info("Applied Runner method wrappers for AgentOps.") + logger.error( + f"OpenAIAgentsInstrumentor (id: {id(self)}) Failed to apply custom wrapper for {config.method_name}: {e_wrap}", + exc_info=True, + ) + + self._is_instrumented_instance_flag = True + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) set _is_instrumented_instance_flag to True.") except Exception as e: - logger.warning(f"Failed to instrument OpenAI Agents SDK: {e}", exc_info=True) + logger.warning(f"OpenAIAgentsInstrumentor (id: {id(self)}) Failed to instrument: {e}", exc_info=True) + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) _instrument END") def _uninstrument(self, **kwargs): """Remove instrumentation from OpenAI Agents SDK.""" + logger.debug( + f"OpenAIAgentsInstrumentor (id: {id(self)}) _uninstrument START. Current _is_instrumented_instance_flag: {self._is_instrumented_instance_flag}" + ) + if not self._is_instrumented_instance_flag: + logger.debug( + f"OpenAIAgentsInstrumentor (id: {id(self)}) not currently instrumented. Skipping uninstrument." + ) + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) _uninstrument END (skipped)") + return try: - # Clean up any active spans in the exporter if hasattr(self, "_exporter") and self._exporter: - # Call cleanup to properly handle any active spans + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) Cleaning up exporter.") if hasattr(self._exporter, "cleanup"): self._exporter.cleanup() - # Put back the default processor from agents import set_trace_processors + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) Attempting to restore default processor.") if hasattr(self, "_default_processor") and self._default_processor: + logger.debug( + f"OpenAIAgentsInstrumentor (id: {id(self)}) Restoring default processor: {self._default_processor}" + ) set_trace_processors([self._default_processor]) self._default_processor = None + else: + logger.warning(f"OpenAIAgentsInstrumentor (id: {id(self)}) No default_processor to restore.") self._processor = None - self._exporter = None # type: ignore + self._exporter = None - # Remove Runner method wrappers + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) Removing Runner method wrappers...") for config in AGENT_RUNNER_WRAP_CONFIGS: try: - logger.debug(f"Removing wrapper for {config.package}.{config.class_name}.{config.method_name}") - unwrap(config) + module_path, class_name, method_name = config.package, config.class_name, config.method_name + + parts = module_path.split(".") + current_module = __import__(parts[0]) + for part in parts[1:]: + current_module = getattr(current_module, part) + + cls_to_wrap = getattr(current_module, class_name) + + # Get the potentially wrapped method + method_to_unwrap = getattr(cls_to_wrap, method_name, None) + + if hasattr(method_to_unwrap, "__wrapped__"): + # If it's a wrapt proxy, __wrapped__ gives the original + original = method_to_unwrap.__wrapped__ + setattr(cls_to_wrap, method_name, original) + logger.info( + f"OpenAIAgentsInstrumentor (id: {id(self)}) Removed custom wrapper for {class_name}.{method_name}" + ) + elif isinstance(method_to_unwrap, functools.partial) and hasattr( + method_to_unwrap.func, "__wrapped__" + ): + # Handle cases where it might be a partial of a wrapper (less common here but good to check) + original = method_to_unwrap.func.__wrapped__ + setattr(cls_to_wrap, method_name, original) # This might be tricky if partial had specific args + logger.info( + f"OpenAIAgentsInstrumentor (id: {id(self)}) Removed custom wrapper (from partial) for {class_name}.{method_name}" + ) + else: + logger.warning( + f"OpenAIAgentsInstrumentor (id: {id(self)}) Wrapper not found or not a recognized wrapt wrapper for {class_name}.{method_name}. Current type: {type(method_to_unwrap)}" + ) + except Exception as e_unwrap: - logger.error(f"Failed to remove wrapper for {config}: {e_unwrap}", exc_info=True) - logger.info("Removed Runner method wrappers for AgentOps.") + logger.error( + f"OpenAIAgentsInstrumentor (id: {id(self)}) Failed to remove custom wrapper for {config.method_name}: {e_unwrap}", + exc_info=True, + ) + + self._is_instrumented_instance_flag = False + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) set _is_instrumented_instance_flag to False.") - logger.info("Successfully removed OpenAI Agents SDK instrumentation") + logger.info( + f"OpenAIAgentsInstrumentor (id: {id(self)}) Successfully removed OpenAI Agents SDK instrumentation" + ) except Exception as e: - logger.warning(f"Failed to uninstrument OpenAI Agents SDK: {e}", exc_info=True) + logger.warning(f"OpenAIAgentsInstrumentor (id: {id(self)}) Failed to uninstrument: {e}", exc_info=True) + logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) _uninstrument END") From 46e1020a40ca28f76972d29481c90f8e590aec61 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 04:11:51 +0530 Subject: [PATCH 04/16] remove duplicate LLM calls --- .../openai_agents/attributes/common.py | 5 +- .../openai_agents/instrumentor.py | 50 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index f357972a2..0c5deb0cb 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -342,6 +342,9 @@ def get_response_span_attributes(span_data: Any) -> AttributeMap: # Ensure LLM span kind is set attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.LLM.value + logger.debug( + f"[get_response_span_attributes] Set AGENTOPS_SPAN_KIND to '{AgentOpsSpanKindValues.LLM.value}' for span_data.id: {getattr(span_data, 'id', 'N/A')}" + ) return attributes @@ -440,7 +443,7 @@ def get_generation_span_attributes(span_data: Any) -> AttributeMap: # Ensure LLM span kind is set attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.LLM.value logger.debug( - f"[get_generation_span_attributes] Returning attributes for span_data.id: {getattr(span_data, 'id', 'N/A')}. agentops.span.kind: {attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND)}" + f"[get_generation_span_attributes] Set AGENTOPS_SPAN_KIND to '{AgentOpsSpanKindValues.LLM.value}' for span_data.id: {getattr(span_data, 'id', 'N/A')}. Current attributes include agentops.span.kind: {attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND)}" ) return attributes diff --git a/agentops/instrumentation/openai_agents/instrumentor.py b/agentops/instrumentation/openai_agents/instrumentor.py index d56adbe8d..ab2dceabb 100644 --- a/agentops/instrumentation/openai_agents/instrumentor.py +++ b/agentops/instrumentation/openai_agents/instrumentor.py @@ -122,6 +122,14 @@ def _extract_agent_runner_attributes_and_set_contextvar( """ attributes: AttributeMap = {} attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.AGENT.value + otel_span_id_for_log = "unknown" + if otel_span and hasattr(otel_span, "get_span_context"): + span_ctx = otel_span.get_span_context() + if span_ctx and hasattr(span_ctx, "span_id"): + otel_span_id_for_log = f"{span_ctx.span_id:016x}" + logger.debug( + f"[_extract_agent_runner_attributes_and_set_contextvar] Set AGENTOPS_SPAN_KIND to '{AgentOpsSpanKindValues.AGENT.value}' for OTel span ID: {otel_span_id_for_log}" + ) agent_obj: Any = None sdk_input: Any = None @@ -193,8 +201,48 @@ def _extract_agent_runner_attributes_and_set_contextvar( attributes[CoreAttributes.ERROR_TYPE] = type(exception).__name__ # Contextvar is reset in the finally block of the main wrapper - # Set all extracted attributes on the span + # Log all collected attributes before filtering + logger.debug( + f"[_extract_agent_runner_attributes_and_set_contextvar] All collected attributes before filtering for OTel span ID {otel_span_id_for_log}: {safe_serialize(attributes)}" + ) + + # Define filter conditions and log them + # For gen_ai.prompt.* attributes (e.g., gen_ai.prompt.0.content, gen_ai.prompt.0.role) + prompt_attr_prefix_to_exclude = "gen_ai.prompt." + + # For gen_ai.request.instructions + # SpanAttributes.LLM_REQUEST_INSTRUCTIONS should resolve to "gen_ai.request.instructions" + instructions_attr_key_to_exclude = SpanAttributes.LLM_REQUEST_INSTRUCTIONS + + logger.debug( + f"[_extract_agent_runner_attributes_and_set_contextvar] Filter: Excluding keys starting with '{prompt_attr_prefix_to_exclude}' for OTel span ID {otel_span_id_for_log}" + ) + logger.debug( + f"[_extract_agent_runner_attributes_and_set_contextvar] Filter: Excluding key equal to '{instructions_attr_key_to_exclude}' (actual value of SpanAttributes.LLM_REQUEST_INSTRUCTIONS) for OTel span ID {otel_span_id_for_log}" + ) + + attributes_for_agent_span = {} for key, value in attributes.items(): + excluded_by_prompt_filter = key.startswith(prompt_attr_prefix_to_exclude) + excluded_by_instructions_filter = key == instructions_attr_key_to_exclude + + if excluded_by_prompt_filter: + logger.debug( + f"[_extract_agent_runner_attributes_and_set_contextvar] Filtering out key '{key}' for OTel span ID {otel_span_id_for_log} (matched prompt_prefix_to_exclude: '{prompt_attr_prefix_to_exclude}')" + ) + continue + if excluded_by_instructions_filter: + logger.debug( + f"[_extract_agent_runner_attributes_and_set_contextvar] Filtering out key '{key}' for OTel span ID {otel_span_id_for_log} (matched instructions_attr_key_to_exclude: '{instructions_attr_key_to_exclude}')" + ) + continue + + attributes_for_agent_span[key] = value + + logger.debug( + f"[_extract_agent_runner_attributes_and_set_contextvar] Attributes for AGENT span (OTel span ID {otel_span_id_for_log}) after filtering: {safe_serialize(attributes_for_agent_span)}" + ) + for key, value in attributes_for_agent_span.items(): otel_span.set_attribute(key, value) def _create_agent_runner_wrapper( From f0266d0ee99daf01a8b05761777cdae2cb241256 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 10:20:02 +0530 Subject: [PATCH 05/16] keep a single agent span --- .../openai_agents/attributes/common.py | 56 +++- .../instrumentation/openai_agents/context.py | 2 + .../openai_agents/instrumentor.py | 299 ++++++------------ 3 files changed, 143 insertions(+), 214 deletions(-) diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index 0c5deb0cb..17aa7d6d2 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -25,7 +25,7 @@ from agentops.instrumentation.openai_agents import LIBRARY_NAME, LIBRARY_VERSION # Import full_prompt_contextvar from the new context module -from ..context import full_prompt_contextvar +from ..context import full_prompt_contextvar, agent_name_contextvar, agent_handoffs_contextvar from agentops.instrumentation.openai_agents.attributes.model import ( get_model_attributes, get_model_config_attributes, @@ -197,9 +197,59 @@ def get_agent_span_attributes(span_data: Any) -> AttributeMap: Returns: Dictionary of attributes for agent span """ - attributes = _extract_attributes_from_mapping(span_data, AGENT_SPAN_ATTRIBUTES) - attributes.update(get_common_attributes()) + # attributes = _extract_attributes_from_mapping(span_data, AGENT_SPAN_ATTRIBUTES) # We will set attributes more selectively + attributes = {} # Start with an empty dict + attributes.update(get_common_attributes()) # Get common OTel/AgentOps attributes + + # Set AGENTOPS_SPAN_KIND to 'agent' + attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.AGENT.value + logger.debug( + f"[get_agent_span_attributes] Set AGENTOPS_SPAN_KIND to '{AgentOpsSpanKindValues.AGENT.value}' for AgentSpanData id: {getattr(span_data, 'id', 'N/A')}" + ) + + # Get agent name from contextvar (set by instrumentor wrapper) + ctx_agent_name = agent_name_contextvar.get() + if ctx_agent_name: + attributes[AgentAttributes.AGENT_NAME] = ctx_agent_name + logger.debug(f"[get_agent_span_attributes] Set AGENT_NAME from contextvar: {ctx_agent_name}") + elif hasattr(span_data, "name") and span_data.name: # Fallback to span_data.name if contextvar is not set + attributes[AgentAttributes.AGENT_NAME] = str(span_data.name) + logger.debug(f"[get_agent_span_attributes] Set AGENT_NAME from span_data.name: {str(span_data.name)}") + + # Get simplified handoffs from contextvar (set by instrumentor wrapper) + ctx_handoffs = agent_handoffs_contextvar.get() + if ctx_handoffs: + attributes[AgentAttributes.HANDOFFS] = safe_serialize(ctx_handoffs) # Ensure it's a JSON string array + logger.debug(f"[get_agent_span_attributes] Set HANDOFFS from contextvar: {safe_serialize(ctx_handoffs)}") + elif ( + hasattr(span_data, "handoffs") and span_data.handoffs + ): # Fallback for safety, though contextvar should be primary + # This fallback might re-introduce complex objects if not careful, + # but contextvar is the intended source for the simplified list. + attributes[AgentAttributes.HANDOFFS] = safe_serialize(span_data.handoffs) + logger.debug( + f"[get_agent_span_attributes] Set HANDOFFS from span_data.handoffs: {safe_serialize(span_data.handoffs)}" + ) + + # Selectively add other relevant attributes from AgentSpanData if needed, avoiding LLM details + if hasattr(span_data, "input") and span_data.input is not None: + # Avoid setting detailed prompt input here. If a general workflow input is desired, use a non-LLM semconv. + # For now, let's assume WORKFLOW_INPUT is too generic and might contain prompts. + # attributes[WorkflowAttributes.WORKFLOW_INPUT] = safe_serialize(span_data.input) + pass + + if hasattr(span_data, "output") and span_data.output is not None: + # Similar to input, avoid detailed LLM output. + # attributes[WorkflowAttributes.FINAL_OUTPUT] = safe_serialize(span_data.output) + pass + if hasattr(span_data, "tools") and span_data.tools: + # Serialize tools if they are simple list of strings or basic structures + attributes[AgentAttributes.AGENT_TOOLS] = safe_serialize([str(getattr(t, "name", t)) for t in span_data.tools]) + + logger.debug( + f"[get_agent_span_attributes] Final attributes for AgentSpanData id {getattr(span_data, 'id', 'N/A')}: {safe_serialize(attributes)}" + ) return attributes diff --git a/agentops/instrumentation/openai_agents/context.py b/agentops/instrumentation/openai_agents/context.py index 690d915ff..023d8e16c 100644 --- a/agentops/instrumentation/openai_agents/context.py +++ b/agentops/instrumentation/openai_agents/context.py @@ -4,3 +4,5 @@ import contextvars full_prompt_contextvar = contextvars.ContextVar("agentops_full_prompt_context", default=None) +agent_name_contextvar = contextvars.ContextVar("agentops_agent_name_context", default=None) +agent_handoffs_contextvar = contextvars.ContextVar("agentops_agent_handoffs_context", default=None) diff --git a/agentops/instrumentation/openai_agents/instrumentor.py b/agentops/instrumentation/openai_agents/instrumentor.py index ab2dceabb..723f16e38 100644 --- a/agentops/instrumentation/openai_agents/instrumentor.py +++ b/agentops/instrumentation/openai_agents/instrumentor.py @@ -24,7 +24,7 @@ import functools # For functools.wraps from opentelemetry import trace # Needed for tracer -from opentelemetry.trace import SpanKind as OtelSpanKind, Status, StatusCode # Renamed to avoid conflict +from opentelemetry.trace import SpanKind as OtelSpanKind # Renamed to avoid conflict from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore import wrapt # For wrapping # Remove local contextvars import, will import from .context @@ -33,22 +33,16 @@ from agentops.logging import logger from agentops.instrumentation.openai_agents.processor import OpenAIAgentsProcessor from agentops.instrumentation.openai_agents.exporter import OpenAIAgentsExporter -from .context import full_prompt_contextvar # Import from .context +from .context import full_prompt_contextvar, agent_name_contextvar, agent_handoffs_contextvar # Import from .context from agentops.instrumentation.common.wrappers import WrapConfig # Keep WrapConfig # Remove wrap, unwrap from common.wrappers as we'll use wrapt directly for custom wrapper # from agentops.instrumentation.common.wrappers import wrap, unwrap -from agentops.instrumentation.common.attributes import AttributeMap # For type hinting from agentops.helpers import safe_serialize # Semantic conventions from AgentOps (Copied from runner_wrappers.py for use in new handler logic) from agentops.semconv import ( - AgentAttributes, - MessageAttributes, - SpanAttributes, - CoreAttributes, AgentOpsSpanKindValues, - WorkflowAttributes, ) # Removed local definition of full_prompt_contextvar @@ -107,30 +101,15 @@ def __init__(self): # OTel BaseInstrumentor __init__ takes no args # Removed property for _is_instrumented, will use direct instance member _is_instrumented_instance_flag - def _extract_agent_runner_attributes_and_set_contextvar( + def _prepare_and_set_agent_contextvars( self, - otel_span: trace.Span, args: Optional[Tuple] = None, kwargs: Optional[Dict] = None, - return_value: Optional[Any] = None, - exception: Optional[Exception] = None, ) -> None: """ - Helper to extract attributes for an 'Agent Run/Turn' span and manage contextvar. - Logic is derived from the original agent_run_turn_attribute_handler. - Sets attributes directly on the provided otel_span. + Helper to extract agent information and set context variables for later use by the exporter. + This method does NOT create or modify OTel spans directly. """ - attributes: AttributeMap = {} - attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.AGENT.value - otel_span_id_for_log = "unknown" - if otel_span and hasattr(otel_span, "get_span_context"): - span_ctx = otel_span.get_span_context() - if span_ctx and hasattr(span_ctx, "span_id"): - otel_span_id_for_log = f"{span_ctx.span_id:016x}" - logger.debug( - f"[_extract_agent_runner_attributes_and_set_contextvar] Set AGENTOPS_SPAN_KIND to '{AgentOpsSpanKindValues.AGENT.value}' for OTel span ID: {otel_span_id_for_log}" - ) - agent_obj: Any = None sdk_input: Any = None @@ -151,220 +130,119 @@ def _extract_agent_runner_attributes_and_set_contextvar( sdk_input = kwargs["input"] current_full_prompt_for_llm = [] + extracted_agent_name: Optional[str] = None + extracted_handoffs: Optional[list[str]] = None if agent_obj: if hasattr(agent_obj, "name") and agent_obj.name: - attributes[AgentAttributes.AGENT_NAME] = str(agent_obj.name) - if hasattr(agent_obj, "model") and agent_obj.model: - attributes[SpanAttributes.LLM_REQUEST_MODEL] = str(agent_obj.model) + extracted_agent_name = str(agent_obj.name) if hasattr(agent_obj, "instructions") and agent_obj.instructions: instructions = str(agent_obj.instructions) - attributes[SpanAttributes.LLM_REQUEST_INSTRUCTIONS] = instructions current_full_prompt_for_llm.append({"role": "system", "content": instructions}) - if hasattr(agent_obj, "tools") and agent_obj.tools: - attributes[AgentAttributes.AGENT_TOOLS] = [str(getattr(t, "name", t)) for t in agent_obj.tools] if hasattr(agent_obj, "handoffs") and agent_obj.handoffs: - attributes[AgentAttributes.HANDOFFS] = [str(getattr(h, "name", h)) for h in agent_obj.handoffs] - if hasattr(agent_obj, "output_type") and agent_obj.output_type: - attributes["gen_ai.output.type"] = str(agent_obj.output_type) + processed_handoffs = [] + for h_item in agent_obj.handoffs: + if isinstance(h_item, str): + processed_handoffs.append(h_item) + elif hasattr(h_item, "agent_name") and h_item.agent_name: # For Handoff callable wrapper + processed_handoffs.append(str(h_item.agent_name)) + elif hasattr(h_item, "name") and h_item.name: # For Agent objects + processed_handoffs.append(str(h_item.name)) + else: + processed_handoffs.append(str(h_item)) # Fallback + extracted_handoffs = processed_handoffs if sdk_input: if isinstance(sdk_input, str): - attributes[MessageAttributes.PROMPT_CONTENT.format(i=0)] = sdk_input - attributes[MessageAttributes.PROMPT_ROLE.format(i=0)] = "user" current_full_prompt_for_llm.append({"role": "user", "content": sdk_input}) elif isinstance(sdk_input, list): - for i, msg in enumerate(sdk_input): + for i, msg in enumerate(sdk_input): # msg is already a dict from sdk_input list if isinstance(msg, dict): role = msg.get("role") content = msg.get("content") - if role: - attributes[MessageAttributes.PROMPT_ROLE.format(i=i)] = str(role) - if content is not None: # Allow empty string for content - serialized_content = safe_serialize(content) - attributes[MessageAttributes.PROMPT_CONTENT.format(i=i)] = serialized_content - if role: # Add to full_prompt only if role and content exist - current_full_prompt_for_llm.append({"role": str(role), "content": serialized_content}) - else: - attributes[WorkflowAttributes.WORKFLOW_INPUT] = safe_serialize(sdk_input) + if role and content is not None: + current_full_prompt_for_llm.append({"role": str(role), "content": safe_serialize(content)}) + + # Set context variables for the exporter to pick up + if extracted_agent_name: + agent_name_contextvar.set(extracted_agent_name) + logger.debug(f"[_prepare_and_set_agent_contextvars] Set agent_name_contextvar to: {extracted_agent_name}") + else: # Ensure it's set to None if no agent_name + agent_name_contextvar.set(None) + + if extracted_handoffs: + agent_handoffs_contextvar.set(extracted_handoffs) + logger.debug( + f"[_prepare_and_set_agent_contextvars] Set agent_handoffs_contextvar to: {safe_serialize(extracted_handoffs)}" + ) + else: # Ensure it's set to None if no handoffs + agent_handoffs_contextvar.set(None) if current_full_prompt_for_llm: full_prompt_contextvar.set(current_full_prompt_for_llm) + logger.debug( + f"[_prepare_and_set_agent_contextvars] Set full_prompt_contextvar to: {safe_serialize(current_full_prompt_for_llm)}" + ) else: - full_prompt_contextvar.set(None) # Ensure reset if no prompt - - if return_value: - if type(return_value).__name__ == "RunResultStreaming": - attributes[SpanAttributes.LLM_REQUEST_STREAMING] = True - - if exception: - attributes[CoreAttributes.ERROR_TYPE] = type(exception).__name__ - # Contextvar is reset in the finally block of the main wrapper - - # Log all collected attributes before filtering - logger.debug( - f"[_extract_agent_runner_attributes_and_set_contextvar] All collected attributes before filtering for OTel span ID {otel_span_id_for_log}: {safe_serialize(attributes)}" - ) - - # Define filter conditions and log them - # For gen_ai.prompt.* attributes (e.g., gen_ai.prompt.0.content, gen_ai.prompt.0.role) - prompt_attr_prefix_to_exclude = "gen_ai.prompt." - - # For gen_ai.request.instructions - # SpanAttributes.LLM_REQUEST_INSTRUCTIONS should resolve to "gen_ai.request.instructions" - instructions_attr_key_to_exclude = SpanAttributes.LLM_REQUEST_INSTRUCTIONS - - logger.debug( - f"[_extract_agent_runner_attributes_and_set_contextvar] Filter: Excluding keys starting with '{prompt_attr_prefix_to_exclude}' for OTel span ID {otel_span_id_for_log}" - ) - logger.debug( - f"[_extract_agent_runner_attributes_and_set_contextvar] Filter: Excluding key equal to '{instructions_attr_key_to_exclude}' (actual value of SpanAttributes.LLM_REQUEST_INSTRUCTIONS) for OTel span ID {otel_span_id_for_log}" - ) - - attributes_for_agent_span = {} - for key, value in attributes.items(): - excluded_by_prompt_filter = key.startswith(prompt_attr_prefix_to_exclude) - excluded_by_instructions_filter = key == instructions_attr_key_to_exclude - - if excluded_by_prompt_filter: - logger.debug( - f"[_extract_agent_runner_attributes_and_set_contextvar] Filtering out key '{key}' for OTel span ID {otel_span_id_for_log} (matched prompt_prefix_to_exclude: '{prompt_attr_prefix_to_exclude}')" - ) - continue - if excluded_by_instructions_filter: - logger.debug( - f"[_extract_agent_runner_attributes_and_set_contextvar] Filtering out key '{key}' for OTel span ID {otel_span_id_for_log} (matched instructions_attr_key_to_exclude: '{instructions_attr_key_to_exclude}')" - ) - continue - - attributes_for_agent_span[key] = value - - logger.debug( - f"[_extract_agent_runner_attributes_and_set_contextvar] Attributes for AGENT span (OTel span ID {otel_span_id_for_log}) after filtering: {safe_serialize(attributes_for_agent_span)}" - ) - for key, value in attributes_for_agent_span.items(): - otel_span.set_attribute(key, value) + full_prompt_contextvar.set(None) def _create_agent_runner_wrapper( - self, wrapped_method_to_call, is_async: bool, trace_name: str, span_kind: OtelSpanKind - ): # Renamed original_method for clarity + self, wrapped_method_to_call, is_async: bool + ): # trace_name and span_kind no longer needed """ Creates a wrapper for an OpenAI Agents Runner method (run, run_sync, run_streamed). - This wrapper starts an OTel span, extracts attributes, manages contextvar for full prompt, - calls the original method, and ensures the span is ended and contextvar is reset. + This wrapper NO LONGER starts an OTel span. It only prepares and sets context variables. """ - otel_tracer = self._tracer # Use the instrumentor's tracer + # otel_tracer = self._tracer # No longer creating spans here if is_async: - # Corrected wrapper signature for wrapt: (wrapped, instance, args, kwargs) - @functools.wraps(wrapped_method_to_call) # Use the passed original method for functools.wraps - async def wrapper_async(wrapped, instance, args, kwargs): - # 'wrapped' is the original unbound method (e.g., Runner.run) - # 'instance' is the Runner class (if called as Runner.run) or Runner instance - # 'args' and 'kwargs' are the arguments passed to Runner.run - - span_attributes_args = args # These are the direct args to Runner.run - span_attributes_kwargs = kwargs - - otel_span = otel_tracer.start_span(name=trace_name, kind=span_kind) - with trace.use_span(otel_span, end_on_exit=False): - exception_obj = None - res = None - try: - # _extract_agent_runner_attributes_and_set_contextvar expects args and kwargs - # as they are passed to the *original method call*. - self._extract_agent_runner_attributes_and_set_contextvar(otel_span, args=args, kwargs=kwargs) - logger.debug( - f"wrapper_async: About to call wrapped method. instance type: {type(instance)}, args: {safe_serialize(args)}, kwargs: {safe_serialize(kwargs)}" - ) - # How to call 'wrapped' depends on whether 'instance' is None (e.g. staticmethod called on class) - # or if 'instance' is the class itself (e.g. classmethod or instance method called on class) - # or if 'instance' is an actual object instance. - # Given the example call `Runner.run(agent, input, ...)` and `Runner.run` being an instance method, - # `instance` will be the `Runner` class. `wrapped` is the unbound method. - # We need to call it as `wrapped(instance, *args, **kwargs)` if it's an instance method - # and `instance` is the actual instance. - # If `instance` is the class, and `wrapped` is an instance method, this is like `Runner.run(Runner, agent, input, ...)` - # which was causing "5 arguments" error. - # The example `Runner.run(agent, input, ...)` implies the SDK handles this. - # So, the call should likely be `wrapped(*args, **kwargs)` if `instance` is the class and `wrapped` is a static/class method - # or if the SDK's `run` method descriptor handles being called from the class. - # If `wrapped` is an instance method, it needs an instance. - # The `main.py` calls `Runner.run(current_agent, input_items, context=context)` - # This means `args` = (current_agent, input_items), `kwargs` = {context: ...} - # The `Runner.run` signature is `run(self, agent, input, ...)` - # The most direct way to replicate the call from main.py is `wrapped(*args, **kwargs)` - # assuming `wrapped` is what `Runner.run` resolves to. - - # If `wrapped` is the unbound instance method `run(self, agent, input, ...)` - # and `instance` is the class `Runner`, then `wrapped(instance, *args, **kwargs)` - # becomes `run(Runner, agent_arg, input_arg, ...)`. This is 3 positional. - # The error "5 were given" for this call is the core puzzle. - - # The error "4 were given" for `wrapped(*args, **kwargs)` means `run(agent_arg, input_arg, ...)` - # was missing `self`. - - # Let's stick to the wrapt convention: the `wrapped` callable should be invoked - # appropriately based on `instance`. - # If `instance` is not None, it's typically `wrapped(instance, *args, **kwargs)`. - # If `instance` is None (e.g. for a static method called via class), it's `wrapped(*args, **kwargs)`. - # Since `Runner.run` is an instance method, and `instance` is `Runner` (the class) - # when called as `Runner.run()`, this is an unbound call. - # The correct way to call an unbound instance method is `MethodType(wrapped, instance_obj)(*args, **kwargs)` - # OR `wrapped(instance_obj, *args, **kwargs)`. - # Here, `instance` is the class. The SDK must handle `Runner.run(agent, input)` by creating an instance. - # So, we should call `wrapped` as it was called in the user's code. - # The `args` and `kwargs` are what the user supplied to `Runner.run`. - # `wrapped` is `Runner.run`. So, `wrapped(*args, **kwargs)` is `Runner.run(*args, **kwargs)`. - - res = await wrapped(*args, **kwargs) # This replicates the original call structure - - self._extract_agent_runner_attributes_and_set_contextvar(otel_span, return_value=res) - otel_span.set_status(Status(StatusCode.OK)) - except Exception as e: - exception_obj = e - self._extract_agent_runner_attributes_and_set_contextvar(otel_span, exception=e) - otel_span.set_status(Status(StatusCode.ERROR, description=str(e))) - otel_span.record_exception(e) - raise # Re-raise the exception - finally: - full_prompt_contextvar.set(None) # Reset contextvar - otel_span.end() - return res + @functools.wraps(wrapped_method_to_call) + async def wrapper_async(wrapped, instance, args, kwargs): + # Initialize context var tokens to their current values before setting new ones + token_full_prompt = full_prompt_contextvar.set(None) + token_agent_name = agent_name_contextvar.set(None) + token_agent_handoffs = agent_handoffs_contextvar.set(None) + res = None + try: + self._prepare_and_set_agent_contextvars(args=args, kwargs=kwargs) + res = await wrapped(*args, **kwargs) + except Exception as e: + logger.error(f"Exception in wrapped async agent call: {e}", exc_info=True) + raise + finally: + # Reset context variables to their state before this wrapper ran + if token_full_prompt is not None: + full_prompt_contextvar.reset(token_full_prompt) + if token_agent_name is not None: + agent_name_contextvar.reset(token_agent_name) + if token_agent_handoffs is not None: + agent_handoffs_contextvar.reset(token_agent_handoffs) + return res return wrapper_async else: # Synchronous wrapper - # Corrected wrapper signature for wrapt: (wrapped, instance, args, kwargs) - @functools.wraps(wrapped_method_to_call) # Use the passed original method for functools.wraps - def wrapper_sync(wrapped, instance, args, kwargs): - span_attributes_args = args - span_attributes_kwargs = kwargs - - otel_span = otel_tracer.start_span(name=trace_name, kind=span_kind) - with trace.use_span(otel_span, end_on_exit=False): - exception_obj = None - res = None - try: - self._extract_agent_runner_attributes_and_set_contextvar(otel_span, args=args, kwargs=kwargs) - logger.debug( - f"wrapper_sync: About to call wrapped method. instance type: {type(instance)}, args: {safe_serialize(args)}, kwargs: {safe_serialize(kwargs)}" - ) - res = wrapped(*args, **kwargs) # Replicates original call structure - - self._extract_agent_runner_attributes_and_set_contextvar(otel_span, return_value=res) - otel_span.set_status(Status(StatusCode.OK)) - except Exception as e: - exception_obj = e - self._extract_agent_runner_attributes_and_set_contextvar(otel_span, exception=e) - otel_span.set_status(Status(StatusCode.ERROR, description=str(e))) - otel_span.record_exception(e) - raise - finally: - full_prompt_contextvar.set(None) - otel_span.end() - return res + @functools.wraps(wrapped_method_to_call) + def wrapper_sync(wrapped, instance, args, kwargs): + token_full_prompt = full_prompt_contextvar.set(None) + token_agent_name = agent_name_contextvar.set(None) + token_agent_handoffs = agent_handoffs_contextvar.set(None) + res = None + try: + self._prepare_and_set_agent_contextvars(args=args, kwargs=kwargs) + res = wrapped(*args, **kwargs) + except Exception as e: + logger.error(f"Exception in wrapped sync agent call: {e}", exc_info=True) + raise + finally: + if token_full_prompt is not None: + full_prompt_contextvar.reset(token_full_prompt) + if token_agent_name is not None: + agent_name_contextvar.reset(token_agent_name) + if token_agent_handoffs is not None: + agent_handoffs_contextvar.reset(token_agent_handoffs) + return res return wrapper_sync @@ -433,8 +311,7 @@ def _instrument(self, **kwargs): custom_wrapper = self._create_agent_runner_wrapper( original_method, is_async=config.is_async, - trace_name=config.trace_name, - span_kind=config.span_kind, + # trace_name and span_kind are no longer passed ) # Apply the wrapper using wrapt From 04a48cb4a1ecc894b3c46cf23ff136b59f6c6515 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 15:56:51 +0530 Subject: [PATCH 06/16] cleanup --- .../openai_agents/attributes/common.py | 146 ++---------------- .../instrumentation/openai_agents/exporter.py | 55 ++----- .../openai_agents/instrumentor.py | 67 +++----- 3 files changed, 49 insertions(+), 219 deletions(-) diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index 17aa7d6d2..422b768c2 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -5,7 +5,7 @@ for extracting and formatting attributes according to OpenTelemetry semantic conventions. """ -from typing import Any +from typing import Any, List, Dict, Optional from agentops.logging import logger from agentops.semconv import ( AgentAttributes, @@ -24,8 +24,7 @@ from agentops.instrumentation.openai.attributes.response import get_response_response_attributes from agentops.instrumentation.openai_agents import LIBRARY_NAME, LIBRARY_VERSION -# Import full_prompt_contextvar from the new context module -from ..context import full_prompt_contextvar, agent_name_contextvar, agent_handoffs_contextvar +from openai_agents.context import full_prompt_contextvar, agent_name_contextvar, agent_handoffs_contextvar from agentops.instrumentation.openai_agents.attributes.model import ( get_model_attributes, get_model_config_attributes, @@ -43,15 +42,10 @@ } -# Attribute mapping for FunctionSpanData - Corrected for Tool Semantics -# FunctionSpanData has: name (str), input (Any), output (Any), from_agent (str, optional) CORRECTED_FUNCTION_TOOL_ATTRIBUTES: AttributeMap = { ToolAttributes.TOOL_NAME: "name", - ToolAttributes.TOOL_PARAMETERS: "input", # Will be serialized - ToolAttributes.TOOL_RESULT: "output", # Will be serialized - # 'from_agent' could be mapped to a custom attribute if needed, or ignored for pure tool spans - # For now, let's focus on standard tool attributes. - # AgentAttributes.FROM_AGENT: "from_agent", # Example if we wanted to keep it + ToolAttributes.TOOL_PARAMETERS: "input", + ToolAttributes.TOOL_RESULT: "output", } @@ -94,10 +88,6 @@ WorkflowAttributes.WORKFLOW_INPUT: "input", } -from typing import List, Dict, Optional # Ensure these are imported if not already at the top - -# (Make sure logger, safe_serialize, MessageAttributes are imported at the top of the file) - def _get_llm_messages_attributes(messages: Optional[List[Dict]], attribute_base: str) -> AttributeMap: """ @@ -106,9 +96,6 @@ def _get_llm_messages_attributes(messages: Optional[List[Dict]], attribute_base: """ attributes: AttributeMap = {} if not messages: - logger.debug( - f"[_get_llm_messages_attributes] No messages provided for base: {attribute_base}. Returning empty attributes." - ) return attributes if not isinstance(messages, list): logger.warning( @@ -197,59 +184,27 @@ def get_agent_span_attributes(span_data: Any) -> AttributeMap: Returns: Dictionary of attributes for agent span """ - # attributes = _extract_attributes_from_mapping(span_data, AGENT_SPAN_ATTRIBUTES) # We will set attributes more selectively attributes = {} # Start with an empty dict attributes.update(get_common_attributes()) # Get common OTel/AgentOps attributes - # Set AGENTOPS_SPAN_KIND to 'agent' attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.AGENT.value - logger.debug( - f"[get_agent_span_attributes] Set AGENTOPS_SPAN_KIND to '{AgentOpsSpanKindValues.AGENT.value}' for AgentSpanData id: {getattr(span_data, 'id', 'N/A')}" - ) # Get agent name from contextvar (set by instrumentor wrapper) ctx_agent_name = agent_name_contextvar.get() if ctx_agent_name: attributes[AgentAttributes.AGENT_NAME] = ctx_agent_name - logger.debug(f"[get_agent_span_attributes] Set AGENT_NAME from contextvar: {ctx_agent_name}") - elif hasattr(span_data, "name") and span_data.name: # Fallback to span_data.name if contextvar is not set + elif hasattr(span_data, "name") and span_data.name: attributes[AgentAttributes.AGENT_NAME] = str(span_data.name) - logger.debug(f"[get_agent_span_attributes] Set AGENT_NAME from span_data.name: {str(span_data.name)}") - # Get simplified handoffs from contextvar (set by instrumentor wrapper) ctx_handoffs = agent_handoffs_contextvar.get() if ctx_handoffs: - attributes[AgentAttributes.HANDOFFS] = safe_serialize(ctx_handoffs) # Ensure it's a JSON string array - logger.debug(f"[get_agent_span_attributes] Set HANDOFFS from contextvar: {safe_serialize(ctx_handoffs)}") - elif ( - hasattr(span_data, "handoffs") and span_data.handoffs - ): # Fallback for safety, though contextvar should be primary - # This fallback might re-introduce complex objects if not careful, - # but contextvar is the intended source for the simplified list. + attributes[AgentAttributes.HANDOFFS] = safe_serialize(ctx_handoffs) + elif hasattr(span_data, "handoffs") and span_data.handoffs: attributes[AgentAttributes.HANDOFFS] = safe_serialize(span_data.handoffs) - logger.debug( - f"[get_agent_span_attributes] Set HANDOFFS from span_data.handoffs: {safe_serialize(span_data.handoffs)}" - ) - - # Selectively add other relevant attributes from AgentSpanData if needed, avoiding LLM details - if hasattr(span_data, "input") and span_data.input is not None: - # Avoid setting detailed prompt input here. If a general workflow input is desired, use a non-LLM semconv. - # For now, let's assume WORKFLOW_INPUT is too generic and might contain prompts. - # attributes[WorkflowAttributes.WORKFLOW_INPUT] = safe_serialize(span_data.input) - pass - - if hasattr(span_data, "output") and span_data.output is not None: - # Similar to input, avoid detailed LLM output. - # attributes[WorkflowAttributes.FINAL_OUTPUT] = safe_serialize(span_data.output) - pass if hasattr(span_data, "tools") and span_data.tools: - # Serialize tools if they are simple list of strings or basic structures attributes[AgentAttributes.AGENT_TOOLS] = safe_serialize([str(getattr(t, "name", t)) for t in span_data.tools]) - logger.debug( - f"[get_agent_span_attributes] Final attributes for AgentSpanData id {getattr(span_data, 'id', 'N/A')}: {safe_serialize(attributes)}" - ) return attributes @@ -268,22 +223,14 @@ def get_function_span_attributes(span_data: Any) -> AttributeMap: attributes.update(get_common_attributes()) attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.TOOL.value - # Determine tool status based on presence of error in span_data (if available) or assume success - # The main SDK's Span object has an 'error' field. If span_data itself doesn't, - # this status might be better set by the exporter which has access to the full SDK Span. - # For now, assuming success if no error attribute is directly on span_data. - # A more robust way would be to check the OTel span's status if this handler is called *after* error processing. + # Determine tool status based on presence of error if hasattr(span_data, "error") and span_data.error: attributes[ToolAttributes.TOOL_STATUS] = ToolStatus.FAILED.value else: - # This might be premature if output isn't available yet (on_span_start) - # but the exporter handles separate start/end, so this is for the full attribute set. if hasattr(span_data, "output") and span_data.output is not None: attributes[ToolAttributes.TOOL_STATUS] = ToolStatus.SUCCEEDED.value else: - # If called on start, or if output is None without error, it's executing or status unknown yet. - # The exporter should ideally set this based on the OTel span status later. - # For now, we won't set a default status here if output is not yet available. + # Status will be set by exporter based on span lifecycle pass # If from_agent is available on span_data, add it. @@ -336,65 +283,36 @@ def get_response_span_attributes(span_data: Any) -> AttributeMap: # Read full prompt from contextvar (set by instrumentor's wrapper) full_prompt_from_context = full_prompt_contextvar.get() if full_prompt_from_context: - logger.debug( - f"[get_response_span_attributes] Found full_prompt_from_context: {safe_serialize(full_prompt_from_context)}" - ) attributes.update(_get_llm_messages_attributes(full_prompt_from_context, "gen_ai.prompt")) prompt_attributes_set = True else: - logger.debug("[get_response_span_attributes] full_prompt_contextvar is None.") - # Fallback to SDK's request_messages if contextvar wasn't available or didn't have prompt if ( span_data.response and hasattr(span_data.response, "request_messages") and span_data.response.request_messages ): prompt_messages_from_sdk = span_data.response.request_messages - logger.debug( - f"[get_response_span_attributes] Using agents.Response.request_messages: {safe_serialize(prompt_messages_from_sdk)}" - ) attributes.update(_get_llm_messages_attributes(prompt_messages_from_sdk, "gen_ai.prompt")) prompt_attributes_set = True - else: - logger.debug( - "[get_response_span_attributes] No prompt source: neither contextvar nor SDK request_messages available/sufficient." - ) - # Process response (completion, usage, model etc.) using the existing get_response_response_attributes - # This function handles the `span_data.response` object which is an `agents.Response` + # Process response attributes if span_data.response: - # get_response_response_attributes from openai instrumentation expects an OpenAIObject-like response. - # We need to ensure it can handle agents.Response or adapt. - # For now, let's assume it primarily extracts completion and usage, and we've handled prompt. - - # Call the original function to get completion, usage, model, etc. - # It might also try to set prompt attributes, which we might need to reconcile. openai_style_response_attrs = get_response_response_attributes(span_data.response) - # If we've already set prompt attributes from our preferred source (wrapper context), - # remove any prompt attributes that get_response_response_attributes might have set, - # to avoid conflicts or overwriting with potentially less complete data. + # Remove prompt attributes if already set from context if prompt_attributes_set: - keys_to_remove = [ - k for k in openai_style_response_attrs if k.startswith("gen_ai.prompt") - ] # e.g. "gen_ai.prompt" - if keys_to_remove: - for key in keys_to_remove: - if key in openai_style_response_attrs: # Check if key exists before deleting - del openai_style_response_attrs[key] - - # Remove gen_ai.request.tools if present, as it's not appropriate for the LLM response span itself - # The LLM span should reflect the LLM's output (completion, tool_calls selected), not the definition of tools it was given. + keys_to_remove = [k for k in openai_style_response_attrs if k.startswith("gen_ai.prompt")] + for key in keys_to_remove: + if key in openai_style_response_attrs: + del openai_style_response_attrs[key] + + # Remove tool definitions from response attributes if "gen_ai.request.tools" in openai_style_response_attrs: del openai_style_response_attrs["gen_ai.request.tools"] attributes.update(openai_style_response_attrs) - # Ensure LLM span kind is set attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.LLM.value - logger.debug( - f"[get_response_span_attributes] Set AGENTOPS_SPAN_KIND to '{AgentOpsSpanKindValues.LLM.value}' for span_data.id: {getattr(span_data, 'id', 'N/A')}" - ) return attributes @@ -403,34 +321,21 @@ def get_generation_span_attributes(span_data: Any) -> AttributeMap: Generations are requests made to the `openai.completions` endpoint. - # TODO this has not been extensively tested yet as there is a flag that needs ot be set to use the - # completions API with the Agents SDK. - # We can enable chat.completions API by calling: - # `from agents import set_default_openai_api` - # `set_default_openai_api("chat_completions")` - Args: span_data: The GenerationSpanData object Returns: Dictionary of attributes for generation span """ - logger.debug( - f"[get_generation_span_attributes] Called for span_data.id: {getattr(span_data, 'id', 'N/A')}, name: {getattr(span_data, 'name', 'N/A')}, type: {span_data.__class__.__name__}" - ) attributes = _extract_attributes_from_mapping( span_data, GENERATION_SPAN_ATTRIBUTES ) # This might set gen_ai.prompt from span_data.input attributes.update(get_common_attributes()) - prompt_attributes_set = False # Read full prompt from contextvar (set by instrumentor's wrapper) full_prompt_from_context = full_prompt_contextvar.get() if full_prompt_from_context: - logger.debug( - f"[get_generation_span_attributes] Found full_prompt_from_context: {safe_serialize(full_prompt_from_context)}" - ) # Clear any prompt set by _extract_attributes_from_mapping from span_data.input prompt_keys_to_clear = [k for k in attributes if k.startswith("gen_ai.prompt")] if SpanAttributes.LLM_PROMPTS in attributes: @@ -440,11 +345,7 @@ def get_generation_span_attributes(span_data: Any) -> AttributeMap: del attributes[key] attributes.update(_get_llm_messages_attributes(full_prompt_from_context, "gen_ai.prompt")) - prompt_attributes_set = True elif SpanAttributes.LLM_PROMPTS in attributes: # Fallback to span_data.input if contextvar is empty - logger.debug( - "[get_generation_span_attributes] full_prompt_contextvar is None. Using LLM_PROMPTS from span_data.input." - ) raw_prompt_input = attributes.pop(SpanAttributes.LLM_PROMPTS) formatted_prompt_for_llm = [] if isinstance(raw_prompt_input, str): @@ -469,15 +370,6 @@ def get_generation_span_attributes(span_data: Any) -> AttributeMap: if formatted_prompt_for_llm: attributes.update(_get_llm_messages_attributes(formatted_prompt_for_llm, "gen_ai.prompt")) - prompt_attributes_set = True - else: - logger.debug( - "[get_generation_span_attributes] No prompt data from span_data.input or it was not formattable." - ) - else: - logger.debug( - "[get_generation_span_attributes] No prompt source: contextvar is None and no LLM_PROMPTS in attributes from span_data.input." - ) if span_data.model: attributes.update(get_model_attributes(span_data.model)) @@ -490,11 +382,7 @@ def get_generation_span_attributes(span_data: Any) -> AttributeMap: if span_data.model_config: attributes.update(get_model_config_attributes(span_data.model_config)) - # Ensure LLM span kind is set attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.LLM.value - logger.debug( - f"[get_generation_span_attributes] Set AGENTOPS_SPAN_KIND to '{AgentOpsSpanKindValues.LLM.value}' for span_data.id: {getattr(span_data, 'id', 'N/A')}. Current attributes include agentops.span.kind: {attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND)}" - ) return attributes diff --git a/agentops/instrumentation/openai_agents/exporter.py b/agentops/instrumentation/openai_agents/exporter.py index e9b314095..02f10540c 100644 --- a/agentops/instrumentation/openai_agents/exporter.py +++ b/agentops/instrumentation/openai_agents/exporter.py @@ -37,8 +37,6 @@ from agentops.instrumentation.openai_agents.attributes.common import ( get_span_attributes, ) -# Removed: from agentops.helpers import safe_serialize -# full_prompt_contextvar will be imported and used by attributes.common, not directly here. def log_otel_trace_id(span_type): @@ -143,7 +141,6 @@ def export_trace(self, trace: Any) -> None: return # Determine if this is a trace end event using status field - # We use the status field to determine if this is an end event is_end_event = hasattr(trace, "status") and trace.status == StatusCode.OK.name trace_lookup_key = _get_span_lookup_key(trace_id, trace_id) attributes = get_base_trace_attributes(trace) @@ -198,7 +195,7 @@ def export_trace(self, trace: Any) -> None: "span": span, "span_type": "TraceSpan", "trace_id": trace_id, - "parent_id": None, # Trace spans don't have parents + "parent_id": None, } else: span.end() @@ -242,7 +239,6 @@ def _get_parent_context(self, trace_id: str, span_id: str, parent_id: Optional[s # If we couldn't find the parent by ID, use the current span context as parent if not parent_span_ctx: - # Get the current span context from the context API ctx = context_api.get_current() parent_span_ctx = trace_api.get_current_span(ctx).get_span_context() @@ -305,89 +301,71 @@ def export_span(self, span: Any) -> None: trace_id = getattr(span, "trace_id", "unknown") parent_id = getattr(span, "parent_id", None) - # logger.debug(f"[Exporter] export_span called for SDK span_id: {span_id}, name: {getattr(span_data, 'name', span_type)}, type: {span_type}, trace_id: {trace_id}, parent_id: {parent_id}") - # Check if this is a span end event is_end_event = hasattr(span, "status") and span.status == StatusCode.OK.name # Unique lookup key for this span span_lookup_key = _get_span_lookup_key(trace_id, span_id) - # span_data augmentation with _full_prompt_from_wrapper_context is removed. - # attributes/common.py will now directly get the contextvar. - - # Log content of span_data.response before getting attributes - # if hasattr(span_data, 'response') and span_data.response is not None: - # logger.debug(f"[Exporter] SDK span_id: {span_id} - span_data.response before get_span_attributes: {safe_serialize(vars(span_data.response)) if hasattr(span_data.response, '__dict__') else safe_serialize(span_data.response)}") - # elif span_type in ["ResponseSpanData", "GenerationSpanData"]: # Only log absence for relevant types - # logger.debug(f"[Exporter] SDK span_id: {span_id} - span_data.response is None or not present before get_span_attributes for {span_type}.") - attributes = get_base_span_attributes(span) # Basic attributes from the SDK span object span_specific_attributes = get_span_attributes(span_data) # Type-specific attributes from SpanData attributes.update(span_specific_attributes) - # logger.debug(f"[Exporter] SDK span_id: {span_id} - Combined attributes before OTel span creation/update: {safe_serialize(attributes)}") - if is_end_event: - # For end events, ensure all attributes from span_data are captured - # get_span_attributes should ideally get everything needed based on the final state of span_data - pass # Attributes are already updated above + pass # Log the trace ID for debugging and correlation with AgentOps API log_otel_trace_id(span_type) # For start events, create a new span and store it (don't end it) if not is_end_event: - span_name = get_span_name(span) # Get OTel span name - span_kind = get_span_kind(span) # Get OTel span kind + span_name = get_span_name(span) + span_kind = get_span_kind(span) parent_span_ctx = self._get_parent_context(trace_id, span_id, parent_id) otel_span = self._create_span_with_parent( name=span_name, kind=span_kind, attributes=attributes, parent_ctx=parent_span_ctx ) - # logger.debug(f"[Exporter] SDK span_id: {span_id} (START event) - CREATED OTel span with OTel_span_id: {otel_span.context.span_id if otel_span else 'None'}") if not isinstance(otel_span, NonRecordingSpan): self._span_map[span_lookup_key] = otel_span - self._active_spans[span_id] = { # Use SDK span_id as key for _active_spans + self._active_spans[span_id] = { "span": otel_span, "span_type": span_type, - "trace_id": trace_id, # SDK trace_id - "parent_id": parent_id, # SDK parent_id + "trace_id": trace_id, + "parent_id": parent_id, } - # logger.debug(f"[Exporter] SDK span_id: {span_id} (START event) - Stored OTel span in _span_map and _active_spans.") - self._handle_span_error(span, otel_span) # Handle error for start event too + self._handle_span_error(span, otel_span) return # For end events, check if we already have the span if span_lookup_key in self._span_map: existing_span = self._span_map[span_lookup_key] - # logger.debug(f"[Exporter] SDK span_id: {span_id} (END event) - Found existing OTel span in _span_map with OTel_span_id: {existing_span.context.span_id}") span_is_ended = False if isinstance(existing_span, Span) and hasattr(existing_span, "_end_time"): span_is_ended = existing_span._end_time is not None - # logger.debug(f"[Exporter] SDK span_id: {span_id} (END event) - Existing OTel span was_ended: {span_is_ended}") if not span_is_ended: - for key, value in attributes.items(): # Update with final attributes + # Update with final attributes + for key, value in attributes.items(): existing_span.set_attribute(key, value) - # logger.debug(f"[Exporter] SDK span_id: {span_id} (END event) - Updated attributes on existing OTel span.") existing_span.set_status( Status(StatusCode.OK if getattr(span, "status", "OK") == "OK" else StatusCode.ERROR) ) self._handle_span_error(span, existing_span) existing_span.end() - # logger.debug(f"[Exporter] SDK span_id: {span_id} (END event) - ENDED existing OTel span.") - else: # Span already ended, create a new one (should be rare if logic is correct) + # Span already ended, create a new one (should be rare if logic is correct) + else: logger.warning( f"[Exporter] SDK span_id: {span_id} (END event) - Attempting to end an ALREADY ENDED OTel span: {span_lookup_key}. Creating a new one instead." ) self.create_span(span, span_type, attributes, is_already_ended=True) - else: # No existing span found for end event, create a new one + # No existing span found for end event, create a new one + else: logger.warning( f"[Exporter] SDK span_id: {span_id} (END event) - No active OTel span found for end event: {span_lookup_key}. Creating a new one." ) @@ -395,7 +373,6 @@ def export_span(self, span: Any) -> None: self._active_spans.pop(span_id, None) self._span_map.pop(span_lookup_key, None) - # logger.debug(f"[Exporter] SDK span_id: {span_id} (END event) - Popped from _active_spans and _span_map.") def create_span( self, span: Any, span_type: str, attributes: Dict[str, Any], is_already_ended: bool = False @@ -411,11 +388,8 @@ def create_span( span_type: The type of span being created attributes: The attributes to set on the span """ - # For simplicity and backward compatibility, use None as the parent context - # In a real implementation, you might want to look up the parent parent_ctx = None if hasattr(span, "parent_id") and span.parent_id: - # Get parent context from trace_id and parent_id if available parent_ctx = self._get_parent_context( getattr(span, "trace_id", "unknown"), getattr(span, "id", "unknown"), span.parent_id ) @@ -423,7 +397,6 @@ def create_span( name = get_span_name(span) kind = get_span_kind(span) - # Create the span with parent context and end it immediately self._create_span_with_parent( name=name, kind=kind, attributes=attributes, parent_ctx=parent_ctx, end_immediately=True ) diff --git a/agentops/instrumentation/openai_agents/instrumentor.py b/agentops/instrumentation/openai_agents/instrumentor.py index 723f16e38..486ca7c27 100644 --- a/agentops/instrumentation/openai_agents/instrumentor.py +++ b/agentops/instrumentation/openai_agents/instrumentor.py @@ -27,8 +27,6 @@ from opentelemetry.trace import SpanKind as OtelSpanKind # Renamed to avoid conflict from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore import wrapt # For wrapping -# Remove local contextvars import, will import from .context -# import contextvars from agentops.logging import logger from agentops.instrumentation.openai_agents.processor import OpenAIAgentsProcessor @@ -36,19 +34,12 @@ from .context import full_prompt_contextvar, agent_name_contextvar, agent_handoffs_contextvar # Import from .context from agentops.instrumentation.common.wrappers import WrapConfig # Keep WrapConfig -# Remove wrap, unwrap from common.wrappers as we'll use wrapt directly for custom wrapper -# from agentops.instrumentation.common.wrappers import wrap, unwrap from agentops.helpers import safe_serialize -# Semantic conventions from AgentOps (Copied from runner_wrappers.py for use in new handler logic) from agentops.semconv import ( AgentOpsSpanKindValues, ) -# Removed local definition of full_prompt_contextvar - -# Define AGENT_RUNNER_WRAP_CONFIGS locally (adapted from runner_wrappers.py) -# Handler field is removed as the new wrapper incorporates this logic. _OPENAI_AGENTS_RUNNER_MODULE = "agents.run" _OPENAI_AGENTS_RUNNER_CLASS = "Runner" @@ -89,18 +80,15 @@ class OpenAIAgentsInstrumentor(BaseInstrumentor): _processor = None _exporter = None _default_processor = None - # _is_instrumented_flag_for_instance = {} # Using instance member instead - def __init__(self): # OTel BaseInstrumentor __init__ takes no args + def __init__(self): super().__init__() - self._tracer = None # Ensure _tracer is initialized - self._is_instrumented_instance_flag = False # Instance-specific flag + self._tracer = None + self._is_instrumented_instance_flag = False logger.debug( f"OpenAIAgentsInstrumentor (id: {id(self)}) created. Initial _is_instrumented_instance_flag: {self._is_instrumented_instance_flag}" ) - # Removed property for _is_instrumented, will use direct instance member _is_instrumented_instance_flag - def _prepare_and_set_agent_contextvars( self, args: Optional[Tuple] = None, @@ -113,9 +101,6 @@ def _prepare_and_set_agent_contextvars( agent_obj: Any = None sdk_input: Any = None - # The `args` passed to this function are the direct *args of the wrapped method (e.g., Runner.run), - # so args[0] is 'agent', args[1] is 'input'. - # `kwargs` are the direct **kwargs of the wrapped method. if args: if len(args) > 0: agent_obj = args[0] @@ -124,9 +109,9 @@ def _prepare_and_set_agent_contextvars( # Allow kwargs to override or provide if not in args if kwargs: - if "agent" in kwargs and kwargs["agent"] is not None: # Check for None explicitly + if "agent" in kwargs and kwargs["agent"] is not None: agent_obj = kwargs["agent"] - if "input" in kwargs and kwargs["input"] is not None: # Check for None explicitly + if "input" in kwargs and kwargs["input"] is not None: sdk_input = kwargs["input"] current_full_prompt_for_llm = [] @@ -144,30 +129,29 @@ def _prepare_and_set_agent_contextvars( for h_item in agent_obj.handoffs: if isinstance(h_item, str): processed_handoffs.append(h_item) - elif hasattr(h_item, "agent_name") and h_item.agent_name: # For Handoff callable wrapper + elif hasattr(h_item, "agent_name") and h_item.agent_name: processed_handoffs.append(str(h_item.agent_name)) - elif hasattr(h_item, "name") and h_item.name: # For Agent objects + elif hasattr(h_item, "name") and h_item.name: processed_handoffs.append(str(h_item.name)) else: - processed_handoffs.append(str(h_item)) # Fallback + processed_handoffs.append(str(h_item)) extracted_handoffs = processed_handoffs if sdk_input: if isinstance(sdk_input, str): current_full_prompt_for_llm.append({"role": "user", "content": sdk_input}) elif isinstance(sdk_input, list): - for i, msg in enumerate(sdk_input): # msg is already a dict from sdk_input list + for i, msg in enumerate(sdk_input): if isinstance(msg, dict): role = msg.get("role") content = msg.get("content") if role and content is not None: current_full_prompt_for_llm.append({"role": str(role), "content": safe_serialize(content)}) - # Set context variables for the exporter to pick up if extracted_agent_name: agent_name_contextvar.set(extracted_agent_name) logger.debug(f"[_prepare_and_set_agent_contextvars] Set agent_name_contextvar to: {extracted_agent_name}") - else: # Ensure it's set to None if no agent_name + else: agent_name_contextvar.set(None) if extracted_handoffs: @@ -175,7 +159,7 @@ def _prepare_and_set_agent_contextvars( logger.debug( f"[_prepare_and_set_agent_contextvars] Set agent_handoffs_contextvar to: {safe_serialize(extracted_handoffs)}" ) - else: # Ensure it's set to None if no handoffs + else: agent_handoffs_contextvar.set(None) if current_full_prompt_for_llm: @@ -186,15 +170,11 @@ def _prepare_and_set_agent_contextvars( else: full_prompt_contextvar.set(None) - def _create_agent_runner_wrapper( - self, wrapped_method_to_call, is_async: bool - ): # trace_name and span_kind no longer needed + def _create_agent_runner_wrapper(self, wrapped_method_to_call, is_async: bool): """ Creates a wrapper for an OpenAI Agents Runner method (run, run_sync, run_streamed). This wrapper NO LONGER starts an OTel span. It only prepares and sets context variables. """ - # otel_tracer = self._tracer # No longer creating spans here - if is_async: @functools.wraps(wrapped_method_to_call) @@ -221,7 +201,7 @@ async def wrapper_async(wrapped, instance, args, kwargs): return res return wrapper_async - else: # Synchronous wrapper + else: @functools.wraps(wrapped_method_to_call) def wrapper_sync(wrapped, instance, args, kwargs): @@ -275,7 +255,7 @@ def _instrument(self, **kwargs): from agents.tracing.processors import default_processor logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) getting default processor...") - if getattr(self, "_default_processor", None) is None: # Check if already stored by this instance + if getattr(self, "_default_processor", None) is None: self._default_processor = default_processor() logger.debug( f"OpenAIAgentsInstrumentor (id: {id(self)}) Stored original default processor: {self._default_processor}" @@ -293,12 +273,8 @@ def _instrument(self, **kwargs): for config in AGENT_RUNNER_WRAP_CONFIGS: try: module_path, class_name, method_name = config.package, config.class_name, config.method_name - # Ensure the module is imported correctly to find the class - # __import__ returns the top-level package, so need to getattr down - # For "agents.run", __import__("agents.run", fromlist=["Runner"]) - # module = __import__(module_path, fromlist=[class_name]) # This might not work for nested modules correctly - # A more robust way to get the class + # Get the class from the module path parts = module_path.split(".") current_module = __import__(parts[0]) for part in parts[1:]: @@ -314,12 +290,9 @@ def _instrument(self, **kwargs): # trace_name and span_kind are no longer passed ) - # Apply the wrapper using wrapt - # wrapt.wrap_function_wrapper expects module as string, name as string - # For class methods, name is 'ClassName.method_name' wrapt.wrap_function_wrapper( - module_path, # Module name as string - f"{class_name}.{method_name}", # 'ClassName.method_name' + module_path, + f"{class_name}.{method_name}", custom_wrapper, ) logger.info( @@ -380,12 +353,9 @@ def _uninstrument(self, **kwargs): current_module = getattr(current_module, part) cls_to_wrap = getattr(current_module, class_name) - - # Get the potentially wrapped method method_to_unwrap = getattr(cls_to_wrap, method_name, None) if hasattr(method_to_unwrap, "__wrapped__"): - # If it's a wrapt proxy, __wrapped__ gives the original original = method_to_unwrap.__wrapped__ setattr(cls_to_wrap, method_name, original) logger.info( @@ -394,9 +364,8 @@ def _uninstrument(self, **kwargs): elif isinstance(method_to_unwrap, functools.partial) and hasattr( method_to_unwrap.func, "__wrapped__" ): - # Handle cases where it might be a partial of a wrapper (less common here but good to check) original = method_to_unwrap.func.__wrapped__ - setattr(cls_to_wrap, method_name, original) # This might be tricky if partial had specific args + setattr(cls_to_wrap, method_name, original) logger.info( f"OpenAIAgentsInstrumentor (id: {id(self)}) Removed custom wrapper (from partial) for {class_name}.{method_name}" ) From a5c5cbe9769c96bdf9a38fac4738d20fc6166b5f Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 16:16:43 +0530 Subject: [PATCH 07/16] correct import and variable name --- .../instrumentation/openai_agents/attributes/common.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index 422b768c2..ba8ab153a 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -24,7 +24,11 @@ from agentops.instrumentation.openai.attributes.response import get_response_response_attributes from agentops.instrumentation.openai_agents import LIBRARY_NAME, LIBRARY_VERSION -from openai_agents.context import full_prompt_contextvar, agent_name_contextvar, agent_handoffs_contextvar +from agentops.instrumentation.openai_agents.context import ( + full_prompt_contextvar, + agent_name_contextvar, + agent_handoffs_contextvar, +) from agentops.instrumentation.openai_agents.attributes.model import ( get_model_attributes, get_model_config_attributes, @@ -42,7 +46,7 @@ } -CORRECTED_FUNCTION_TOOL_ATTRIBUTES: AttributeMap = { +FUNCTION_TOOL_ATTRIBUTES: AttributeMap = { ToolAttributes.TOOL_NAME: "name", ToolAttributes.TOOL_PARAMETERS: "input", ToolAttributes.TOOL_RESULT: "output", @@ -219,7 +223,7 @@ def get_function_span_attributes(span_data: Any) -> AttributeMap: Returns: Dictionary of attributes for function span """ - attributes = _extract_attributes_from_mapping(span_data, CORRECTED_FUNCTION_TOOL_ATTRIBUTES) + attributes = _extract_attributes_from_mapping(span_data, FUNCTION_TOOL_ATTRIBUTES) attributes.update(get_common_attributes()) attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.TOOL.value From 485a7ff902def1faf1e478da5d6c64a1971bcfeb Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 21:44:07 +0530 Subject: [PATCH 08/16] remove context propagation and fix issue with llm attributes --- .../openai/attributes/response.py | 1 - .../openai_agents/attributes/common.py | 164 ++++++---- .../instrumentation/openai_agents/context.py | 8 - .../openai_agents/instrumentor.py | 280 +----------------- 4 files changed, 121 insertions(+), 332 deletions(-) delete mode 100644 agentops/instrumentation/openai_agents/context.py diff --git a/agentops/instrumentation/openai/attributes/response.py b/agentops/instrumentation/openai/attributes/response.py index d0a821f8f..8655de135 100644 --- a/agentops/instrumentation/openai/attributes/response.py +++ b/agentops/instrumentation/openai/attributes/response.py @@ -83,7 +83,6 @@ SpanAttributes.LLM_RESPONSE_ID: "id", SpanAttributes.LLM_REQUEST_MODEL: "model", SpanAttributes.LLM_RESPONSE_MODEL: "model", - SpanAttributes.LLM_PROMPTS: "instructions", SpanAttributes.LLM_REQUEST_MAX_TOKENS: "max_output_tokens", SpanAttributes.LLM_REQUEST_TEMPERATURE: "temperature", SpanAttributes.LLM_REQUEST_TOP_P: "top_p", diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index ba8ab153a..02be52505 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -24,11 +24,6 @@ from agentops.instrumentation.openai.attributes.response import get_response_response_attributes from agentops.instrumentation.openai_agents import LIBRARY_NAME, LIBRARY_VERSION -from agentops.instrumentation.openai_agents.context import ( - full_prompt_contextvar, - agent_name_contextvar, - agent_handoffs_contextvar, -) from agentops.instrumentation.openai_agents.attributes.model import ( get_model_attributes, get_model_config_attributes, @@ -46,10 +41,13 @@ } +# Attribute mapping for FunctionSpanData FUNCTION_TOOL_ATTRIBUTES: AttributeMap = { ToolAttributes.TOOL_NAME: "name", ToolAttributes.TOOL_PARAMETERS: "input", ToolAttributes.TOOL_RESULT: "output", + # AgentAttributes.AGENT_NAME: "name", + AgentAttributes.FROM_AGENT: "from_agent", } @@ -68,7 +66,9 @@ # Attribute mapping for ResponseSpanData RESPONSE_SPAN_ATTRIBUTES: AttributeMap = { - WorkflowAttributes.WORKFLOW_INPUT: "input", + # Don't map input here as it causes double serialization + # We handle prompts manually in get_response_span_attributes + SpanAttributes.LLM_RESPONSE_MODEL: "model", } @@ -193,17 +193,12 @@ def get_agent_span_attributes(span_data: Any) -> AttributeMap: attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.AGENT.value - # Get agent name from contextvar (set by instrumentor wrapper) - ctx_agent_name = agent_name_contextvar.get() - if ctx_agent_name: - attributes[AgentAttributes.AGENT_NAME] = ctx_agent_name - elif hasattr(span_data, "name") and span_data.name: + # Get agent name directly from span_data + if hasattr(span_data, "name") and span_data.name: attributes[AgentAttributes.AGENT_NAME] = str(span_data.name) - ctx_handoffs = agent_handoffs_contextvar.get() - if ctx_handoffs: - attributes[AgentAttributes.HANDOFFS] = safe_serialize(ctx_handoffs) - elif hasattr(span_data, "handoffs") and span_data.handoffs: + # Get handoffs directly from span_data + if hasattr(span_data, "handoffs") and span_data.handoffs: attributes[AgentAttributes.HANDOFFS] = safe_serialize(span_data.handoffs) if hasattr(span_data, "tools") and span_data.tools: @@ -278,37 +273,111 @@ def get_response_span_attributes(span_data: Any) -> AttributeMap: Returns: Dictionary of attributes for response span """ + # Debug logging + import json + print(f"\n[DEBUG] get_response_span_attributes called") + print(f"[DEBUG] span_data type: {type(span_data)}") + print(f"[DEBUG] span_data attributes: {[attr for attr in dir(span_data) if not attr.startswith('_')]}") + + # Check what's in span_data.input + if hasattr(span_data, "input"): + print(f"[DEBUG] span_data.input type: {type(span_data.input)}") + try: + print(f"[DEBUG] span_data.input content: {json.dumps(span_data.input, indent=2) if span_data.input else 'None'}") + except: + print(f"[DEBUG] span_data.input content (repr): {repr(span_data.input)}") + + # Check for response and instructions + if hasattr(span_data, "response") and span_data.response: + print(f"[DEBUG] span_data.response type: {type(span_data.response)}") + if hasattr(span_data.response, "instructions"): + print(f"[DEBUG] span_data.response.instructions: {span_data.response.instructions}") + # Get basic attributes from mapping attributes = _extract_attributes_from_mapping(span_data, RESPONSE_SPAN_ATTRIBUTES) attributes.update(get_common_attributes()) - prompt_attributes_set = False - - # Read full prompt from contextvar (set by instrumentor's wrapper) - full_prompt_from_context = full_prompt_contextvar.get() - if full_prompt_from_context: - attributes.update(_get_llm_messages_attributes(full_prompt_from_context, "gen_ai.prompt")) - prompt_attributes_set = True - else: - if ( - span_data.response - and hasattr(span_data.response, "request_messages") - and span_data.response.request_messages - ): - prompt_messages_from_sdk = span_data.response.request_messages - attributes.update(_get_llm_messages_attributes(prompt_messages_from_sdk, "gen_ai.prompt")) - prompt_attributes_set = True + # Build complete prompt list from system instructions and conversation history + prompt_messages = [] + + # Add system instruction as first message if available + if span_data.response and hasattr(span_data.response, "instructions") and span_data.response.instructions: + prompt_messages.append({ + "role": "system", + "content": span_data.response.instructions + }) + print(f"[DEBUG] Added system message from instructions") + + # Add conversation history from span_data.input + if hasattr(span_data, "input") and span_data.input: + if isinstance(span_data.input, list): + for i, msg in enumerate(span_data.input): + print(f"[DEBUG] Processing message {i}: type={type(msg)}") + if isinstance(msg, dict): + role = msg.get("role") + content = msg.get("content") + print(f"[DEBUG] Message {i}: role={role}, content type={type(content)}") + + # Handle different content formats + if role and content is not None: + # If content is a string, use it directly + if isinstance(content, str): + prompt_messages.append({ + "role": role, + "content": content + }) + # If content is a list (complex assistant message), extract text + elif isinstance(content, list): + text_parts = [] + for item in content: + if isinstance(item, dict): + # Handle output_text type + if item.get("type") == "output_text": + text_parts.append(item.get("text", "")) + # Handle other text content + elif "text" in item: + text_parts.append(item.get("text", "")) + # Handle annotations with text + elif "annotations" in item and "text" in item: + text_parts.append(item.get("text", "")) + + if text_parts: + prompt_messages.append({ + "role": role, + "content": " ".join(text_parts) + }) + # If content is a dict, try to extract text + elif isinstance(content, dict): + if "text" in content: + prompt_messages.append({ + "role": role, + "content": content["text"] + }) + elif isinstance(span_data.input, str): + # Single string input - assume it's a user message + prompt_messages.append({ + "role": "user", + "content": span_data.input + }) + print(f"[DEBUG] Added user message from string input") + + print(f"[DEBUG] Total prompt_messages: {len(prompt_messages)}") + for i, msg in enumerate(prompt_messages): + print(f"[DEBUG] prompt_messages[{i}]: role={msg.get('role')}, content_len={len(str(msg.get('content', '')))}") + + # Format prompts using existing function + if prompt_messages: + attributes.update(_get_llm_messages_attributes(prompt_messages, "gen_ai.prompt")) # Process response attributes if span_data.response: openai_style_response_attrs = get_response_response_attributes(span_data.response) - # Remove prompt attributes if already set from context - if prompt_attributes_set: - keys_to_remove = [k for k in openai_style_response_attrs if k.startswith("gen_ai.prompt")] - for key in keys_to_remove: - if key in openai_style_response_attrs: - del openai_style_response_attrs[key] + # Remove any prompt attributes from response processing since we handle them above + keys_to_remove = [k for k in openai_style_response_attrs if k.startswith("gen_ai.prompt")] + for key in keys_to_remove: + if key in openai_style_response_attrs: + del openai_style_response_attrs[key] # Remove tool definitions from response attributes if "gen_ai.request.tools" in openai_style_response_attrs: @@ -317,6 +386,11 @@ def get_response_span_attributes(span_data: Any) -> AttributeMap: attributes.update(openai_style_response_attrs) attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.LLM.value + + print(f"[DEBUG] Final attributes keys: {list(attributes.keys())}") + prompt_keys = [k for k in attributes.keys() if k.startswith("gen_ai.prompt")] + print(f"[DEBUG] Prompt attribute keys: {prompt_keys}") + return attributes @@ -336,20 +410,8 @@ def get_generation_span_attributes(span_data: Any) -> AttributeMap: ) # This might set gen_ai.prompt from span_data.input attributes.update(get_common_attributes()) - # Read full prompt from contextvar (set by instrumentor's wrapper) - full_prompt_from_context = full_prompt_contextvar.get() - - if full_prompt_from_context: - # Clear any prompt set by _extract_attributes_from_mapping from span_data.input - prompt_keys_to_clear = [k for k in attributes if k.startswith("gen_ai.prompt")] - if SpanAttributes.LLM_PROMPTS in attributes: - prompt_keys_to_clear.append(SpanAttributes.LLM_PROMPTS) - for key in set(prompt_keys_to_clear): - if key in attributes: - del attributes[key] - - attributes.update(_get_llm_messages_attributes(full_prompt_from_context, "gen_ai.prompt")) - elif SpanAttributes.LLM_PROMPTS in attributes: # Fallback to span_data.input if contextvar is empty + # Process prompt from span_data.input + if SpanAttributes.LLM_PROMPTS in attributes: raw_prompt_input = attributes.pop(SpanAttributes.LLM_PROMPTS) formatted_prompt_for_llm = [] if isinstance(raw_prompt_input, str): diff --git a/agentops/instrumentation/openai_agents/context.py b/agentops/instrumentation/openai_agents/context.py deleted file mode 100644 index 023d8e16c..000000000 --- a/agentops/instrumentation/openai_agents/context.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -Context variables for OpenAI Agents instrumentation. -""" -import contextvars - -full_prompt_contextvar = contextvars.ContextVar("agentops_full_prompt_context", default=None) -agent_name_contextvar = contextvars.ContextVar("agentops_agent_name_context", default=None) -agent_handoffs_contextvar = contextvars.ContextVar("agentops_agent_handoffs_context", default=None) diff --git a/agentops/instrumentation/openai_agents/instrumentor.py b/agentops/instrumentation/openai_agents/instrumentor.py index 486ca7c27..a87468de5 100644 --- a/agentops/instrumentation/openai_agents/instrumentor.py +++ b/agentops/instrumentation/openai_agents/instrumentor.py @@ -4,78 +4,26 @@ tracing API for observability. It captures detailed information about agent execution, tool usage, LLM requests, and token metrics. -The implementation uses a clean separation between exporters and processors. The exporter -translates Agent spans into OpenTelemetry spans with appropriate semantic conventions. -The processor implements the tracing interface, collects metrics, and manages timing data. +The implementation uses the SDK's TracingProcessor interface as the integration point. +The processor receives span data from the SDK's built-in tracing system, and the exporter +translates these spans into OpenTelemetry spans with appropriate semantic conventions. -We use the built-in add_trace_processor hook for all functionality. Streaming support -would require monkey-patching the run method of `Runner`, but doesn't really get us -more data than we already have, since the `Response` object is always passed to us -from the `agents.tracing` module. - -TODO Calls to the OpenAI API are not available in this tracing context, so we may -need to monkey-patch the `openai` from here to get that data. While we do have -separate instrumentation for the OpenAI API, in order to get it to nest with the -spans we create here, it's probably easier (or even required) that we incorporate -that here as well. +No method wrapping or context variables are needed - the SDK handles all span creation +and data collection internally. """ -from typing import Collection, Tuple, Dict, Any, Optional -import functools # For functools.wraps +from typing import Collection -from opentelemetry import trace # Needed for tracer -from opentelemetry.trace import SpanKind as OtelSpanKind # Renamed to avoid conflict +from opentelemetry import trace from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore -import wrapt # For wrapping from agentops.logging import logger from agentops.instrumentation.openai_agents.processor import OpenAIAgentsProcessor from agentops.instrumentation.openai_agents.exporter import OpenAIAgentsExporter -from .context import full_prompt_contextvar, agent_name_contextvar, agent_handoffs_contextvar # Import from .context -from agentops.instrumentation.common.wrappers import WrapConfig # Keep WrapConfig - -from agentops.helpers import safe_serialize - -from agentops.semconv import ( - AgentOpsSpanKindValues, -) - -_OPENAI_AGENTS_RUNNER_MODULE = "agents.run" -_OPENAI_AGENTS_RUNNER_CLASS = "Runner" - -AGENT_RUNNER_WRAP_CONFIGS = [ - WrapConfig( - trace_name=AgentOpsSpanKindValues.AGENT.value, # This will be the OTel span name - package=_OPENAI_AGENTS_RUNNER_MODULE, - class_name=_OPENAI_AGENTS_RUNNER_CLASS, - method_name="run", - handler=None, - is_async=True, - span_kind=OtelSpanKind.INTERNAL, - ), - WrapConfig( - trace_name=AgentOpsSpanKindValues.AGENT.value, - package=_OPENAI_AGENTS_RUNNER_MODULE, - class_name=_OPENAI_AGENTS_RUNNER_CLASS, - method_name="run_sync", - handler=None, - is_async=False, - span_kind=OtelSpanKind.INTERNAL, - ), - WrapConfig( - trace_name=AgentOpsSpanKindValues.AGENT.value, - package=_OPENAI_AGENTS_RUNNER_MODULE, - class_name=_OPENAI_AGENTS_RUNNER_CLASS, - method_name="run_streamed", - handler=None, - is_async=True, - span_kind=OtelSpanKind.INTERNAL, - ), -] class OpenAIAgentsInstrumentor(BaseInstrumentor): - """An instrumentor for OpenAI Agents SDK that primarily uses the built-in tracing API.""" + """An instrumentor for OpenAI Agents SDK that uses the built-in tracing API.""" _processor = None _exporter = None @@ -89,143 +37,6 @@ def __init__(self): f"OpenAIAgentsInstrumentor (id: {id(self)}) created. Initial _is_instrumented_instance_flag: {self._is_instrumented_instance_flag}" ) - def _prepare_and_set_agent_contextvars( - self, - args: Optional[Tuple] = None, - kwargs: Optional[Dict] = None, - ) -> None: - """ - Helper to extract agent information and set context variables for later use by the exporter. - This method does NOT create or modify OTel spans directly. - """ - agent_obj: Any = None - sdk_input: Any = None - - if args: - if len(args) > 0: - agent_obj = args[0] - if len(args) > 1: - sdk_input = args[1] - - # Allow kwargs to override or provide if not in args - if kwargs: - if "agent" in kwargs and kwargs["agent"] is not None: - agent_obj = kwargs["agent"] - if "input" in kwargs and kwargs["input"] is not None: - sdk_input = kwargs["input"] - - current_full_prompt_for_llm = [] - extracted_agent_name: Optional[str] = None - extracted_handoffs: Optional[list[str]] = None - - if agent_obj: - if hasattr(agent_obj, "name") and agent_obj.name: - extracted_agent_name = str(agent_obj.name) - if hasattr(agent_obj, "instructions") and agent_obj.instructions: - instructions = str(agent_obj.instructions) - current_full_prompt_for_llm.append({"role": "system", "content": instructions}) - if hasattr(agent_obj, "handoffs") and agent_obj.handoffs: - processed_handoffs = [] - for h_item in agent_obj.handoffs: - if isinstance(h_item, str): - processed_handoffs.append(h_item) - elif hasattr(h_item, "agent_name") and h_item.agent_name: - processed_handoffs.append(str(h_item.agent_name)) - elif hasattr(h_item, "name") and h_item.name: - processed_handoffs.append(str(h_item.name)) - else: - processed_handoffs.append(str(h_item)) - extracted_handoffs = processed_handoffs - - if sdk_input: - if isinstance(sdk_input, str): - current_full_prompt_for_llm.append({"role": "user", "content": sdk_input}) - elif isinstance(sdk_input, list): - for i, msg in enumerate(sdk_input): - if isinstance(msg, dict): - role = msg.get("role") - content = msg.get("content") - if role and content is not None: - current_full_prompt_for_llm.append({"role": str(role), "content": safe_serialize(content)}) - - if extracted_agent_name: - agent_name_contextvar.set(extracted_agent_name) - logger.debug(f"[_prepare_and_set_agent_contextvars] Set agent_name_contextvar to: {extracted_agent_name}") - else: - agent_name_contextvar.set(None) - - if extracted_handoffs: - agent_handoffs_contextvar.set(extracted_handoffs) - logger.debug( - f"[_prepare_and_set_agent_contextvars] Set agent_handoffs_contextvar to: {safe_serialize(extracted_handoffs)}" - ) - else: - agent_handoffs_contextvar.set(None) - - if current_full_prompt_for_llm: - full_prompt_contextvar.set(current_full_prompt_for_llm) - logger.debug( - f"[_prepare_and_set_agent_contextvars] Set full_prompt_contextvar to: {safe_serialize(current_full_prompt_for_llm)}" - ) - else: - full_prompt_contextvar.set(None) - - def _create_agent_runner_wrapper(self, wrapped_method_to_call, is_async: bool): - """ - Creates a wrapper for an OpenAI Agents Runner method (run, run_sync, run_streamed). - This wrapper NO LONGER starts an OTel span. It only prepares and sets context variables. - """ - if is_async: - - @functools.wraps(wrapped_method_to_call) - async def wrapper_async(wrapped, instance, args, kwargs): - # Initialize context var tokens to their current values before setting new ones - token_full_prompt = full_prompt_contextvar.set(None) - token_agent_name = agent_name_contextvar.set(None) - token_agent_handoffs = agent_handoffs_contextvar.set(None) - res = None - try: - self._prepare_and_set_agent_contextvars(args=args, kwargs=kwargs) - res = await wrapped(*args, **kwargs) - except Exception as e: - logger.error(f"Exception in wrapped async agent call: {e}", exc_info=True) - raise - finally: - # Reset context variables to their state before this wrapper ran - if token_full_prompt is not None: - full_prompt_contextvar.reset(token_full_prompt) - if token_agent_name is not None: - agent_name_contextvar.reset(token_agent_name) - if token_agent_handoffs is not None: - agent_handoffs_contextvar.reset(token_agent_handoffs) - return res - - return wrapper_async - else: - - @functools.wraps(wrapped_method_to_call) - def wrapper_sync(wrapped, instance, args, kwargs): - token_full_prompt = full_prompt_contextvar.set(None) - token_agent_name = agent_name_contextvar.set(None) - token_agent_handoffs = agent_handoffs_contextvar.set(None) - res = None - try: - self._prepare_and_set_agent_contextvars(args=args, kwargs=kwargs) - res = wrapped(*args, **kwargs) - except Exception as e: - logger.error(f"Exception in wrapped sync agent call: {e}", exc_info=True) - raise - finally: - if token_full_prompt is not None: - full_prompt_contextvar.reset(token_full_prompt) - if token_agent_name is not None: - agent_name_contextvar.reset(token_agent_name) - if token_agent_handoffs is not None: - agent_handoffs_contextvar.reset(token_agent_handoffs) - return res - - return wrapper_sync - def instrumentation_dependencies(self) -> Collection[str]: """Return packages required for instrumentation.""" return ["openai-agents >= 0.0.1"] @@ -267,43 +78,6 @@ def _instrument(self, **kwargs): f"OpenAIAgentsInstrumentor (id: {id(self)}) Replaced default processor with OpenAIAgentsProcessor." ) - logger.debug( - f"OpenAIAgentsInstrumentor (id: {id(self)}) Applying Runner method wrappers using new custom wrapper..." - ) - for config in AGENT_RUNNER_WRAP_CONFIGS: - try: - module_path, class_name, method_name = config.package, config.class_name, config.method_name - - # Get the class from the module path - parts = module_path.split(".") - current_module = __import__(parts[0]) - for part in parts[1:]: - current_module = getattr(current_module, part) - - cls_to_wrap = getattr(current_module, class_name) - original_method = getattr(cls_to_wrap, method_name) - - # Create the specific wrapper for this method - custom_wrapper = self._create_agent_runner_wrapper( - original_method, - is_async=config.is_async, - # trace_name and span_kind are no longer passed - ) - - wrapt.wrap_function_wrapper( - module_path, - f"{class_name}.{method_name}", - custom_wrapper, - ) - logger.info( - f"OpenAIAgentsInstrumentor (id: {id(self)}) Applied custom wrapper for {class_name}.{method_name}" - ) - except Exception as e_wrap: - logger.error( - f"OpenAIAgentsInstrumentor (id: {id(self)}) Failed to apply custom wrapper for {config.method_name}: {e_wrap}", - exc_info=True, - ) - self._is_instrumented_instance_flag = True logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) set _is_instrumented_instance_flag to True.") @@ -342,44 +116,6 @@ def _uninstrument(self, **kwargs): self._processor = None self._exporter = None - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) Removing Runner method wrappers...") - for config in AGENT_RUNNER_WRAP_CONFIGS: - try: - module_path, class_name, method_name = config.package, config.class_name, config.method_name - - parts = module_path.split(".") - current_module = __import__(parts[0]) - for part in parts[1:]: - current_module = getattr(current_module, part) - - cls_to_wrap = getattr(current_module, class_name) - method_to_unwrap = getattr(cls_to_wrap, method_name, None) - - if hasattr(method_to_unwrap, "__wrapped__"): - original = method_to_unwrap.__wrapped__ - setattr(cls_to_wrap, method_name, original) - logger.info( - f"OpenAIAgentsInstrumentor (id: {id(self)}) Removed custom wrapper for {class_name}.{method_name}" - ) - elif isinstance(method_to_unwrap, functools.partial) and hasattr( - method_to_unwrap.func, "__wrapped__" - ): - original = method_to_unwrap.func.__wrapped__ - setattr(cls_to_wrap, method_name, original) - logger.info( - f"OpenAIAgentsInstrumentor (id: {id(self)}) Removed custom wrapper (from partial) for {class_name}.{method_name}" - ) - else: - logger.warning( - f"OpenAIAgentsInstrumentor (id: {id(self)}) Wrapper not found or not a recognized wrapt wrapper for {class_name}.{method_name}. Current type: {type(method_to_unwrap)}" - ) - - except Exception as e_unwrap: - logger.error( - f"OpenAIAgentsInstrumentor (id: {id(self)}) Failed to remove custom wrapper for {config.method_name}: {e_unwrap}", - exc_info=True, - ) - self._is_instrumented_instance_flag = False logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) set _is_instrumented_instance_flag to False.") From 40899f26a49ddb5685996219554058a269560af2 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 22:44:24 +0530 Subject: [PATCH 09/16] linting --- .../openai_agents/attributes/common.py | 71 ++++--------------- 1 file changed, 12 insertions(+), 59 deletions(-) diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index 02be52505..d04d05f9d 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -273,59 +273,30 @@ def get_response_span_attributes(span_data: Any) -> AttributeMap: Returns: Dictionary of attributes for response span """ - # Debug logging - import json - print(f"\n[DEBUG] get_response_span_attributes called") - print(f"[DEBUG] span_data type: {type(span_data)}") - print(f"[DEBUG] span_data attributes: {[attr for attr in dir(span_data) if not attr.startswith('_')]}") - - # Check what's in span_data.input - if hasattr(span_data, "input"): - print(f"[DEBUG] span_data.input type: {type(span_data.input)}") - try: - print(f"[DEBUG] span_data.input content: {json.dumps(span_data.input, indent=2) if span_data.input else 'None'}") - except: - print(f"[DEBUG] span_data.input content (repr): {repr(span_data.input)}") - - # Check for response and instructions - if hasattr(span_data, "response") and span_data.response: - print(f"[DEBUG] span_data.response type: {type(span_data.response)}") - if hasattr(span_data.response, "instructions"): - print(f"[DEBUG] span_data.response.instructions: {span_data.response.instructions}") - # Get basic attributes from mapping attributes = _extract_attributes_from_mapping(span_data, RESPONSE_SPAN_ATTRIBUTES) attributes.update(get_common_attributes()) # Build complete prompt list from system instructions and conversation history prompt_messages = [] - + # Add system instruction as first message if available if span_data.response and hasattr(span_data.response, "instructions") and span_data.response.instructions: - prompt_messages.append({ - "role": "system", - "content": span_data.response.instructions - }) - print(f"[DEBUG] Added system message from instructions") - + prompt_messages.append({"role": "system", "content": span_data.response.instructions}) + # Add conversation history from span_data.input if hasattr(span_data, "input") and span_data.input: if isinstance(span_data.input, list): - for i, msg in enumerate(span_data.input): - print(f"[DEBUG] Processing message {i}: type={type(msg)}") + for msg in span_data.input: if isinstance(msg, dict): role = msg.get("role") content = msg.get("content") - print(f"[DEBUG] Message {i}: role={role}, content type={type(content)}") - + # Handle different content formats if role and content is not None: # If content is a string, use it directly if isinstance(content, str): - prompt_messages.append({ - "role": role, - "content": content - }) + prompt_messages.append({"role": role, "content": content}) # If content is a list (complex assistant message), extract text elif isinstance(content, list): text_parts = [] @@ -340,31 +311,17 @@ def get_response_span_attributes(span_data: Any) -> AttributeMap: # Handle annotations with text elif "annotations" in item and "text" in item: text_parts.append(item.get("text", "")) - + if text_parts: - prompt_messages.append({ - "role": role, - "content": " ".join(text_parts) - }) + prompt_messages.append({"role": role, "content": " ".join(text_parts)}) # If content is a dict, try to extract text elif isinstance(content, dict): if "text" in content: - prompt_messages.append({ - "role": role, - "content": content["text"] - }) + prompt_messages.append({"role": role, "content": content["text"]}) elif isinstance(span_data.input, str): # Single string input - assume it's a user message - prompt_messages.append({ - "role": "user", - "content": span_data.input - }) - print(f"[DEBUG] Added user message from string input") - - print(f"[DEBUG] Total prompt_messages: {len(prompt_messages)}") - for i, msg in enumerate(prompt_messages): - print(f"[DEBUG] prompt_messages[{i}]: role={msg.get('role')}, content_len={len(str(msg.get('content', '')))}") - + prompt_messages.append({"role": "user", "content": span_data.input}) + # Format prompts using existing function if prompt_messages: attributes.update(_get_llm_messages_attributes(prompt_messages, "gen_ai.prompt")) @@ -386,11 +343,7 @@ def get_response_span_attributes(span_data: Any) -> AttributeMap: attributes.update(openai_style_response_attrs) attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.LLM.value - - print(f"[DEBUG] Final attributes keys: {list(attributes.keys())}") - prompt_keys = [k for k in attributes.keys() if k.startswith("gen_ai.prompt")] - print(f"[DEBUG] Prompt attribute keys: {prompt_keys}") - + return attributes From 15a27d6ce1c7fb807d9e7a9835ac8c50f0a9c295 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 23:13:18 +0530 Subject: [PATCH 10/16] some more cleanup --- .../openai_agents/attributes/common.py | 26 +++---- .../instrumentation/openai_agents/exporter.py | 18 ++++- .../openai_agents/instrumentor.py | 73 +++++++------------ 3 files changed, 50 insertions(+), 67 deletions(-) diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index d04d05f9d..5856cdfdc 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -111,14 +111,14 @@ def _get_llm_messages_attributes(messages: Optional[List[Dict]], attribute_base: if isinstance(msg_dict, dict): role = msg_dict.get("role") content = msg_dict.get("content") - name = msg_dict.get("name") # For named messages if ever used - tool_calls = msg_dict.get("tool_calls") # For assistant messages with tool calls - tool_call_id = msg_dict.get("tool_call_id") # For tool_call_output messages + name = msg_dict.get("name") + tool_calls = msg_dict.get("tool_calls") + tool_call_id = msg_dict.get("tool_call_id") # Common role and content if role: attributes[f"{attribute_base}.{i}.role"] = str(role) - if content is not None: # Ensure content can be an empty string but not None without being set + if content is not None: attributes[f"{attribute_base}.{i}.content"] = safe_serialize(content) # Optional name for some roles @@ -130,7 +130,7 @@ def _get_llm_messages_attributes(messages: Optional[List[Dict]], attribute_base: for tc_idx, tc_dict in enumerate(tool_calls): if isinstance(tc_dict, dict): tc_id = tc_dict.get("id") - tc_type = tc_dict.get("type") # e.g., "function" + tc_type = tc_dict.get("type") tc_function_data = tc_dict.get("function") if tc_function_data and isinstance(tc_function_data, dict): @@ -144,13 +144,13 @@ def _get_llm_messages_attributes(messages: Optional[List[Dict]], attribute_base: attributes[f"{base_tool_call_key_formatted}.type"] = str(tc_type) if tc_func_name: attributes[f"{base_tool_call_key_formatted}.function.name"] = str(tc_func_name) - if tc_func_args is not None: # Arguments can be an empty string + if tc_func_args is not None: attributes[f"{base_tool_call_key_formatted}.function.arguments"] = safe_serialize( tc_func_args ) # Tool call ID (specific to tool_call_output messages) - if tool_call_id: # This is for the result of a tool call + if tool_call_id: attributes[f"{attribute_base}.{i}.tool_call_id"] = str(tool_call_id) else: # If a message is not a dict, serialize its representation @@ -188,8 +188,8 @@ def get_agent_span_attributes(span_data: Any) -> AttributeMap: Returns: Dictionary of attributes for agent span """ - attributes = {} # Start with an empty dict - attributes.update(get_common_attributes()) # Get common OTel/AgentOps attributes + attributes = {} + attributes.update(get_common_attributes()) attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.AGENT.value @@ -232,7 +232,6 @@ def get_function_span_attributes(span_data: Any) -> AttributeMap: # Status will be set by exporter based on span lifecycle pass - # If from_agent is available on span_data, add it. if hasattr(span_data, "from_agent") and span_data.from_agent: attributes[f"{AgentAttributes.AGENT}.calling_tool.name"] = str(span_data.from_agent) @@ -358,12 +357,9 @@ def get_generation_span_attributes(span_data: Any) -> AttributeMap: Returns: Dictionary of attributes for generation span """ - attributes = _extract_attributes_from_mapping( - span_data, GENERATION_SPAN_ATTRIBUTES - ) # This might set gen_ai.prompt from span_data.input + attributes = _extract_attributes_from_mapping(span_data, GENERATION_SPAN_ATTRIBUTES) attributes.update(get_common_attributes()) - # Process prompt from span_data.input if SpanAttributes.LLM_PROMPTS in attributes: raw_prompt_input = attributes.pop(SpanAttributes.LLM_PROMPTS) formatted_prompt_for_llm = [] @@ -393,11 +389,9 @@ def get_generation_span_attributes(span_data: Any) -> AttributeMap: if span_data.model: attributes.update(get_model_attributes(span_data.model)) - # Process output for GenerationSpanData if available if span_data.output: attributes.update(get_generation_output_attributes(span_data.output)) - # Add model config attributes if present if span_data.model_config: attributes.update(get_model_config_attributes(span_data.model_config)) diff --git a/agentops/instrumentation/openai_agents/exporter.py b/agentops/instrumentation/openai_agents/exporter.py index 02f10540c..3a61cb79f 100644 --- a/agentops/instrumentation/openai_agents/exporter.py +++ b/agentops/instrumentation/openai_agents/exporter.py @@ -141,6 +141,7 @@ def export_trace(self, trace: Any) -> None: return # Determine if this is a trace end event using status field + # We use the status field to determine if this is an end event is_end_event = hasattr(trace, "status") and trace.status == StatusCode.OK.name trace_lookup_key = _get_span_lookup_key(trace_id, trace_id) attributes = get_base_trace_attributes(trace) @@ -239,6 +240,7 @@ def _get_parent_context(self, trace_id: str, span_id: str, parent_id: Optional[s # If we couldn't find the parent by ID, use the current span context as parent if not parent_span_ctx: + # Get the current span context from the context API ctx = context_api.get_current() parent_span_ctx = trace_api.get_current_span(ctx).get_span_context() @@ -307,11 +309,12 @@ def export_span(self, span: Any) -> None: # Unique lookup key for this span span_lookup_key = _get_span_lookup_key(trace_id, span_id) - attributes = get_base_span_attributes(span) # Basic attributes from the SDK span object - span_specific_attributes = get_span_attributes(span_data) # Type-specific attributes from SpanData + attributes = get_base_span_attributes(span) + span_specific_attributes = get_span_attributes(span_data) attributes.update(span_specific_attributes) if is_end_event: + # Update all attributes for end events pass # Log the trace ID for debugging and correlation with AgentOps API @@ -319,15 +322,20 @@ def export_span(self, span: Any) -> None: # For start events, create a new span and store it (don't end it) if not is_end_event: + # Process the span based on its type + # TODO span_name should come from the attributes module span_name = get_span_name(span) span_kind = get_span_kind(span) + # Get parent context for proper nesting parent_span_ctx = self._get_parent_context(trace_id, span_id, parent_id) + # Create the span with proper parent context otel_span = self._create_span_with_parent( name=span_name, kind=span_kind, attributes=attributes, parent_ctx=parent_span_ctx ) + # Store the span for later reference if not isinstance(otel_span, NonRecordingSpan): self._span_map[span_lookup_key] = otel_span self._active_spans[span_id] = { @@ -337,7 +345,9 @@ def export_span(self, span: Any) -> None: "parent_id": parent_id, } + # Handle any error information self._handle_span_error(span, otel_span) + # DO NOT end the span for start events - we want to keep it open for updates return # For end events, check if we already have the span @@ -361,13 +371,13 @@ def export_span(self, span: Any) -> None: # Span already ended, create a new one (should be rare if logic is correct) else: logger.warning( - f"[Exporter] SDK span_id: {span_id} (END event) - Attempting to end an ALREADY ENDED OTel span: {span_lookup_key}. Creating a new one instead." + f"[Exporter] SDK span_id: {span_id} (END event) - Attempting to end an ALREADY ENDED span: {span_lookup_key}. Creating a new one instead." ) self.create_span(span, span_type, attributes, is_already_ended=True) # No existing span found for end event, create a new one else: logger.warning( - f"[Exporter] SDK span_id: {span_id} (END event) - No active OTel span found for end event: {span_lookup_key}. Creating a new one." + f"[Exporter] SDK span_id: {span_id} (END event) - No active span found for end event: {span_lookup_key}. Creating a new one." ) self.create_span(span, span_type, attributes, is_already_ended=True) diff --git a/agentops/instrumentation/openai_agents/instrumentor.py b/agentops/instrumentation/openai_agents/instrumentor.py index a87468de5..fc4c76a3f 100644 --- a/agentops/instrumentation/openai_agents/instrumentor.py +++ b/agentops/instrumentation/openai_agents/instrumentor.py @@ -4,12 +4,21 @@ tracing API for observability. It captures detailed information about agent execution, tool usage, LLM requests, and token metrics. -The implementation uses the SDK's TracingProcessor interface as the integration point. -The processor receives span data from the SDK's built-in tracing system, and the exporter -translates these spans into OpenTelemetry spans with appropriate semantic conventions. +The implementation uses a clean separation between exporters and processors. The exporter + translates Agent spans into OpenTelemetry spans with appropriate semantic conventions. -No method wrapping or context variables are needed - the SDK handles all span creation -and data collection internally. + The processor implements the tracing interface, collects metrics, and manages timing data. + + We use the built-in add_trace_processor hook for all functionality. Streaming support + would require monkey-patching the run method of `Runner`, but doesn't really get us + more data than we already have, since the `Response` object is always passed to us + from the `agents.tracing` module. + + TODO Calls to the OpenAI API are not available in this tracing context, so we may + need to monkey-patch the `openai` from here to get that data. While we do have + separate instrumentation for the OpenAI API, in order to get it to nest with the + spans we create here, it's probably easier (or even required) that we incorporate + that here as well. """ from typing import Collection @@ -33,9 +42,6 @@ def __init__(self): super().__init__() self._tracer = None self._is_instrumented_instance_flag = False - logger.debug( - f"OpenAIAgentsInstrumentor (id: {id(self)}) created. Initial _is_instrumented_instance_flag: {self._is_instrumented_instance_flag}" - ) def instrumentation_dependencies(self) -> Collection[str]: """Return packages required for instrumentation.""" @@ -43,85 +49,58 @@ def instrumentation_dependencies(self) -> Collection[str]: def _instrument(self, **kwargs): """Instrument the OpenAI Agents SDK.""" - logger.debug( - f"OpenAIAgentsInstrumentor (id: {id(self)}) _instrument START. Current _is_instrumented_instance_flag: {self._is_instrumented_instance_flag}" - ) if self._is_instrumented_instance_flag: - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) already instrumented. Skipping.") - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) _instrument END (skipped)") + logger.debug("OpenAI Agents SDK already instrumented. Skipping.") return tracer_provider = kwargs.get("tracer_provider") if self._tracer is None: + logger.debug("OpenAI Agents SDK tracer is None, creating new tracer.") self._tracer = trace.get_tracer("agentops.instrumentation.openai_agents", "0.1.0") - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) using tracer: {self._tracer}") try: - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) creating exporter and processor.") self._exporter = OpenAIAgentsExporter(tracer_provider=tracer_provider) self._processor = OpenAIAgentsProcessor( exporter=self._exporter, ) + + # Replace the default processor with our processor from agents import set_trace_processors from agents.tracing.processors import default_processor - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) getting default processor...") if getattr(self, "_default_processor", None) is None: self._default_processor = default_processor() - logger.debug( - f"OpenAIAgentsInstrumentor (id: {id(self)}) Stored original default processor: {self._default_processor}" - ) - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) setting trace processors to: {self._processor}") + # Store reference to default processor for later restoration set_trace_processors([self._processor]) - logger.debug( - f"OpenAIAgentsInstrumentor (id: {id(self)}) Replaced default processor with OpenAIAgentsProcessor." - ) - self._is_instrumented_instance_flag = True - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) set _is_instrumented_instance_flag to True.") except Exception as e: - logger.warning(f"OpenAIAgentsInstrumentor (id: {id(self)}) Failed to instrument: {e}", exc_info=True) - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) _instrument END") + logger.warning(f"Failed to instrument OpenAI Agents SDK: {e}", exc_info=True) def _uninstrument(self, **kwargs): """Remove instrumentation from OpenAI Agents SDK.""" - logger.debug( - f"OpenAIAgentsInstrumentor (id: {id(self)}) _uninstrument START. Current _is_instrumented_instance_flag: {self._is_instrumented_instance_flag}" - ) if not self._is_instrumented_instance_flag: - logger.debug( - f"OpenAIAgentsInstrumentor (id: {id(self)}) not currently instrumented. Skipping uninstrument." - ) - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) _uninstrument END (skipped)") + logger.debug("OpenAI Agents SDK not currently instrumented. Skipping uninstrument.") return try: + # Clean up any active spans in the exporter if hasattr(self, "_exporter") and self._exporter: - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) Cleaning up exporter.") if hasattr(self._exporter, "cleanup"): self._exporter.cleanup() + # Put back the default processor from agents import set_trace_processors - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) Attempting to restore default processor.") if hasattr(self, "_default_processor") and self._default_processor: - logger.debug( - f"OpenAIAgentsInstrumentor (id: {id(self)}) Restoring default processor: {self._default_processor}" - ) set_trace_processors([self._default_processor]) self._default_processor = None else: - logger.warning(f"OpenAIAgentsInstrumentor (id: {id(self)}) No default_processor to restore.") + logger.warning("OpenAI Agents SDK has no default processor to restore.") self._processor = None self._exporter = None self._is_instrumented_instance_flag = False - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) set _is_instrumented_instance_flag to False.") - - logger.info( - f"OpenAIAgentsInstrumentor (id: {id(self)}) Successfully removed OpenAI Agents SDK instrumentation" - ) + logger.info("Successfully removed OpenAI Agents SDK instrumentation") except Exception as e: - logger.warning(f"OpenAIAgentsInstrumentor (id: {id(self)}) Failed to uninstrument: {e}", exc_info=True) - logger.debug(f"OpenAIAgentsInstrumentor (id: {id(self)}) _uninstrument END") + logger.warning(f"Failed to uninstrument OpenAI Agents SDK: {e}", exc_info=True) From 3a8b6b9891dbe637fb1f384a7f9733a23596410c Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 23:14:43 +0530 Subject: [PATCH 11/16] its `FUNCTION_SPAN_ATTRIBUTES` --- agentops/instrumentation/openai_agents/attributes/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index 5856cdfdc..9d33a6c8d 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -42,7 +42,7 @@ # Attribute mapping for FunctionSpanData -FUNCTION_TOOL_ATTRIBUTES: AttributeMap = { +FUNCTION_SPAN_ATTRIBUTES: AttributeMap = { ToolAttributes.TOOL_NAME: "name", ToolAttributes.TOOL_PARAMETERS: "input", ToolAttributes.TOOL_RESULT: "output", @@ -218,7 +218,7 @@ def get_function_span_attributes(span_data: Any) -> AttributeMap: Returns: Dictionary of attributes for function span """ - attributes = _extract_attributes_from_mapping(span_data, FUNCTION_TOOL_ATTRIBUTES) + attributes = _extract_attributes_from_mapping(span_data, FUNCTION_SPAN_ATTRIBUTES) attributes.update(get_common_attributes()) attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.TOOL.value From ae47025e5f483800a22cf4e0d5fd40f4b22e6de6 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 23:20:00 +0530 Subject: [PATCH 12/16] forgot this one but now we good --- agentops/instrumentation/openai_agents/exporter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agentops/instrumentation/openai_agents/exporter.py b/agentops/instrumentation/openai_agents/exporter.py index 3a61cb79f..6e6734971 100644 --- a/agentops/instrumentation/openai_agents/exporter.py +++ b/agentops/instrumentation/openai_agents/exporter.py @@ -310,12 +310,12 @@ def export_span(self, span: Any) -> None: span_lookup_key = _get_span_lookup_key(trace_id, span_id) attributes = get_base_span_attributes(span) - span_specific_attributes = get_span_attributes(span_data) - attributes.update(span_specific_attributes) + span_attributes = get_span_attributes(span_data) + attributes.update(span_attributes) if is_end_event: # Update all attributes for end events - pass + attributes.update(span_attributes) # Log the trace ID for debugging and correlation with AgentOps API log_otel_trace_id(span_type) From 25df495031876e6b464b9c28c788fe9921a80638 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Tue, 27 May 2025 23:40:31 +0530 Subject: [PATCH 13/16] refactor for responses instructions attribute --- .../openai/attributes/response.py | 1 + .../openai_agents/attributes/common.py | 151 +++++++++++------- agentops/semconv/span_attributes.py | 1 + 3 files changed, 93 insertions(+), 60 deletions(-) diff --git a/agentops/instrumentation/openai/attributes/response.py b/agentops/instrumentation/openai/attributes/response.py index 8655de135..195eb5bdb 100644 --- a/agentops/instrumentation/openai/attributes/response.py +++ b/agentops/instrumentation/openai/attributes/response.py @@ -83,6 +83,7 @@ SpanAttributes.LLM_RESPONSE_ID: "id", SpanAttributes.LLM_REQUEST_MODEL: "model", SpanAttributes.LLM_RESPONSE_MODEL: "model", + SpanAttributes.LLM_OPENAI_RESPONSE_INSTRUCTIONS: "instructions", SpanAttributes.LLM_REQUEST_MAX_TOKENS: "max_output_tokens", SpanAttributes.LLM_REQUEST_TEMPERATURE: "temperature", SpanAttributes.LLM_REQUEST_TOP_P: "top_p", diff --git a/agentops/instrumentation/openai_agents/attributes/common.py b/agentops/instrumentation/openai_agents/attributes/common.py index 9d33a6c8d..93e880cf3 100644 --- a/agentops/instrumentation/openai_agents/attributes/common.py +++ b/agentops/instrumentation/openai_agents/attributes/common.py @@ -255,6 +255,66 @@ def get_handoff_span_attributes(span_data: Any) -> AttributeMap: return attributes +def _extract_text_from_content(content: Any) -> Optional[str]: + """Extract text from various content formats used in the Responses API. + + Args: + content: Content in various formats (str, dict, list) + + Returns: + Extracted text or None if no text found + """ + if isinstance(content, str): + return content + + if isinstance(content, dict): + # Direct text field + if "text" in content: + return content["text"] + # Output text type + if content.get("type") == "output_text": + return content.get("text", "") + + if isinstance(content, list): + text_parts = [] + for item in content: + extracted = _extract_text_from_content(item) + if extracted: + text_parts.append(extracted) + return " ".join(text_parts) if text_parts else None + + return None + + +def _build_prompt_messages_from_input(input_data: Any) -> List[Dict[str, Any]]: + """Build prompt messages from various input formats. + + Args: + input_data: Input data from span_data.input + + Returns: + List of message dictionaries with role and content + """ + messages = [] + + if isinstance(input_data, str): + # Single string input - assume it's a user message + messages.append({"role": "user", "content": input_data}) + + elif isinstance(input_data, list): + for msg in input_data: + if isinstance(msg, dict): + role = msg.get("role") + content = msg.get("content") + + if role and content is not None: + extracted_text = _extract_text_from_content(content) + if extracted_text: + messages.append({"role": role, "content": extracted_text}) + + return messages + + def get_response_span_attributes(span_data: Any) -> AttributeMap: """Extract attributes from a ResponseSpanData object with full LLM response processing. @@ -276,70 +336,41 @@ def get_response_span_attributes(span_data: Any) -> AttributeMap: attributes = _extract_attributes_from_mapping(span_data, RESPONSE_SPAN_ATTRIBUTES) attributes.update(get_common_attributes()) - # Build complete prompt list from system instructions and conversation history - prompt_messages = [] - - # Add system instruction as first message if available - if span_data.response and hasattr(span_data.response, "instructions") and span_data.response.instructions: - prompt_messages.append({"role": "system", "content": span_data.response.instructions}) - - # Add conversation history from span_data.input - if hasattr(span_data, "input") and span_data.input: - if isinstance(span_data.input, list): - for msg in span_data.input: - if isinstance(msg, dict): - role = msg.get("role") - content = msg.get("content") - - # Handle different content formats - if role and content is not None: - # If content is a string, use it directly - if isinstance(content, str): - prompt_messages.append({"role": role, "content": content}) - # If content is a list (complex assistant message), extract text - elif isinstance(content, list): - text_parts = [] - for item in content: - if isinstance(item, dict): - # Handle output_text type - if item.get("type") == "output_text": - text_parts.append(item.get("text", "")) - # Handle other text content - elif "text" in item: - text_parts.append(item.get("text", "")) - # Handle annotations with text - elif "annotations" in item and "text" in item: - text_parts.append(item.get("text", "")) - - if text_parts: - prompt_messages.append({"role": role, "content": " ".join(text_parts)}) - # If content is a dict, try to extract text - elif isinstance(content, dict): - if "text" in content: - prompt_messages.append({"role": role, "content": content["text"]}) - elif isinstance(span_data.input, str): - # Single string input - assume it's a user message - prompt_messages.append({"role": "user", "content": span_data.input}) - - # Format prompts using existing function - if prompt_messages: - attributes.update(_get_llm_messages_attributes(prompt_messages, "gen_ai.prompt")) - - # Process response attributes + # Process response attributes first to get all response data including instructions if span_data.response: - openai_style_response_attrs = get_response_response_attributes(span_data.response) + response_attrs = get_response_response_attributes(span_data.response) + + # Extract system prompt if present + system_prompt = response_attrs.get(SpanAttributes.LLM_OPENAI_RESPONSE_INSTRUCTIONS) + + prompt_messages = [] + # Add system prompt as first message if available + if system_prompt: + prompt_messages.append({"role": "system", "content": system_prompt}) + # Remove from response attrs to avoid duplication + response_attrs.pop(SpanAttributes.LLM_OPENAI_RESPONSE_INSTRUCTIONS, None) - # Remove any prompt attributes from response processing since we handle them above - keys_to_remove = [k for k in openai_style_response_attrs if k.startswith("gen_ai.prompt")] - for key in keys_to_remove: - if key in openai_style_response_attrs: - del openai_style_response_attrs[key] + # Add conversation history from input + if hasattr(span_data, "input") and span_data.input: + prompt_messages.extend(_build_prompt_messages_from_input(span_data.input)) - # Remove tool definitions from response attributes - if "gen_ai.request.tools" in openai_style_response_attrs: - del openai_style_response_attrs["gen_ai.request.tools"] + # Format prompts using existing function + if prompt_messages: + attributes.update(_get_llm_messages_attributes(prompt_messages, "gen_ai.prompt")) - attributes.update(openai_style_response_attrs) + # Remove any prompt-related attributes that might have been set by response processing + response_attrs = { + k: v for k, v in response_attrs.items() if not k.startswith("gen_ai.prompt") and k != "gen_ai.request.tools" + } + + # Add remaining response attributes + attributes.update(response_attrs) + else: + # No response object, just process input as prompts + if hasattr(span_data, "input") and span_data.input: + prompt_messages = _build_prompt_messages_from_input(span_data.input) + if prompt_messages: + attributes.update(_get_llm_messages_attributes(prompt_messages, "gen_ai.prompt")) attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.LLM.value diff --git a/agentops/semconv/span_attributes.py b/agentops/semconv/span_attributes.py index 79f0285a9..0561c3910 100644 --- a/agentops/semconv/span_attributes.py +++ b/agentops/semconv/span_attributes.py @@ -77,6 +77,7 @@ class SpanAttributes: # OpenAI specific LLM_OPENAI_RESPONSE_SYSTEM_FINGERPRINT = "gen_ai.openai.system_fingerprint" + LLM_OPENAI_RESPONSE_INSTRUCTIONS = "gen_ai.openai.instructions" LLM_OPENAI_API_BASE = "gen_ai.openai.api_base" LLM_OPENAI_API_VERSION = "gen_ai.openai.api_version" LLM_OPENAI_API_TYPE = "gen_ai.openai.api_type" From a6726d0b6bc32ec4086150d3648cd885c92b8ad1 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Wed, 28 May 2025 00:16:50 +0530 Subject: [PATCH 14/16] fix tests --- .../test_openai_agents_attributes.py | 44 ++++++++++++------- .../openai_core/test_response_attributes.py | 4 +- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/tests/unit/instrumentation/openai_agents/test_openai_agents_attributes.py b/tests/unit/instrumentation/openai_agents/test_openai_agents_attributes.py index 1173b34af..35085eef7 100644 --- a/tests/unit/instrumentation/openai_agents/test_openai_agents_attributes.py +++ b/tests/unit/instrumentation/openai_agents/test_openai_agents_attributes.py @@ -46,7 +46,6 @@ SpanAttributes, MessageAttributes, AgentAttributes, - WorkflowAttributes, InstrumentationAttributes, ) @@ -171,10 +170,8 @@ def test_agent_span_attributes(self): # Verify extracted attributes assert attrs[AgentAttributes.AGENT_NAME] == "test_agent" - assert attrs[WorkflowAttributes.WORKFLOW_INPUT] == "test input" - assert attrs[WorkflowAttributes.FINAL_OUTPUT] == "test output" - assert attrs[AgentAttributes.AGENT_TOOLS] == '["tool1", "tool2"]' # JSON-serialized string is fine. - # LLM_PROMPTS is handled in common.py now so we don't test for it directly + assert "agentops.span.kind" in attrs + assert attrs["agentops.span.kind"] == "agent" def test_function_span_attributes(self): """Test extraction of attributes from a FunctionSpanData object""" @@ -189,11 +186,17 @@ def test_function_span_attributes(self): # Extract attributes attrs = get_function_span_attributes(mock_function_span) - # Verify extracted attributes - note that complex objects should be serialized to strings - assert attrs[AgentAttributes.AGENT_NAME] == "test_function" - assert attrs[WorkflowAttributes.WORKFLOW_INPUT] == '{"arg1": "value1"}' # Serialized string - assert attrs[WorkflowAttributes.FINAL_OUTPUT] == '{"result": "success"}' # Serialized string - assert attrs[AgentAttributes.FROM_AGENT] == "caller_agent" + # Verify extracted attributes + assert "tool.name" in attrs + assert attrs["tool.name"] == "test_function" + assert "tool.parameters" in attrs + assert '{"arg1": "value1"}' in attrs["tool.parameters"] # Serialized string + assert "tool.result" in attrs + assert '{"result": "success"}' in attrs["tool.result"] # Serialized string + assert "agentops.span.kind" in attrs + assert attrs["agentops.span.kind"] == "tool" + assert "agent.calling_tool.name" in attrs + assert attrs["agent.calling_tool.name"] == "caller_agent" def test_generation_span_with_chat_completion(self): """Test extraction of attributes from a GenerationSpanData with Chat Completion API data""" @@ -217,7 +220,10 @@ def __init__(self): # Verify model and input attributes assert attrs[SpanAttributes.LLM_REQUEST_MODEL] == "gpt-4o-2024-08-06" assert attrs[SpanAttributes.LLM_RESPONSE_MODEL] == "gpt-4o-2024-08-06" - assert attrs[SpanAttributes.LLM_PROMPTS] == "What is the capital of France?" + assert "gen_ai.prompt.0.role" in attrs + assert attrs["gen_ai.prompt.0.role"] == "user" + assert "gen_ai.prompt.0.content" in attrs + assert attrs["gen_ai.prompt.0.content"] == "What is the capital of France?" # Verify model config attributes assert attrs[SpanAttributes.LLM_REQUEST_TEMPERATURE] == 0.7 @@ -248,7 +254,10 @@ def __init__(self): # Verify model and input attributes assert attrs[SpanAttributes.LLM_REQUEST_MODEL] == "gpt-4o-2024-08-06" assert attrs[SpanAttributes.LLM_RESPONSE_MODEL] == "gpt-4o-2024-08-06" - assert attrs[SpanAttributes.LLM_PROMPTS] == "What is the capital of France?" + assert "gen_ai.prompt.0.role" in attrs + assert attrs["gen_ai.prompt.0.role"] == "user" + assert "gen_ai.prompt.0.content" in attrs + assert attrs["gen_ai.prompt.0.content"] == "What is the capital of France?" # Verify token usage - this is handled through model_to_dict now # Since we're using a direct fixture, the serialization might differ @@ -421,8 +430,12 @@ def __init__(self): attrs = get_response_span_attributes(mock_response_span) # Verify extracted attributes - # SpanAttributes.LLM_PROMPTS is no longer explicitly set here - assert attrs[WorkflowAttributes.WORKFLOW_INPUT] == "user query" + assert "gen_ai.prompt.0.role" in attrs + assert attrs["gen_ai.prompt.0.role"] == "user" + assert "gen_ai.prompt.0.content" in attrs + assert attrs["gen_ai.prompt.0.content"] == "user query" + assert "agentops.span.kind" in attrs + assert attrs["agentops.span.kind"] == "llm" def test_span_attributes_dispatcher(self): """Test the dispatcher function that routes to type-specific extractors""" @@ -456,7 +469,8 @@ def __init__(self): assert AgentAttributes.AGENT_NAME in agent_attrs function_attrs = get_span_attributes(function_span) - assert AgentAttributes.AGENT_NAME in function_attrs + assert "tool.name" in function_attrs + assert function_attrs["tool.name"] == "test_function" # Unknown span type should return empty dict unknown_attrs = get_span_attributes(unknown_span) diff --git a/tests/unit/instrumentation/openai_core/test_response_attributes.py b/tests/unit/instrumentation/openai_core/test_response_attributes.py index 90391002b..660302ab0 100644 --- a/tests/unit/instrumentation/openai_core/test_response_attributes.py +++ b/tests/unit/instrumentation/openai_core/test_response_attributes.py @@ -340,8 +340,8 @@ def test_get_response_response_attributes(self): assert attributes[SpanAttributes.LLM_RESPONSE_ID] == response_data["id"] assert SpanAttributes.LLM_RESPONSE_MODEL in attributes assert attributes[SpanAttributes.LLM_RESPONSE_MODEL] == response_data["model"] - assert SpanAttributes.LLM_PROMPTS in attributes - assert attributes[SpanAttributes.LLM_PROMPTS] == response_data["instructions"] + assert SpanAttributes.LLM_OPENAI_RESPONSE_INSTRUCTIONS in attributes + assert attributes[SpanAttributes.LLM_OPENAI_RESPONSE_INSTRUCTIONS] == response_data["instructions"] # Check usage attributes assert SpanAttributes.LLM_USAGE_PROMPT_TOKENS in attributes From b1a98154089a8eb4abf8014579f1c490865fa226 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Wed, 28 May 2025 00:26:12 +0530 Subject: [PATCH 15/16] get correct library version --- agentops/instrumentation/openai_agents/instrumentor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/agentops/instrumentation/openai_agents/instrumentor.py b/agentops/instrumentation/openai_agents/instrumentor.py index fc4c76a3f..8a78bce26 100644 --- a/agentops/instrumentation/openai_agents/instrumentor.py +++ b/agentops/instrumentation/openai_agents/instrumentor.py @@ -25,6 +25,7 @@ from opentelemetry import trace from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore +from openai_agents import LIBRARY_VERSION from agentops.logging import logger from agentops.instrumentation.openai_agents.processor import OpenAIAgentsProcessor @@ -56,7 +57,7 @@ def _instrument(self, **kwargs): tracer_provider = kwargs.get("tracer_provider") if self._tracer is None: logger.debug("OpenAI Agents SDK tracer is None, creating new tracer.") - self._tracer = trace.get_tracer("agentops.instrumentation.openai_agents", "0.1.0") + self._tracer = trace.get_tracer("agentops.instrumentation.openai_agents", LIBRARY_VERSION) try: self._exporter = OpenAIAgentsExporter(tracer_provider=tracer_provider) From 41c99d58a49eb138ae5dfa30bca3ac84fb49b601 Mon Sep 17 00:00:00 2001 From: Pratyush Shukla Date: Wed, 28 May 2025 00:29:42 +0530 Subject: [PATCH 16/16] oops --- agentops/instrumentation/openai_agents/instrumentor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentops/instrumentation/openai_agents/instrumentor.py b/agentops/instrumentation/openai_agents/instrumentor.py index 8a78bce26..2b92e2d79 100644 --- a/agentops/instrumentation/openai_agents/instrumentor.py +++ b/agentops/instrumentation/openai_agents/instrumentor.py @@ -25,7 +25,7 @@ from opentelemetry import trace from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore -from openai_agents import LIBRARY_VERSION +from agentops.instrumentation.openai_agents import LIBRARY_VERSION from agentops.logging import logger from agentops.instrumentation.openai_agents.processor import OpenAIAgentsProcessor