diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py index 6f39474edf..b52cf6e51c 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py @@ -188,7 +188,7 @@ def _flatten_compound_value( rename_keys=rename_keys, flatten_functions=flatten_functions, ) - if hasattr(value, "model_dump"): + if hasattr(value, "model_dump") and not isinstance(value, type): return _flatten_dict( value.model_dump(), key_prefix=key, @@ -196,6 +196,14 @@ def _flatten_compound_value( rename_keys=rename_keys, flatten_functions=flatten_functions, ) + if hasattr(value, "model_json_schema"): + return _flatten_dict( + value.model_json_schema(), + key_prefix=key, + exclude_keys=exclude_keys, + rename_keys=rename_keys, + flatten_functions=flatten_functions, + ) return _flatten_compound_value_using_json( key, value, diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py index 4598915dc3..0631f173f7 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py @@ -161,8 +161,10 @@ def _determine_genai_system(models_object: Union[Models, AsyncModels]): def _to_dict(value: object): if isinstance(value, dict): return value - if hasattr(value, "model_dump"): + if hasattr(value, "model_dump") and not isinstance(value, type): return value.model_dump() + if hasattr(value, "model_json_schema"): + return value.model_json_schema() return json.loads(json.dumps(value)) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py index f4303306e3..f7da0f216e 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py @@ -54,8 +54,12 @@ def _to_otel_value(python_value): return { key: _to_otel_value(val) for (key, val) in python_value.items() } - if hasattr(python_value, "model_dump"): + if hasattr(python_value, "model_dump") and not isinstance( + python_value, type + ): return python_value.model_dump() + if hasattr(python_value, "model_json_schema"): + return python_value.model_json_schema() if hasattr(python_value, "__dict__"): return _to_otel_value(python_value.__dict__) return repr(python_value) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_dict_util.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_dict_util.py index ef2e641360..6ba88ff867 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_dict_util.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_dict_util.py @@ -207,6 +207,25 @@ def test_flatten_with_mixed_structures(): } +def test_flatten_with_pydantic_class_not_instance(): + """Test that passing a Pydantic class (not instance) doesn't cause model_dump() error.""" + input_dict = { + "foo": PydanticModel, # Class, not instance + "bar": "value", + } + + # Should not raise "BaseModel.model_dump() missing 1 required positional argument: 'self'" + output = dict_util.flatten_dict(input_dict) + # The class should be converted using JSON serialization fallback + assert "bar" in output + assert output["bar"] == "value" + assert "foo.description" in output + assert "foo.properties.int_value.title" in output + assert "foo.properties.int_value.default" in output + assert "foo.properties.str_value.title" in output + assert "foo.properties.str_value.default" in output + + def test_converts_tuple_with_json_fallback(): input_dict = { "foo": ("abc", 123), diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py index af5dcef29e..41b2a73ef7 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py @@ -17,6 +17,7 @@ from unittest.mock import patch from google.genai import types as genai_types +from pydantic import BaseModel from opentelemetry._logs import get_logger_provider from opentelemetry.instrumentation._semconv import ( @@ -35,6 +36,13 @@ from ..common import otel_mocker +class PydanticModel(BaseModel): + """Used to verify handling of pydantic models in the flattener.""" + + str_value: str = "" + int_value: int = 0 + + class TestCase(unittest.TestCase): def setUp(self): self._otel = otel_mocker.OTelMocker() @@ -329,3 +337,19 @@ def somefunction(arg=None): span.attributes, ) self.tearDown() + + def test_handles_pydantic_class_not_instance(self): + """Test that passing a Pydantic class (not instance) doesn't cause model_dump() error.""" + + def somefunction(arg=None): + return arg + + wrapped_somefunction = self.wrap(somefunction) + # Pass the class itself, not an instance + wrapped_somefunction(PydanticModel) + + # Should not raise "BaseModel.model_dump() missing 1 required positional argument: 'self'" + span = self.otel.get_span_named("execute_tool somefunction") + self.assertIsNotNone(span) + # The class should be handled without error + self.assertIn("code.function.parameters.arg.type", span.attributes)