diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md b/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md index 73ef0d2f67..894c520643 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Add automatic instrumentation to tool call functions ([#3446](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3446)) + ## Version 0.2b0 (2025-04-28) - Add more request configuration options to the span attributes ([#3374](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3374)) 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 a029c992df..7e85336e56 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 @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy import functools import json import logging @@ -28,6 +29,7 @@ ContentListUnionDict, ContentUnion, ContentUnionDict, + GenerateContentConfig, GenerateContentConfigOrDict, GenerateContentResponse, ) @@ -44,6 +46,7 @@ from .dict_util import flatten_dict from .flags import is_content_recording_enabled from .otel_wrapper import OTelWrapper +from .tool_call_wrapper import wrapped as wrapped_tool _logger = logging.getLogger(__name__) @@ -206,6 +209,29 @@ def _get_response_property(response: GenerateContentResponse, path: str): return current_context +def _coerce_config_to_object( + config: GenerateContentConfigOrDict, +) -> GenerateContentConfig: + if isinstance(config, GenerateContentConfig): + return config + # Input must be a dictionary; convert by invoking the constructor. + return GenerateContentConfig(**config) + + +def _wrapped_config_with_tools( + otel_wrapper: OTelWrapper, + config: GenerateContentConfig, + **kwargs, +): + if not config.tools: + return config + result = copy.copy(config) + result.tools = [ + wrapped_tool(tool, otel_wrapper, **kwargs) for tool in config.tools + ] + return result + + class _GenerateContentInstrumentationHelper: def __init__( self, @@ -229,6 +255,17 @@ def __init__( generate_content_config_key_allowlist or AllowList() ) + def wrapped_config( + self, config: Optional[GenerateContentConfigOrDict] + ) -> Optional[GenerateContentConfig]: + if config is None: + return None + return _wrapped_config_with_tools( + self._otel_wrapper, + _coerce_config_to_object(config), + extra_span_attributes={"gen_ai.system": self._genai_system}, + ) + def start_span_as_current_span( self, model_name, function_name, end_on_exit=True ): @@ -556,7 +593,7 @@ def instrumented_generate_content( self, model=model, contents=contents, - config=config, + config=helper.wrapped_config(config), **kwargs, ) helper.process_response(response) @@ -601,7 +638,7 @@ def instrumented_generate_content_stream( self, model=model, contents=contents, - config=config, + config=helper.wrapped_config(config), **kwargs, ): helper.process_response(response) @@ -646,7 +683,7 @@ async def instrumented_generate_content( self, model=model, contents=contents, - config=config, + config=helper.wrapped_config(config), **kwargs, ) helper.process_response(response) @@ -694,7 +731,7 @@ async def instrumented_generate_content_stream( self, model=model, contents=contents, - config=config, + config=helper.wrapped_config(config), **kwargs, ) except Exception as error: # pylint: disable=broad-exception-caught 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 new file mode 100644 index 0000000000..7b4cc1924a --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py @@ -0,0 +1,220 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import functools +import inspect +import json +from typing import Any, Callable, Optional, Union + +from google.genai.types import ( + ToolListUnion, + ToolListUnionDict, + ToolOrDict, +) + +from opentelemetry import trace +from opentelemetry.semconv._incubating.attributes import ( + code_attributes, +) + +from .flags import is_content_recording_enabled +from .otel_wrapper import OTelWrapper + +ToolFunction = Callable[..., Any] + + +def _is_primitive(value): + return isinstance(value, (str, int, bool, float)) + + +def _to_otel_value(python_value): + """Coerces parameters to something representable with Open Telemetry.""" + if python_value is None or _is_primitive(python_value): + return python_value + if isinstance(python_value, list): + return [_to_otel_value(x) for x in python_value] + if isinstance(python_value, dict): + return { + key: _to_otel_value(val) for (key, val) in python_value.items() + } + if hasattr(python_value, "model_dump"): + return python_value.model_dump() + if hasattr(python_value, "__dict__"): + return _to_otel_value(python_value.__dict__) + return repr(python_value) + + +def _is_homogenous_primitive_list(value): + if not isinstance(value, list): + return False + if not value: + return True + if not _is_primitive(value[0]): + return False + first_type = type(value[0]) + for entry in value[1:]: + if not isinstance(entry, first_type): + return False + return True + + +def _to_otel_attribute(python_value): + otel_value = _to_otel_value(python_value) + if _is_primitive(otel_value) or _is_homogenous_primitive_list(otel_value): + return otel_value + return json.dumps(otel_value) + + +def _create_function_span_name(wrapped_function): + """Constructs the span name for a given local function tool call.""" + function_name = wrapped_function.__name__ + return f"execute_tool {function_name}" + + +def _create_function_span_attributes( + wrapped_function, function_args, function_kwargs, extra_span_attributes +): + """Creates the attributes for a tool call function span.""" + result = {} + if extra_span_attributes: + result.update(extra_span_attributes) + result["gen_ai.operation.name"] = "execute_tool" + result["gen_ai.tool.name"] = wrapped_function.__name__ + if wrapped_function.__doc__: + result["gen_ai.tool.description"] = wrapped_function.__doc__ + result[code_attributes.CODE_FUNCTION_NAME] = wrapped_function.__name__ + result["code.module"] = wrapped_function.__module__ + result["code.args.positional.count"] = len(function_args) + result["code.args.keyword.count"] = len(function_kwargs) + return result + + +def _record_function_call_argument( + span, param_name, param_value, include_values +): + attribute_prefix = f"code.function.parameters.{param_name}" + type_attribute = f"{attribute_prefix}.type" + span.set_attribute(type_attribute, type(param_value).__name__) + if include_values: + value_attribute = f"{attribute_prefix}.value" + span.set_attribute(value_attribute, _to_otel_attribute(param_value)) + + +def _record_function_call_arguments( + otel_wrapper, wrapped_function, function_args, function_kwargs +): + """Records the details about a function invocation as span attributes.""" + include_values = is_content_recording_enabled() + span = trace.get_current_span() + signature = inspect.signature(wrapped_function) + params = list(signature.parameters.values()) + for index, entry in enumerate(function_args): + param_name = f"args[{index}]" + if index < len(params): + param_name = params[index].name + _record_function_call_argument(span, param_name, entry, include_values) + for key, value in function_kwargs.items(): + _record_function_call_argument(span, key, value, include_values) + + +def _record_function_call_result(otel_wrapper, wrapped_function, result): + """Records the details about a function result as span attributes.""" + include_values = is_content_recording_enabled() + span = trace.get_current_span() + span.set_attribute("code.function.return.type", type(result).__name__) + if include_values: + span.set_attribute( + "code.function.return.value", _to_otel_attribute(result) + ) + + +def _wrap_sync_tool_function( + tool_function: ToolFunction, + otel_wrapper: OTelWrapper, + extra_span_attributes: Optional[dict[str, str]] = None, + **unused_kwargs, +): + @functools.wraps(tool_function) + def wrapped_function(*args, **kwargs): + span_name = _create_function_span_name(tool_function) + attributes = _create_function_span_attributes( + tool_function, args, kwargs, extra_span_attributes + ) + with otel_wrapper.start_as_current_span( + span_name, attributes=attributes + ): + _record_function_call_arguments( + otel_wrapper, tool_function, args, kwargs + ) + result = tool_function(*args, **kwargs) + _record_function_call_result(otel_wrapper, tool_function, result) + return result + + return wrapped_function + + +def _wrap_async_tool_function( + tool_function: ToolFunction, + otel_wrapper: OTelWrapper, + extra_span_attributes: Optional[dict[str, str]] = None, + **unused_kwargs, +): + @functools.wraps(tool_function) + async def wrapped_function(*args, **kwargs): + span_name = _create_function_span_name(tool_function) + attributes = _create_function_span_attributes( + tool_function, args, kwargs, extra_span_attributes + ) + with otel_wrapper.start_as_current_span( + span_name, attributes=attributes + ): + _record_function_call_arguments( + otel_wrapper, tool_function, args, kwargs + ) + result = await tool_function(*args, **kwargs) + _record_function_call_result(otel_wrapper, tool_function, result) + return result + + return wrapped_function + + +def _wrap_tool_function( + tool_function: ToolFunction, otel_wrapper: OTelWrapper, **kwargs +): + if inspect.iscoroutinefunction(tool_function): + return _wrap_async_tool_function(tool_function, otel_wrapper, **kwargs) + return _wrap_sync_tool_function(tool_function, otel_wrapper, **kwargs) + + +def wrapped( + tool_or_tools: Optional[ + Union[ToolFunction, ToolOrDict, ToolListUnion, ToolListUnionDict] + ], + otel_wrapper: OTelWrapper, + **kwargs, +): + if tool_or_tools is None: + return None + if isinstance(tool_or_tools, list): + return [ + wrapped(item, otel_wrapper, **kwargs) for item in tool_or_tools + ] + if isinstance(tool_or_tools, dict): + return { + key: wrapped(value, otel_wrapper, **kwargs) + for (key, value) in tool_or_tools.items() + } + if callable(tool_or_tools): + return _wrap_tool_function(tool_or_tools, otel_wrapper, **kwargs) + return tool_or_tools diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/common/otel_mocker.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/common/otel_mocker.py index c8747789c8..fd87d424d9 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/common/otel_mocker.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/common/otel_mocker.py @@ -170,6 +170,10 @@ def assert_has_span_named(self, name): span is not None ), f'Could not find span named "{name}"; finished spans: {finished_spans}' + def assert_does_not_have_span_named(self, name): + span = self.get_span_named(name) + assert span is None, f"Found unexpected span named {name}" + def get_event_named(self, event_name): for event in self.get_finished_logs(): event_name_attr = event.attributes.get("event.name") diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/test_tool_call_instrumentation.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/test_tool_call_instrumentation.py new file mode 100644 index 0000000000..7e06422812 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/test_tool_call_instrumentation.py @@ -0,0 +1,277 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import patch + +import google.genai.types as genai_types + +from .base import TestCase + + +class ToolCallInstrumentationTestCase(TestCase): + def test_tool_calls_with_config_dict_outputs_spans(self): + calls = [] + + def handle(*args, **kwargs): + calls.append((args, kwargs)) + return "some result" + + def somefunction(somearg): + print("somearg=%s", somearg) + + self.mock_generate_content.side_effect = handle + self.client.models.generate_content( + model="some-model-name", + contents="Some content", + config={ + "tools": [somefunction], + }, + ) + self.assertEqual(len(calls), 1) + config = calls[0][1]["config"] + tools = config.tools + wrapped_somefunction = tools[0] + + self.assertIsNone( + self.otel.get_span_named("execute_tool somefunction") + ) + wrapped_somefunction("someparam") + self.otel.assert_has_span_named("execute_tool somefunction") + generated_span = self.otel.get_span_named("execute_tool somefunction") + self.assertIn("gen_ai.system", generated_span.attributes) + self.assertEqual( + generated_span.attributes["gen_ai.tool.name"], "somefunction" + ) + self.assertEqual( + generated_span.attributes["code.args.positional.count"], 1 + ) + self.assertEqual( + generated_span.attributes["code.args.keyword.count"], 0 + ) + + def test_tool_calls_with_config_object_outputs_spans(self): + calls = [] + + def handle(*args, **kwargs): + calls.append((args, kwargs)) + return "some result" + + def somefunction(somearg): + print("somearg=%s", somearg) + + self.mock_generate_content.side_effect = handle + self.client.models.generate_content( + model="some-model-name", + contents="Some content", + config=genai_types.GenerateContentConfig( + tools=[somefunction], + ), + ) + self.assertEqual(len(calls), 1) + config = calls[0][1]["config"] + tools = config.tools + wrapped_somefunction = tools[0] + + self.assertIsNone( + self.otel.get_span_named("execute_tool somefunction") + ) + wrapped_somefunction("someparam") + self.otel.assert_has_span_named("execute_tool somefunction") + generated_span = self.otel.get_span_named("execute_tool somefunction") + self.assertIn("gen_ai.system", generated_span.attributes) + self.assertEqual( + generated_span.attributes["gen_ai.tool.name"], "somefunction" + ) + self.assertEqual( + generated_span.attributes["code.args.positional.count"], 1 + ) + self.assertEqual( + generated_span.attributes["code.args.keyword.count"], 0 + ) + + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) + def test_tool_calls_record_parameter_values_on_span_if_enabled(self): + calls = [] + + def handle(*args, **kwargs): + calls.append((args, kwargs)) + return "some result" + + def somefunction(someparam, otherparam=2): + print("someparam=%s, otherparam=%s", someparam, otherparam) + + self.mock_generate_content.side_effect = handle + self.client.models.generate_content( + model="some-model-name", + contents="Some content", + config={ + "tools": [somefunction], + }, + ) + self.assertEqual(len(calls), 1) + config = calls[0][1]["config"] + tools = config.tools + wrapped_somefunction = tools[0] + wrapped_somefunction(123, otherparam="abc") + self.otel.assert_has_span_named("execute_tool somefunction") + generated_span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + generated_span.attributes[ + "code.function.parameters.someparam.type" + ], + "int", + ) + self.assertEqual( + generated_span.attributes[ + "code.function.parameters.otherparam.type" + ], + "str", + ) + self.assertEqual( + generated_span.attributes[ + "code.function.parameters.someparam.value" + ], + 123, + ) + self.assertEqual( + generated_span.attributes[ + "code.function.parameters.otherparam.value" + ], + "abc", + ) + + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "false"}, + ) + def test_tool_calls_do_not_record_parameter_values_if_not_enabled(self): + calls = [] + + def handle(*args, **kwargs): + calls.append((args, kwargs)) + return "some result" + + def somefunction(someparam, otherparam=2): + print("someparam=%s, otherparam=%s", someparam, otherparam) + + self.mock_generate_content.side_effect = handle + self.client.models.generate_content( + model="some-model-name", + contents="Some content", + config={ + "tools": [somefunction], + }, + ) + self.assertEqual(len(calls), 1) + config = calls[0][1]["config"] + tools = config.tools + wrapped_somefunction = tools[0] + wrapped_somefunction(123, otherparam="abc") + self.otel.assert_has_span_named("execute_tool somefunction") + generated_span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + generated_span.attributes[ + "code.function.parameters.someparam.type" + ], + "int", + ) + self.assertEqual( + generated_span.attributes[ + "code.function.parameters.otherparam.type" + ], + "str", + ) + self.assertNotIn( + "code.function.parameters.someparam.value", + generated_span.attributes, + ) + self.assertNotIn( + "code.function.parameters.otherparam.value", + generated_span.attributes, + ) + + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) + def test_tool_calls_record_return_values_on_span_if_enabled(self): + calls = [] + + def handle(*args, **kwargs): + calls.append((args, kwargs)) + return "some result" + + def somefunction(x, y=2): + return x + y + + self.mock_generate_content.side_effect = handle + self.client.models.generate_content( + model="some-model-name", + contents="Some content", + config={ + "tools": [somefunction], + }, + ) + self.assertEqual(len(calls), 1) + config = calls[0][1]["config"] + tools = config.tools + wrapped_somefunction = tools[0] + wrapped_somefunction(123) + self.otel.assert_has_span_named("execute_tool somefunction") + generated_span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + generated_span.attributes["code.function.return.type"], "int" + ) + self.assertEqual( + generated_span.attributes["code.function.return.value"], 125 + ) + + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "false"}, + ) + def test_tool_calls_do_not_record_return_values_if_not_enabled(self): + calls = [] + + def handle(*args, **kwargs): + calls.append((args, kwargs)) + return "some result" + + def somefunction(x, y=2): + return x + y + + self.mock_generate_content.side_effect = handle + self.client.models.generate_content( + model="some-model-name", + contents="Some content", + config={ + "tools": [somefunction], + }, + ) + self.assertEqual(len(calls), 1) + config = calls[0][1]["config"] + tools = config.tools + wrapped_somefunction = tools[0] + wrapped_somefunction(123) + self.otel.assert_has_span_named("execute_tool somefunction") + generated_span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + generated_span.attributes["code.function.return.type"], "int" + ) + self.assertNotIn( + "code.function.return.value", generated_span.attributes + ) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..3c8aee3f70 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py @@ -0,0 +1,280 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import unittest +from unittest.mock import patch + +from google.genai import types as genai_types + +from opentelemetry._events import get_event_logger_provider +from opentelemetry.instrumentation.google_genai import ( + otel_wrapper, + tool_call_wrapper, +) +from opentelemetry.metrics import get_meter_provider +from opentelemetry.trace import get_tracer_provider + +from ..common import otel_mocker + + +class TestCase(unittest.TestCase): + def setUp(self): + self._otel = otel_mocker.OTelMocker() + self._otel.install() + self._otel_wrapper = otel_wrapper.OTelWrapper.from_providers( + get_tracer_provider(), + get_event_logger_provider(), + get_meter_provider(), + ) + + @property + def otel(self): + return self._otel + + @property + def otel_wrapper(self): + return self._otel_wrapper + + def wrap(self, tool_or_tools, **kwargs): + return tool_call_wrapper.wrapped( + tool_or_tools, self.otel_wrapper, **kwargs + ) + + def test_wraps_none(self): + result = self.wrap(None) + self.assertIsNone(result) + + def test_wraps_single_tool_function(self): + def somefunction(): + pass + + wrapped_somefunction = self.wrap(somefunction) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + somefunction() + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + wrapped_somefunction() + self.otel.assert_has_span_named("execute_tool somefunction") + span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + span.attributes["gen_ai.operation.name"], "execute_tool" + ) + self.assertEqual(span.attributes["gen_ai.tool.name"], "somefunction") + + def test_wraps_multiple_tool_functions_as_list(self): + def somefunction(): + pass + + def otherfunction(): + pass + + wrapped_functions = self.wrap([somefunction, otherfunction]) + wrapped_somefunction = wrapped_functions[0] + wrapped_otherfunction = wrapped_functions[1] + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + self.otel.assert_does_not_have_span_named("execute_tool otherfunction") + somefunction() + otherfunction() + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + self.otel.assert_does_not_have_span_named("execute_tool otherfunction") + wrapped_somefunction() + self.otel.assert_has_span_named("execute_tool somefunction") + self.otel.assert_does_not_have_span_named("execute_tool otherfunction") + wrapped_otherfunction() + self.otel.assert_has_span_named("execute_tool otherfunction") + + def test_wraps_multiple_tool_functions_as_dict(self): + def somefunction(): + pass + + def otherfunction(): + pass + + wrapped_functions = self.wrap( + {"somefunction": somefunction, "otherfunction": otherfunction} + ) + wrapped_somefunction = wrapped_functions["somefunction"] + wrapped_otherfunction = wrapped_functions["otherfunction"] + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + self.otel.assert_does_not_have_span_named("execute_tool otherfunction") + somefunction() + otherfunction() + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + self.otel.assert_does_not_have_span_named("execute_tool otherfunction") + wrapped_somefunction() + self.otel.assert_has_span_named("execute_tool somefunction") + self.otel.assert_does_not_have_span_named("execute_tool otherfunction") + wrapped_otherfunction() + self.otel.assert_has_span_named("execute_tool otherfunction") + + def test_wraps_async_tool_function(self): + async def somefunction(): + pass + + wrapped_somefunction = self.wrap(somefunction) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + asyncio.run(somefunction()) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + asyncio.run(wrapped_somefunction()) + self.otel.assert_has_span_named("execute_tool somefunction") + + def test_preserves_tool_dict(self): + tool_dict = genai_types.ToolDict() + wrapped_tool_dict = self.wrap(tool_dict) + self.assertEqual(tool_dict, wrapped_tool_dict) + + def test_does_not_have_description_if_no_doc_string(self): + def somefunction(): + pass + + wrapped_somefunction = self.wrap(somefunction) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + somefunction() + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + wrapped_somefunction() + self.otel.assert_has_span_named("execute_tool somefunction") + span = self.otel.get_span_named("execute_tool somefunction") + self.assertNotIn("gen_ai.tool.description", span.attributes) + + def test_has_description_if_doc_string_present(self): + def somefunction(): + """An example tool call function.""" + + wrapped_somefunction = self.wrap(somefunction) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + somefunction() + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + wrapped_somefunction() + self.otel.assert_has_span_named("execute_tool somefunction") + span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + span.attributes["gen_ai.tool.description"], + "An example tool call function.", + ) + + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) + def test_handles_primitive_int_arg(self): + def somefunction(arg=None): + pass + + wrapped_somefunction = self.wrap(somefunction) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + somefunction(12345) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + wrapped_somefunction(12345) + self.otel.assert_has_span_named("execute_tool somefunction") + span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + span.attributes["code.function.parameters.arg.type"], "int" + ) + self.assertEqual( + span.attributes["code.function.parameters.arg.value"], 12345 + ) + + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) + def test_handles_primitive_string_arg(self): + def somefunction(arg=None): + pass + + wrapped_somefunction = self.wrap(somefunction) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + somefunction("a string value") + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + wrapped_somefunction("a string value") + self.otel.assert_has_span_named("execute_tool somefunction") + span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + span.attributes["code.function.parameters.arg.type"], "str" + ) + self.assertEqual( + span.attributes["code.function.parameters.arg.value"], + "a string value", + ) + + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) + def test_handles_dict_arg(self): + def somefunction(arg=None): + pass + + wrapped_somefunction = self.wrap(somefunction) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + somefunction({"key": "value"}) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + wrapped_somefunction({"key": "value"}) + self.otel.assert_has_span_named("execute_tool somefunction") + span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + span.attributes["code.function.parameters.arg.type"], "dict" + ) + self.assertEqual( + span.attributes["code.function.parameters.arg.value"], + '{"key": "value"}', + ) + + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) + def test_handles_primitive_list_arg(self): + def somefunction(arg=None): + pass + + wrapped_somefunction = self.wrap(somefunction) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + somefunction([1, 2, 3]) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + wrapped_somefunction([1, 2, 3]) + self.otel.assert_has_span_named("execute_tool somefunction") + span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + span.attributes["code.function.parameters.arg.type"], "list" + ) + # A conversion is required here, because the Open Telemetry code converts the + # list into a tuple. (But this conversion isn't happening in "tool_call_wrapper.py"). + self.assertEqual( + list(span.attributes["code.function.parameters.arg.value"]), + [1, 2, 3], + ) + + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) + def test_handles_heterogenous_list_arg(self): + def somefunction(arg=None): + pass + + wrapped_somefunction = self.wrap(somefunction) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + somefunction([123, "abc"]) + self.otel.assert_does_not_have_span_named("execute_tool somefunction") + wrapped_somefunction([123, "abc"]) + self.otel.assert_has_span_named("execute_tool somefunction") + span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + span.attributes["code.function.parameters.arg.type"], "list" + ) + self.assertEqual( + span.attributes["code.function.parameters.arg.value"], + '[123, "abc"]', + )