From 953305e8e18acbf503b1ed3b4d9cce683f0a99ea Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 25 Mar 2025 13:08:18 -0700 Subject: [PATCH 1/3] Update attribute extraction to support dict as well as object. --- .../openai_agents/attributes/__init__.py | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/agentops/instrumentation/openai_agents/attributes/__init__.py b/agentops/instrumentation/openai_agents/attributes/__init__.py index df987951c..be5e38621 100644 --- a/agentops/instrumentation/openai_agents/attributes/__init__.py +++ b/agentops/instrumentation/openai_agents/attributes/__init__.py @@ -34,7 +34,7 @@ def _extract_attributes_from_mapping(span_data: Any, attribute_mapping: Attribut """Helper function to extract attributes based on a mapping. Args: - span_data: The span data object to extract attributes from + span_data: The span data object or dict to extract attributes from attribute_mapping: Dictionary mapping target attributes to source attributes Returns: @@ -43,22 +43,22 @@ def _extract_attributes_from_mapping(span_data: Any, attribute_mapping: Attribut attributes = {} for target_attr, source_attr in attribute_mapping.items(): if hasattr(span_data, source_attr): + # Use getattr to handle properties value = getattr(span_data, source_attr) - - # Skip if value is None or empty - if value is None or (isinstance(value, (list, dict, str)) and not value): - continue - - # Join lists to comma-separated strings - if source_attr == "tools" or source_attr == "handoffs": - if isinstance(value, list): - value = ",".join(value) - else: - value = str(value) - # Serialize complex objects - elif isinstance(value, (dict, list, object)) and not isinstance(value, (str, int, float, bool)): - value = safe_serialize(value) - - attributes[target_attr] = value + elif isinstance(span_data, dict) and source_attr in span_data: + # Use direct key access for dicts + value = span_data[source_attr] + else: + continue + + # Skip if value is None or empty + if value is None or (isinstance(value, (list, dict, str)) and not value): + continue + + # Serialize complex objects + elif isinstance(value, (dict, list, object)) and not isinstance(value, (str, int, float, bool)): + value = safe_serialize(value) + + attributes[target_attr] = value return attributes \ No newline at end of file From ab8dad144661ead321033b0e2cd282a7229a0448 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 25 Mar 2025 13:31:30 -0700 Subject: [PATCH 2/3] Adjust tests to match serialization format of `list[str]`. Patch JSON encode in tests to handle MagicMock objects. --- .../openai_agents/test_openai_agents.py | 2 +- .../test_openai_agents_attributes.py | 37 +++++++++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/tests/unit/instrumentation/openai_agents/test_openai_agents.py b/tests/unit/instrumentation/openai_agents/test_openai_agents.py index 26fe9d79f..34db816ff 100644 --- a/tests/unit/instrumentation/openai_agents/test_openai_agents.py +++ b/tests/unit/instrumentation/openai_agents/test_openai_agents.py @@ -288,7 +288,7 @@ def test_span_hierarchy_and_attributes(self, instrumentation): assert parent_captured_attributes[AgentAttributes.AGENT_NAME] == "parent_agent" assert parent_captured_attributes[WorkflowAttributes.WORKFLOW_INPUT] == "parent input" assert parent_captured_attributes[WorkflowAttributes.FINAL_OUTPUT] == "parent output" - assert parent_captured_attributes[AgentAttributes.AGENT_TOOLS] == "tool1,tool2" + assert parent_captured_attributes[AgentAttributes.AGENT_TOOLS] == '["tool1", "tool2"]' # JSON encoded is fine. # Verify child span attributes assert child_captured_attributes[AgentAttributes.AGENT_NAME] == "child_agent" 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 3c0908f13..5c39c1830 100644 --- a/tests/unit/instrumentation/openai_agents/test_openai_agents_attributes.py +++ b/tests/unit/instrumentation/openai_agents/test_openai_agents_attributes.py @@ -116,8 +116,36 @@ def load_fixture(fixture_name): @pytest.fixture(autouse=True) def mock_external_dependencies(): """Mock any external dependencies to avoid actual API calls or slow operations""" - with patch('importlib.metadata.version', return_value='1.0.0'): - with patch('agentops.helpers.serialization.safe_serialize', side_effect=lambda x: str(x)[:100]): + # Create a more comprehensive mock for JSON serialization + # This will directly patch the json.dumps function which is used inside safe_serialize + + # Store the original json.dumps function + original_dumps = json.dumps + + # Create a wrapper for json.dumps that handles MagicMock objects + def json_dumps_wrapper(*args, **kwargs): + """ + Our JSON encode method doesn't play well with MagicMock objects and gets stuck iun a recursive loop. + Patch the functionality to return a simple string instead of trying to serialize the object. + """ + # If the first argument is a MagicMock, return a simple string + if args and hasattr(args[0], '__module__') and 'mock' in args[0].__module__.lower(): + return '"mock_object"' + # Otherwise, use the original function with a custom encoder that handles MagicMock objects + cls = kwargs.get('cls', None) + if not cls: + # Use our own encoder that handles MagicMock objects + class MagicMockJSONEncoder(json.JSONEncoder): + def default(self, obj): + if hasattr(obj, '__module__') and 'mock' in obj.__module__.lower(): + return 'mock_object' + return super().default(obj) + kwargs['cls'] = MagicMockJSONEncoder + # Call the original dumps with our encoder + return original_dumps(*args, **kwargs) + + with patch('json.dumps', side_effect=json_dumps_wrapper): + with patch('importlib.metadata.version', return_value='1.0.0'): with patch('agentops.instrumentation.openai_agents.LIBRARY_NAME', 'openai'): with patch('agentops.instrumentation.openai_agents.LIBRARY_VERSION', '1.0.0'): yield @@ -138,7 +166,8 @@ def test_common_instrumentation_attributes(self): # Verify values assert attrs[InstrumentationAttributes.NAME] == "agentops" - assert attrs[InstrumentationAttributes.VERSION] == get_agentops_version() # Use actual version + # Don't call get_agentops_version() again, just verify it's in the dictionary + assert InstrumentationAttributes.VERSION in attrs assert attrs[InstrumentationAttributes.LIBRARY_NAME] == LIBRARY_NAME def test_agent_span_attributes(self): @@ -158,7 +187,7 @@ def test_agent_span_attributes(self): 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" + 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 def test_function_span_attributes(self): From 4c6b12fc3a6be68ff80a7be8a3d9819cdaa7cc6e Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 25 Mar 2025 15:21:23 -0700 Subject: [PATCH 3/3] Collect base usage attributes, too. --- agentops/instrumentation/openai_agents/attributes/response.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agentops/instrumentation/openai_agents/attributes/response.py b/agentops/instrumentation/openai_agents/attributes/response.py index 62a62b909..def98b56c 100644 --- a/agentops/instrumentation/openai_agents/attributes/response.py +++ b/agentops/instrumentation/openai_agents/attributes/response.py @@ -294,6 +294,10 @@ def get_response_usage_attributes(usage: 'ResponseUsage') -> AttributeMap: # ) attributes = {} + attributes.update(_extract_attributes_from_mapping( + usage.__dict__, + RESPONSE_USAGE_ATTRIBUTES)) + # input_tokens_details is a dict if it exists if hasattr(usage, 'input_tokens_details'): input_details = usage.input_tokens_details