Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,37 @@
logger = logging.getLogger(__name__)


def _sanitize_attributes_for_metrics(attributes):
"""
Sanitize attributes to ensure they are hashable for metric recording.
OpenTelemetry metrics require hashable attributes for aggregation keys.

Args:
attributes (dict): Dictionary of attributes to sanitize

Returns:
dict: Dictionary with all values converted to hashable types
"""
if not isinstance(attributes, dict):
return attributes

sanitized = {}
for key, value in attributes.items():
try:
# Test if the value is hashable by creating a frozenset
frozenset({key: value}.items())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider replacing the frozenset({key: value}.items()) check with a direct call to hash(value) to test hashability. This is more idiomatic and may be clearer.

Suggested change
frozenset({key: value}.items())
hash(value)

sanitized[key] = value
except TypeError:
# Value is not hashable, convert to JSON string
try:
sanitized[key] = json.dumps(value, sort_keys=True)
except (TypeError, ValueError):
# If JSON serialization fails, convert to string
sanitized[key] = str(value)

return sanitized


@_with_chat_telemetry_wrapper
def chat_wrapper(
tracer: Tracer,
Expand Down Expand Up @@ -375,6 +406,8 @@ def _set_chat_metrics(
server_address=_get_openai_base_url(instance),
is_streaming=is_streaming,
)
# Sanitize attributes to ensure they are hashable for metric recording
shared_attributes = _sanitize_attributes_for_metrics(shared_attributes)

# token metrics
usage = response_dict.get("usage") # type: dict
Expand Down Expand Up @@ -727,14 +760,16 @@ def _process_item(self, item):
_accumulate_stream_items(item, self._complete_response)

def _shared_attributes(self):
return metric_shared_attributes(
attributes = metric_shared_attributes(
response_model=self._complete_response.get("model")
or self._request_kwargs.get("model")
or None,
operation="chat",
server_address=_get_openai_base_url(self._instance),
is_streaming=True,
)
# Sanitize attributes to ensure they are hashable for metric recording
return _sanitize_attributes_for_metrics(attributes)

@dont_throw
def _process_complete_response(self):
Expand Down Expand Up @@ -894,6 +929,8 @@ def _build_from_streaming_response(
"server.address": _get_openai_base_url(instance),
"stream": True,
}
# Sanitize attributes to ensure they are hashable for metric recording
shared_attributes = _sanitize_attributes_for_metrics(shared_attributes)

_set_streaming_token_metrics(
request_kwargs, complete_response, span, token_counter, shared_attributes
Expand Down Expand Up @@ -965,6 +1002,8 @@ async def _abuild_from_streaming_response(
"server.address": _get_openai_base_url(instance),
"stream": True,
}
# Sanitize attributes to ensure they are hashable for metric recording
shared_attributes = _sanitize_attributes_for_metrics(shared_attributes)

_set_streaming_token_metrics(
request_kwargs, complete_response, span, token_counter, shared_attributes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""
Test to reproduce and verify the fix for the unhashable attributes issue #3428.
This test simulates the scenario where OpenAI tool definitions with lists
cause TypeError when streaming chat completions are used.
"""
import pytest
from unittest.mock import MagicMock, patch
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Drop unused patch import.
Flake8 flags this (F401), so the test module won’t pass lint until patch is removed.

-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
🧰 Tools
🪛 Flake8 (7.3.0)

[error] 8-8: 'unittest.mock.patch' imported but unused

(F401)

🤖 Prompt for AI Agents
In
packages/opentelemetry-instrumentation-openai/tests/test_unhashable_attributes_fix.py
around line 8, the import statement includes an unused `patch` (from
unittest.mock) which triggers flake8 F401; remove `patch` from the import so
only `MagicMock` is imported (or delete the entire unused import if not needed
elsewhere) to satisfy linting.

from opentelemetry.instrumentation.openai.shared.chat_wrappers import (
_sanitize_attributes_for_metrics,
ChatStream,
)
from opentelemetry.instrumentation.openai.shared.config import Config


class TestUnhashableAttributesFix:
def test_sanitize_attributes_with_lists(self):
"""Test that _sanitize_attributes_for_metrics handles unhashable values"""
# Test attributes with unhashable types (lists, dicts)
attributes_with_lists = {
"model": "gpt-4",
"tools_required": ["param1", "param2"], # This list causes the issue
"tool_params": {"type": "object", "properties": {"location": {"type": "string"}}}, # Dict
"simple_string": "test",
"simple_number": 42,
}

# This should not raise an exception
sanitized = _sanitize_attributes_for_metrics(attributes_with_lists)

# Verify all values are now hashable
for key, value in sanitized.items():
try:
# Test if the value is hashable by creating a frozenset
frozenset({key: value}.items())
except TypeError:
pytest.fail(f"Value for key '{key}' is still not hashable: {value}")

# Verify the content is preserved (as JSON strings for complex types)
assert sanitized["model"] == "gpt-4"
assert sanitized["simple_string"] == "test"
assert sanitized["simple_number"] == 42
assert '"param1"' in sanitized["tools_required"] # Should be JSON string
assert '"location"' in sanitized["tool_params"] # Should be JSON string

def test_sanitize_attributes_with_none_values(self):
"""Test that None values and empty values are handled correctly"""
attributes = {
"none_value": None,
"empty_list": [],
"empty_dict": {},
"normal_value": "test"
}

sanitized = _sanitize_attributes_for_metrics(attributes)

# Verify all are hashable
for key, value in sanitized.items():
try:
frozenset({key: value}.items())
except TypeError:
pytest.fail(f"Value for key '{key}' is still not hashable: {value}")

def test_sanitize_attributes_preserves_hashable_values(self):
"""Test that already hashable values are preserved as-is"""
hashable_attributes = {
"string": "test",
"int": 42,
"float": 3.14,
"bool": True,
"tuple": (1, 2, 3), # Tuples are hashable
}

sanitized = _sanitize_attributes_for_metrics(hashable_attributes)

# All values should remain the same
assert sanitized == hashable_attributes

def test_config_get_common_metrics_attributes_with_unhashable_values(self):
"""Test that the fix works when Config.get_common_metrics_attributes returns unhashable values"""
# Mock Config.get_common_metrics_attributes to return unhashable data
original_fn = Config.get_common_metrics_attributes

def mock_get_common_attributes():
return {
"tool_definitions": [
{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"] # This list causes the crash
}
}
}
],
"other_config": {"nested": {"data": "value"}}
}

try:
Config.get_common_metrics_attributes = mock_get_common_attributes

# Create a mock ChatStream instance
mock_span = MagicMock()
mock_response = MagicMock()
mock_instance = MagicMock()

# Create ChatStream with mocked objects
chat_stream = ChatStream(
span=mock_span,
response=mock_response,
instance=mock_instance,
start_time=1234567890.0,
request_kwargs={"model": "gpt-4"}
)

# This should not raise TypeError: unhashable type: 'list'
attributes = chat_stream._shared_attributes()

# Verify all attributes are hashable
for key, value in attributes.items():
try:
frozenset({key: value}.items())
except TypeError:
pytest.fail(f"Attribute '{key}' with value '{value}' is not hashable")

finally:
# Restore original function
Config.get_common_metrics_attributes = original_fn

def test_original_issue_reproduction_simulation(self):
"""
Simulate the original issue scenario from the bug report.
This test reproduces the conditions that led to the TypeError.
"""
# Simulate the problematic attributes that would come from OpenAI tool definitions
problematic_attributes = {
"gen_ai.system": "openai",
"gen_ai.response.model": "gpt-4",
"gen_ai.operation.name": "chat",
"server.address": "https://api.openai.com/v1",
"stream": True,
# This simulates what happens when tool definitions leak into attributes
"tool_required_params": ["location", "unit"], # This causes the crash
"tool_schema": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["location"] # Another list that causes issues
}
}

# Before the fix, this would fail when trying to create a frozenset
try:
frozenset(problematic_attributes.items())
pytest.fail("Expected TypeError was not raised - test setup may be incorrect")
except TypeError:
# This is expected - the original issue
pass

# After applying our sanitization, it should work
sanitized = _sanitize_attributes_for_metrics(problematic_attributes)

# This should not raise an exception
try:
frozenset(sanitized.items())
except TypeError as e:
pytest.fail(f"Sanitization failed to fix the issue: {e}")

# Verify important attributes are preserved
assert sanitized["gen_ai.system"] == "openai"
assert sanitized["gen_ai.response.model"] == "gpt-4"
assert sanitized["stream"] is True