From daed69e77b22685896d87aadfc83648f19b30938 Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Fri, 25 Apr 2025 18:14:25 -0400 Subject: [PATCH 01/17] Begin work to instrument tool calls. --- .../google_genai/custom_semconv.py | 30 ++++ .../google_genai/generate_content.py | 37 +++- .../google_genai/otel_wrapper.py | 14 ++ .../google_genai/tool_call_wrapper.py | 169 ++++++++++++++++++ 4 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py index fcdf6b1c39..68d5ec9491 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py @@ -16,3 +16,33 @@ # Semantic Convention still being defined in: # https://github.com/open-telemetry/semantic-conventions/pull/2125 GCP_GENAI_OPERATION_CONFIG = "gcp.gen_ai.operation.config" + + +# Semantic Convention to be defined. +# https://github.com/open-telemetry/semantic-conventions/issues/2183 +TOOL_CALL_POSITIONAL_ARG_COUNT = "gen_ai.tool.positional_args.count" + + +# Semantic Convention to be defined. +# https://github.com/open-telemetry/semantic-conventions/issues/2183 +TOOL_CALL_KEYWORD_ARG_COUNT = "gen_ai.tool.keyword_args.count" + + +# Semantic Convention to be defined. +# https://github.com/open-telemetry/semantic-conventions/issues/2185 +FUNCTION_TOOL_CALL_START_EVENT_NAME = "function_start" +FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT = "positional_argument_count" +FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT = "keyword_argument_count" +FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS = "positional_arguments" +FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS = "keyword_arguments" + + +# Semantic Convention to be defined. +# https://github.com/open-telemetry/semantic-conventions/issues/2185 +FUNCTION_TOOL_CALL_END_EVENT_NAME = "function_end" +FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT = "result" + + +# Semantic Convention to be defined. +# https://github.com/open-telemetry/semantic-conventions/issues/2184 +CODE_MODULE = "code.module" 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..840d5a7f83 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,23 @@ 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): + if not config.tools: + return config + result = copy.copy(config) + result.tools = [wrapped_tool(tool, otel_wrapper) for tool in config.tools] + return result + + class _GenerateContentInstrumentationHelper: def __init__( self, @@ -229,6 +249,15 @@ 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)) + def start_span_as_current_span( self, model_name, function_name, end_on_exit=True ): @@ -556,7 +585,7 @@ def instrumented_generate_content( self, model=model, contents=contents, - config=config, + config=helper.wrapped_config(config), **kwargs, ) helper.process_response(response) @@ -601,7 +630,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 +675,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 +723,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/otel_wrapper.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/otel_wrapper.py index b7dbb5de41..5685c020b8 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/otel_wrapper.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/otel_wrapper.py @@ -20,6 +20,10 @@ from opentelemetry.semconv._incubating.metrics import gen_ai_metrics from opentelemetry.semconv.schemas import Schemas +from .custom_semconv import ( + FUNCTION_TOOL_CALL_START_EVENT_NAME, + FUNCTION_TOOL_CALL_END_EVENT_NAME, +) from .version import __version__ as _LIBRARY_VERSION _logger = logging.getLogger(__name__) @@ -87,6 +91,16 @@ def log_response_content(self, attributes, body): event_name = "gen_ai.choice" self._log_event(event_name, attributes, body) + def log_function_call_start(self, attributes, body): + _logger.debug("Recording function call start.") + event_name = FUNCTION_TOOL_CALL_START_EVENT_NAME + self._log_event(event_name, attributes, body) + + def log_function_call_end(self, attributes, body): + _logger.debug("Recording function call end.") + event_name = FUNCTION_TOOL_CALL_END_EVENT_NAME + self._log_event(event_name, attributes, body) + def _log_event(self, event_name, attributes, body): event = Event(event_name, body=body, attributes=attributes) self._event_logger.emit(event) 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..05e6df4239 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py @@ -0,0 +1,169 @@ +# 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 typing import Any, Callable, Optional, Union + +import functools +import inspect + + +from opentelemetry import trace +from opentelemetry.semconv._incubating.attributes import ( + code_attributes, +) +from google.genai.types import ( + ToolOrDict, + ToolListUnion, + ToolListUnionDict, +) +from .custom_semconv import ( + CODE_MODULE, + FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT, + FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT, + FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS, + FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS, + FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT, + TOOL_CALL_POSITIONAL_ARG_COUNT, + TOOL_CALL_KEYWORD_ARG_COUNT, +) +from .flags import is_content_recording_enabled +from .otel_wrapper import OTelWrapper + + +ToolFunction = Callable[..., Any] + + +def _to_otel_value(python_value): + """Coerces parameters to something representable with Open Telemetry.""" + if python_value is None: + return None + if isinstance(python_value, list): + return [_to_otel_value(x) for x in python_value] + if isinstance(python_value, dict): + return dict([(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 _create_function_span_name(wrapped_function): + """Constructs the span name for a given local function tool call.""" + function_name = wrapped_function.__name__ + return f"tool_call {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[code_attributes.CODE_FUNCTION_NAME] = wrapped_function.__name__ + result[CODE_MODULE] = wrapped_function.__module__ + result[TOOL_CALL_POSITIONAL_ARG_COUNT] = len(function_args) + result[TOOL_CALL_KEYWORD_ARG_COUNT] = len(function_kwargs) + return result + + +def _record_function_call_event( + otel_wrapper, + wrapped_function, + function_args, + function_kwargs): + """Records the details about a function invocation as a log event.""" + attributes = { + code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, + CODE_MODULE: wrapped_function.__module__, + FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT: len(function_args), + FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT: len(function_kwargs) + } + body = {} + if is_content_recording_enabled(): + body[FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS] = _to_otel_value(function_args) + body[FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS] = _to_otel_value(function_kwargs) + otel_wrapper.log_function_call_start(attributes, body) + + +def _record_function_call_result_event( + otel_wrapper, + wrapped_function, + result): + attributes = { + code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, + CODE_MODULE: wrapped_function.__module__, + } + body = {} + if is_content_recording_enabled(): + body[FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT] = _to_otel_value(result) + otel_wrapper.log_function_call_end(attributes, body) + + +def _wrap_sync_tool_function( + tool_function: ToolFunction, + otel_wrapper: OTelWrapper, + extra_span_attributes: Optional[dict[str, str]] = None, + **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_event(otel_wrapper, tool_function, args, kwargs) + result = tool_function(*args, **kwargs) + _record_function_call_result_event(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, + **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_event(otel_wrapper, tool_function, args, kwargs) + result = await tool_function(*args, **kwargs) + _record_function_call_result_event(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 dict([(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 From 3ae55b69c9681f2e050433832a2b2d3730f2433a Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Tue, 29 Apr 2025 18:06:08 -0400 Subject: [PATCH 02/17] Add tests as well as the ability to record function details in span attributes. --- .../google_genai/custom_semconv.py | 4 +- .../google_genai/tool_call_wrapper.py | 93 ++++++--- .../test_tool_call_instrumentation.py | 191 ++++++++++++++++++ .../tests/utils/test_tool_call_wrapper.py | 14 ++ 4 files changed, 276 insertions(+), 26 deletions(-) create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/test_tool_call_instrumentation.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py index 68d5ec9491..7877013b9b 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py @@ -30,7 +30,7 @@ # Semantic Convention to be defined. # https://github.com/open-telemetry/semantic-conventions/issues/2185 -FUNCTION_TOOL_CALL_START_EVENT_NAME = "function_start" +FUNCTION_TOOL_CALL_START_EVENT_NAME = "function_call.start" FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT = "positional_argument_count" FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT = "keyword_argument_count" FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS = "positional_arguments" @@ -39,7 +39,7 @@ # Semantic Convention to be defined. # https://github.com/open-telemetry/semantic-conventions/issues/2185 -FUNCTION_TOOL_CALL_END_EVENT_NAME = "function_end" +FUNCTION_TOOL_CALL_END_EVENT_NAME = "function_call.end" FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT = "result" 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 05e6df4239..ea4937f61b 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 @@ -81,37 +81,82 @@ def _create_function_span_attributes( return result +def _record_function_call_span_attributes( + otel_wrapper, + wrapped_function, + function_args, + function_kwargs): + """Records the details about a function invocation as span attributes.""" + if not is_content_recording_enabled(): + return + 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 + attribute_name = f"code.function.params.{param_name}" + span.set_attribute(attribute_name, _to_otel_value(entry)) + for key, value in function_kwargs.items(): + attribute_name = f"code.function.params.{key}" + span.set_attribute(attribute_name, _to_otel_value(value)) + + def _record_function_call_event( otel_wrapper, wrapped_function, function_args, function_kwargs): - """Records the details about a function invocation as a log event.""" - attributes = { - code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, - CODE_MODULE: wrapped_function.__module__, - FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT: len(function_args), - FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT: len(function_kwargs) - } - body = {} - if is_content_recording_enabled(): - body[FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS] = _to_otel_value(function_args) - body[FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS] = _to_otel_value(function_kwargs) - otel_wrapper.log_function_call_start(attributes, body) + """Records the details about a function invocation as a log event.""" + attributes = { + code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, + CODE_MODULE: wrapped_function.__module__, + FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT: len(function_args), + FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT: len(function_kwargs) + } + body = {} + if is_content_recording_enabled(): + body[FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS] = _to_otel_value(function_args) + body[FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS] = _to_otel_value(function_kwargs) + otel_wrapper.log_function_call_start(attributes, body) + + +def _record_function_call_arguments( + otel_wrapper, + wrapped_function, + function_args, + function_kwargs): + _record_function_call_span_attributes(otel_wrapper, wrapped_function, function_args, function_kwargs) + _record_function_call_event(otel_wrapper, wrapped_function, function_args, function_kwargs) def _record_function_call_result_event( otel_wrapper, wrapped_function, result): - attributes = { - code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, - CODE_MODULE: wrapped_function.__module__, - } - body = {} - if is_content_recording_enabled(): - body[FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT] = _to_otel_value(result) - otel_wrapper.log_function_call_end(attributes, body) + """Records the details about a function result as a log event.""" + attributes = { + code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, + CODE_MODULE: wrapped_function.__module__, + } + body = {} + if is_content_recording_enabled(): + body[FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT] = _to_otel_value(result) + otel_wrapper.log_function_call_end(attributes, body) + + +def _record_function_call_result_span_attributes(otel_wrapper, wrapped_function, result): + """Records the details about a function result as span attributes.""" + if not is_content_recording_enabled(): + return + span = trace.get_current_span() + span.set_attribute("code.function.return_value", _to_otel_value(result)) + + +def _record_function_call_result(otel_wrapper, wrapped_function, result): + _record_function_call_result_event(otel_wrapper, wrapped_function, result) + _record_function_call_result_span_attributes(otel_wrapper, wrapped_function, result) def _wrap_sync_tool_function( @@ -124,9 +169,9 @@ 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_event(otel_wrapper, tool_function, args, kwargs) + _record_function_call_arguments(otel_wrapper, tool_function, args, kwargs) result = tool_function(*args, **kwargs) - _record_function_call_result_event(otel_wrapper, tool_function, result) + _record_function_call_result(otel_wrapper, tool_function, result) return result return wrapped_function @@ -141,9 +186,9 @@ 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_event(otel_wrapper, tool_function, args, kwargs) + _record_function_call_arguments(otel_wrapper, tool_function, args, kwargs) result = await tool_function(*args, **kwargs) - _record_function_call_result_event(otel_wrapper, tool_function, result) + _record_function_call_result(otel_wrapper, tool_function, result) return result return wrapped_function 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..798cc75e6e --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/test_tool_call_instrumentation.py @@ -0,0 +1,191 @@ +# 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 os +import unittest + +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("tool_call somefunction")) + wrapped_somefunction(somearg="foo") + self.otel.assert_has_span_named("tool_call somefunction") + generated_span = self.otel.get_span_named("tool_call somefunction") + self.assertEqual( + generated_span.attributes["code.function.name"], + "somefunction") + + 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("tool_call somefunction")) + wrapped_somefunction(somearg="foo") + self.otel.assert_has_span_named("tool_call somefunction") + generated_span = self.otel.get_span_named("tool_call somefunction") + self.assertEqual( + generated_span.attributes["code.function.name"], + "somefunction") + + def test_tool_calls_record_parameter_values_on_span_if_enabled(self): + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true" + calls = [] + def handle(*args, **kwargs): + calls.append((args, kwargs)) + return "some result" + def somefunction(foo, bar=2): + print("foo=%s, bar=%s", foo, bar) + 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, bar="abc") + self.otel.assert_has_span_named("tool_call somefunction") + generated_span = self.otel.get_span_named("tool_call somefunction") + self.assertEqual( + generated_span.attributes["code.function.params.foo"], + "123") + self.assertEqual( + generated_span.attributes["code.function.params.bar"], + "'abc'") + + def test_tool_calls_do_not_record_parameter_values_if_not_enabled(self): + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "false" + calls = [] + def handle(*args, **kwargs): + calls.append((args, kwargs)) + return "some result" + def somefunction(foo, bar=2): + print("foo=%s, bar=%s", foo, bar) + 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, bar="abc") + self.otel.assert_has_span_named("tool_call somefunction") + generated_span = self.otel.get_span_named("tool_call somefunction") + self.assertNotIn( + "code.function.params.foo", generated_span.attributes) + self.assertNotIn( + "code.function.params.bar", generated_span.attributes) + + def test_tool_calls_record_return_values_on_span_if_enabled(self): + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true" + 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("tool_call somefunction") + generated_span = self.otel.get_span_named("tool_call somefunction") + self.assertEqual( + generated_span.attributes["code.function.return_value"], + "125") + + def test_tool_calls_do_not_record_return_values_if_not_enabled(self): + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "false" + 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("tool_call somefunction") + generated_span = self.otel.get_span_named("tool_call somefunction") + self.assertNotIn( + "code.function.return_value", generated_span.attributes) 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..f87ce79b7c --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py @@ -0,0 +1,14 @@ +# 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 13ee90d2940c12062ff84e9e02287122f426c3f8 Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Mon, 5 May 2025 15:27:15 -0400 Subject: [PATCH 03/17] Add tests for the tool call wrapper utility. --- .../tests/common/otel_mocker.py | 4 + .../tests/utils/__init__.py | 0 .../tests/utils/test_tool_call_wrapper.py | 110 ++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/__init__.py 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..6b5de638d9 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/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 index f87ce79b7c..b65d225f06 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 @@ -12,3 +12,113 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio +import unittest + +from google.genai import types as genai_types + +from opentelemetry._events import get_event_logger_provider +from opentelemetry.metrics import get_meter_provider +from opentelemetry.trace import get_tracer_provider + +from opentelemetry.instrumentation.google_genai import tool_call_wrapper +from opentelemetry.instrumentation.google_genai import otel_wrapper +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 foo(): + pass + wrapped_foo = self.wrap(foo) + self.otel.assert_does_not_have_span_named("tool_call foo") + foo() + self.otel.assert_does_not_have_span_named("tool_call foo") + wrapped_foo() + self.otel.assert_has_span_named("tool_call foo") + + def test_wraps_multiple_tool_functions_as_list(self): + def foo(): + pass + def bar(): + pass + wrapped_functions = self.wrap([foo, bar]) + wrapped_foo = wrapped_functions[0] + wrapped_bar = wrapped_functions[1] + self.otel.assert_does_not_have_span_named("tool_call foo") + self.otel.assert_does_not_have_span_named("tool_call bar") + foo() + bar() + self.otel.assert_does_not_have_span_named("tool_call foo") + self.otel.assert_does_not_have_span_named("tool_call bar") + wrapped_foo() + self.otel.assert_has_span_named("tool_call foo") + self.otel.assert_does_not_have_span_named("tool_call bar") + wrapped_bar() + self.otel.assert_has_span_named("tool_call bar") + + + def test_wraps_multiple_tool_functions_as_dict(self): + def foo(): + pass + def bar(): + pass + wrapped_functions = self.wrap({ + "foo": foo, + "bar": bar + }) + wrapped_foo = wrapped_functions["foo"] + wrapped_bar = wrapped_functions["bar"] + self.otel.assert_does_not_have_span_named("tool_call foo") + self.otel.assert_does_not_have_span_named("tool_call bar") + foo() + bar() + self.otel.assert_does_not_have_span_named("tool_call foo") + self.otel.assert_does_not_have_span_named("tool_call bar") + wrapped_foo() + self.otel.assert_has_span_named("tool_call foo") + self.otel.assert_does_not_have_span_named("tool_call bar") + wrapped_bar() + self.otel.assert_has_span_named("tool_call bar") + + def test_wraps_async_tool_function(self): + async def foo(): + pass + wrapped_foo = self.wrap(foo) + self.otel.assert_does_not_have_span_named("tool_call foo") + asyncio.run(foo()) + self.otel.assert_does_not_have_span_named("tool_call foo") + asyncio.run(wrapped_foo()) + self.otel.assert_has_span_named("tool_call foo") + + 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) From 29b1594d869dfc274f9ea939b869d0fd55bedb8e Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Mon, 5 May 2025 15:30:47 -0400 Subject: [PATCH 04/17] Update the changelog. --- .../opentelemetry-instrumentation-google-genai/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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)) From a1abb6bc74fc871c0467d975dcd10b46fb790f26 Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Mon, 5 May 2025 15:31:21 -0400 Subject: [PATCH 05/17] Reformat with ruff. --- .../google_genai/custom_semconv.py | 8 +- .../google_genai/generate_content.py | 16 +- .../google_genai/otel_wrapper.py | 2 +- .../google_genai/tool_call_wrapper.py | 160 +++++++++++------- .../tests/common/otel_mocker.py | 2 +- .../test_tool_call_instrumentation.py | 76 ++++++--- .../tests/utils/test_tool_call_wrapper.py | 29 ++-- 7 files changed, 179 insertions(+), 114 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py index 7877013b9b..5bd3d9654d 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py @@ -31,8 +31,12 @@ # Semantic Convention to be defined. # https://github.com/open-telemetry/semantic-conventions/issues/2185 FUNCTION_TOOL_CALL_START_EVENT_NAME = "function_call.start" -FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT = "positional_argument_count" -FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT = "keyword_argument_count" +FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT = ( + "positional_argument_count" +) +FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT = ( + "keyword_argument_count" +) FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS = "positional_arguments" FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS = "keyword_arguments" 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 840d5a7f83..d503b5877f 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 @@ -209,7 +209,9 @@ def _get_response_property(response: GenerateContentResponse, path: str): return current_context -def _coerce_config_to_object(config: GenerateContentConfigOrDict) -> GenerateContentConfig: +def _coerce_config_to_object( + config: GenerateContentConfigOrDict, +) -> GenerateContentConfig: if isinstance(config, GenerateContentConfig): return config # Input must be a dictionary; convert by invoking the constructor. @@ -217,8 +219,8 @@ def _coerce_config_to_object(config: GenerateContentConfigOrDict) -> GenerateCon def _wrapped_config_with_tools( - otel_wrapper: OTelWrapper, - config: GenerateContentConfig): + otel_wrapper: OTelWrapper, config: GenerateContentConfig +): if not config.tools: return config result = copy.copy(config) @@ -250,13 +252,13 @@ def __init__( ) def wrapped_config( - self, - config: Optional[GenerateContentConfigOrDict]) -> Optional[GenerateContentConfig]: + 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)) + self._otel_wrapper, _coerce_config_to_object(config) + ) def start_span_as_current_span( self, model_name, function_name, end_on_exit=True diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/otel_wrapper.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/otel_wrapper.py index 5685c020b8..321b36710a 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/otel_wrapper.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/otel_wrapper.py @@ -21,8 +21,8 @@ from opentelemetry.semconv.schemas import Schemas from .custom_semconv import ( - FUNCTION_TOOL_CALL_START_EVENT_NAME, FUNCTION_TOOL_CALL_END_EVENT_NAME, + FUNCTION_TOOL_CALL_START_EVENT_NAME, ) from .version import __version__ as _LIBRARY_VERSION 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 ea4937f61b..b16ab5dd0b 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 @@ -12,35 +12,34 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Callable, Optional, Union - import functools import inspect +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 google.genai.types import ( - ToolOrDict, - ToolListUnion, - ToolListUnionDict, -) + from .custom_semconv import ( CODE_MODULE, - FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT, + FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT, FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT, - FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS, + FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT, FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS, - FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT, - TOOL_CALL_POSITIONAL_ARG_COUNT, + FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS, TOOL_CALL_KEYWORD_ARG_COUNT, + TOOL_CALL_POSITIONAL_ARG_COUNT, ) from .flags import is_content_recording_enabled from .otel_wrapper import OTelWrapper - ToolFunction = Callable[..., Any] @@ -50,8 +49,10 @@ def _to_otel_value(python_value): return None if isinstance(python_value, list): return [_to_otel_value(x) for x in python_value] - if isinstance(python_value, dict): - return dict([(key, _to_otel_value(val)) for (key, val) in python_value.items()]) + if isinstance(python_value, dict): + return dict( + [(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__"): @@ -60,16 +61,14 @@ def _to_otel_value(python_value): def _create_function_span_name(wrapped_function): - """Constructs the span name for a given local function tool call.""" + """Constructs the span name for a given local function tool call.""" function_name = wrapped_function.__name__ return f"tool_call {function_name}" def _create_function_span_attributes( - wrapped_function, - function_args, - function_kwargs, - extra_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: @@ -82,10 +81,8 @@ def _create_function_span_attributes( def _record_function_call_span_attributes( - otel_wrapper, - wrapped_function, - function_args, - function_kwargs): + otel_wrapper, wrapped_function, function_args, function_kwargs +): """Records the details about a function invocation as span attributes.""" if not is_content_recording_enabled(): return @@ -104,49 +101,56 @@ def _record_function_call_span_attributes( def _record_function_call_event( - otel_wrapper, - wrapped_function, - function_args, - function_kwargs): + otel_wrapper, wrapped_function, function_args, function_kwargs +): """Records the details about a function invocation as a log event.""" attributes = { - code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, - CODE_MODULE: wrapped_function.__module__, - FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT: len(function_args), - FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT: len(function_kwargs) + code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, + CODE_MODULE: wrapped_function.__module__, + FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT: len( + function_args + ), + FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT: len( + function_kwargs + ), } body = {} if is_content_recording_enabled(): - body[FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS] = _to_otel_value(function_args) - body[FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS] = _to_otel_value(function_kwargs) + body[FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS] = ( + _to_otel_value(function_args) + ) + body[FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS] = ( + _to_otel_value(function_kwargs) + ) otel_wrapper.log_function_call_start(attributes, body) def _record_function_call_arguments( - otel_wrapper, - wrapped_function, - function_args, - function_kwargs): - _record_function_call_span_attributes(otel_wrapper, wrapped_function, function_args, function_kwargs) - _record_function_call_event(otel_wrapper, wrapped_function, function_args, function_kwargs) - - -def _record_function_call_result_event( - otel_wrapper, - wrapped_function, - result): + otel_wrapper, wrapped_function, function_args, function_kwargs +): + _record_function_call_span_attributes( + otel_wrapper, wrapped_function, function_args, function_kwargs + ) + _record_function_call_event( + otel_wrapper, wrapped_function, function_args, function_kwargs + ) + + +def _record_function_call_result_event(otel_wrapper, wrapped_function, result): """Records the details about a function result as a log event.""" attributes = { - code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, - CODE_MODULE: wrapped_function.__module__, + code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, + CODE_MODULE: wrapped_function.__module__, } body = {} if is_content_recording_enabled(): - body[FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT] = _to_otel_value(result) + body[FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT] = _to_otel_value(result) otel_wrapper.log_function_call_end(attributes, body) -def _record_function_call_result_span_attributes(otel_wrapper, wrapped_function, result): +def _record_function_call_result_span_attributes( + otel_wrapper, wrapped_function, result +): """Records the details about a function result as span attributes.""" if not is_content_recording_enabled(): return @@ -156,23 +160,33 @@ def _record_function_call_result_span_attributes(otel_wrapper, wrapped_function, def _record_function_call_result(otel_wrapper, wrapped_function, result): _record_function_call_result_event(otel_wrapper, wrapped_function, result) - _record_function_call_result_span_attributes(otel_wrapper, wrapped_function, result) + _record_function_call_result_span_attributes( + otel_wrapper, wrapped_function, result + ) def _wrap_sync_tool_function( tool_function: ToolFunction, otel_wrapper: OTelWrapper, extra_span_attributes: Optional[dict[str, str]] = None, - **kwargs): + **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) + 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 @@ -180,35 +194,55 @@ def _wrap_async_tool_function( tool_function: ToolFunction, otel_wrapper: OTelWrapper, extra_span_attributes: Optional[dict[str, str]] = None, - **kwargs): + **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) + 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): +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]], + tool_or_tools: Optional[ + Union[ToolFunction, ToolOrDict, ToolListUnion, ToolListUnionDict] + ], otel_wrapper: OTelWrapper, - **kwargs): + **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] + return [ + wrapped(item, otel_wrapper, **kwargs) for item in tool_or_tools + ] if isinstance(tool_or_tools, dict): - return dict([(key, wrapped(value, otel_wrapper, **kwargs)) for (key, value) in tool_or_tools.items()]) + return dict( + [ + (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 6b5de638d9..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 @@ -172,7 +172,7 @@ def assert_has_span_named(self, name): 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}' + assert span is None, f"Found unexpected span named {name}" def get_event_named(self, event_name): for event in self.get_finished_logs(): 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 index 798cc75e6e..acd96312ba 100644 --- 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 @@ -13,21 +13,23 @@ # limitations under the License. import os -import unittest import google.genai.types as genai_types from .base import TestCase -class ToolCallInstrumentationTestCase(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", @@ -41,52 +43,58 @@ def somefunction(somearg): tools = config.tools wrapped_somefunction = tools[0] - self.assertIsNone( - self.otel.get_span_named("tool_call somefunction")) + self.assertIsNone(self.otel.get_span_named("tool_call somefunction")) wrapped_somefunction(somearg="foo") self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertEqual( - generated_span.attributes["code.function.name"], - "somefunction") + generated_span.attributes["code.function.name"], "somefunction" + ) 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], - ) + 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("tool_call somefunction")) + self.assertIsNone(self.otel.get_span_named("tool_call somefunction")) wrapped_somefunction(somearg="foo") self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertEqual( - generated_span.attributes["code.function.name"], - "somefunction") + generated_span.attributes["code.function.name"], "somefunction" + ) def test_tool_calls_record_parameter_values_on_span_if_enabled(self): - os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true" + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "true" + ) calls = [] + def handle(*args, **kwargs): calls.append((args, kwargs)) return "some result" + def somefunction(foo, bar=2): print("foo=%s, bar=%s", foo, bar) + self.mock_generate_content.side_effect = handle self.client.models.generate_content( model="some-model-name", @@ -103,20 +111,25 @@ def somefunction(foo, bar=2): self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertEqual( - generated_span.attributes["code.function.params.foo"], - "123") + generated_span.attributes["code.function.params.foo"], "123" + ) self.assertEqual( - generated_span.attributes["code.function.params.bar"], - "'abc'") + generated_span.attributes["code.function.params.bar"], "'abc'" + ) def test_tool_calls_do_not_record_parameter_values_if_not_enabled(self): - os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "false" + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "false" + ) calls = [] + def handle(*args, **kwargs): calls.append((args, kwargs)) return "some result" + def somefunction(foo, bar=2): print("foo=%s, bar=%s", foo, bar) + self.mock_generate_content.side_effect = handle self.client.models.generate_content( model="some-model-name", @@ -132,19 +145,22 @@ def somefunction(foo, bar=2): wrapped_somefunction(123, bar="abc") self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") - self.assertNotIn( - "code.function.params.foo", generated_span.attributes) - self.assertNotIn( - "code.function.params.bar", generated_span.attributes) + self.assertNotIn("code.function.params.foo", generated_span.attributes) + self.assertNotIn("code.function.params.bar", generated_span.attributes) def test_tool_calls_record_return_values_on_span_if_enabled(self): - os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true" + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "true" + ) 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", @@ -161,17 +177,22 @@ def somefunction(x, y=2): self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertEqual( - generated_span.attributes["code.function.return_value"], - "125") + generated_span.attributes["code.function.return_value"], "125" + ) def test_tool_calls_do_not_record_return_values_if_not_enabled(self): - os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "false" + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "false" + ) 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", @@ -188,4 +209,5 @@ def somefunction(x, y=2): self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertNotIn( - "code.function.return_value", generated_span.attributes) + "code.function.return_value", generated_span.attributes + ) 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 b65d225f06..ee7fca3789 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 @@ -18,24 +18,26 @@ 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 opentelemetry.instrumentation.google_genai import tool_call_wrapper -from opentelemetry.instrumentation.google_genai import otel_wrapper 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()) - + get_meter_provider(), + ) + @property def otel(self): return self._otel @@ -46,9 +48,8 @@ def otel_wrapper(self): def wrap(self, tool_or_tools, **kwargs): return tool_call_wrapper.wrapped( - tool_or_tools, - self.otel_wrapper, - **kwargs) + tool_or_tools, self.otel_wrapper, **kwargs + ) def test_wraps_none(self): result = self.wrap(None) @@ -57,6 +58,7 @@ def test_wraps_none(self): def test_wraps_single_tool_function(self): def foo(): pass + wrapped_foo = self.wrap(foo) self.otel.assert_does_not_have_span_named("tool_call foo") foo() @@ -67,8 +69,10 @@ def foo(): def test_wraps_multiple_tool_functions_as_list(self): def foo(): pass + def bar(): pass + wrapped_functions = self.wrap([foo, bar]) wrapped_foo = wrapped_functions[0] wrapped_bar = wrapped_functions[1] @@ -84,16 +88,14 @@ def bar(): wrapped_bar() self.otel.assert_has_span_named("tool_call bar") - def test_wraps_multiple_tool_functions_as_dict(self): def foo(): pass + def bar(): pass - wrapped_functions = self.wrap({ - "foo": foo, - "bar": bar - }) + + wrapped_functions = self.wrap({"foo": foo, "bar": bar}) wrapped_foo = wrapped_functions["foo"] wrapped_bar = wrapped_functions["bar"] self.otel.assert_does_not_have_span_named("tool_call foo") @@ -111,6 +113,7 @@ def bar(): def test_wraps_async_tool_function(self): async def foo(): pass + wrapped_foo = self.wrap(foo) self.otel.assert_does_not_have_span_named("tool_call foo") asyncio.run(foo()) From 3011c84ce723be98d6b2300a8f8865d4750639a0 Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Mon, 5 May 2025 15:39:30 -0400 Subject: [PATCH 06/17] Switch to dictionary comprehension per lint output. --- .../instrumentation/google_genai/tool_call_wrapper.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) 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 b16ab5dd0b..2fe61c759e 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 @@ -50,9 +50,7 @@ def _to_otel_value(python_value): if isinstance(python_value, list): return [_to_otel_value(x) for x in python_value] if isinstance(python_value, dict): - return dict( - [(key, _to_otel_value(val)) for (key, val) in python_value.items()] - ) + 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__"): @@ -237,12 +235,7 @@ def wrapped( wrapped(item, otel_wrapper, **kwargs) for item in tool_or_tools ] if isinstance(tool_or_tools, dict): - return dict( - [ - (key, wrapped(value, otel_wrapper, **kwargs)) - for (key, value) in tool_or_tools.items() - ] - ) + 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 From dbfcbe29bead1b504e89698f34bfa1d6ff066b6f Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Mon, 5 May 2025 15:43:01 -0400 Subject: [PATCH 07/17] Address generic names foo, bar flagged by lint. --- .../test_tool_call_instrumentation.py | 24 ++--- .../tests/utils/test_tool_call_wrapper.py | 92 +++++++++---------- 2 files changed, 58 insertions(+), 58 deletions(-) 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 index acd96312ba..83ab556850 100644 --- 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 @@ -44,7 +44,7 @@ def somefunction(somearg): wrapped_somefunction = tools[0] self.assertIsNone(self.otel.get_span_named("tool_call somefunction")) - wrapped_somefunction(somearg="foo") + wrapped_somefunction(somearg="someparam") self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertEqual( @@ -75,7 +75,7 @@ def somefunction(somearg): wrapped_somefunction = tools[0] self.assertIsNone(self.otel.get_span_named("tool_call somefunction")) - wrapped_somefunction(somearg="foo") + wrapped_somefunction(somearg="someparam") self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertEqual( @@ -92,8 +92,8 @@ def handle(*args, **kwargs): calls.append((args, kwargs)) return "some result" - def somefunction(foo, bar=2): - print("foo=%s, bar=%s", foo, bar) + def somefunction(someparam, otherparam=2): + print("someparam=%s, otherparam=%s", someparam, otherparam) self.mock_generate_content.side_effect = handle self.client.models.generate_content( @@ -107,14 +107,14 @@ def somefunction(foo, bar=2): config = calls[0][1]["config"] tools = config.tools wrapped_somefunction = tools[0] - wrapped_somefunction(123, bar="abc") + wrapped_somefunction(123, otherparam="abc") self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertEqual( - generated_span.attributes["code.function.params.foo"], "123" + generated_span.attributes["code.function.params.someparam"], "123" ) self.assertEqual( - generated_span.attributes["code.function.params.bar"], "'abc'" + generated_span.attributes["code.function.params.otherparam"], "'abc'" ) def test_tool_calls_do_not_record_parameter_values_if_not_enabled(self): @@ -127,8 +127,8 @@ def handle(*args, **kwargs): calls.append((args, kwargs)) return "some result" - def somefunction(foo, bar=2): - print("foo=%s, bar=%s", foo, bar) + def somefunction(someparam, otherparam=2): + print("someparam=%s, otherparam=%s", someparam, otherparam) self.mock_generate_content.side_effect = handle self.client.models.generate_content( @@ -142,11 +142,11 @@ def somefunction(foo, bar=2): config = calls[0][1]["config"] tools = config.tools wrapped_somefunction = tools[0] - wrapped_somefunction(123, bar="abc") + wrapped_somefunction(123, otherparam="abc") self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") - self.assertNotIn("code.function.params.foo", generated_span.attributes) - self.assertNotIn("code.function.params.bar", generated_span.attributes) + self.assertNotIn("code.function.params.someparam", generated_span.attributes) + self.assertNotIn("code.function.params.otherparam", generated_span.attributes) def test_tool_calls_record_return_values_on_span_if_enabled(self): os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( 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 ee7fca3789..9161a73ff5 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 @@ -56,70 +56,70 @@ def test_wraps_none(self): self.assertIsNone(result) def test_wraps_single_tool_function(self): - def foo(): + def somefunction(): pass - wrapped_foo = self.wrap(foo) - self.otel.assert_does_not_have_span_named("tool_call foo") - foo() - self.otel.assert_does_not_have_span_named("tool_call foo") - wrapped_foo() - self.otel.assert_has_span_named("tool_call foo") + wrapped_somefunction = self.wrap(somefunction) + self.otel.assert_does_not_have_span_named("tool_call somefunction") + somefunction() + self.otel.assert_does_not_have_span_named("tool_call somefunction") + wrapped_somefunction() + self.otel.assert_has_span_named("tool_call somefunction") def test_wraps_multiple_tool_functions_as_list(self): - def foo(): + def somefunction(): pass - def bar(): + def otherfunction(): pass - wrapped_functions = self.wrap([foo, bar]) - wrapped_foo = wrapped_functions[0] - wrapped_bar = wrapped_functions[1] - self.otel.assert_does_not_have_span_named("tool_call foo") - self.otel.assert_does_not_have_span_named("tool_call bar") - foo() - bar() - self.otel.assert_does_not_have_span_named("tool_call foo") - self.otel.assert_does_not_have_span_named("tool_call bar") - wrapped_foo() - self.otel.assert_has_span_named("tool_call foo") - self.otel.assert_does_not_have_span_named("tool_call bar") - wrapped_bar() - self.otel.assert_has_span_named("tool_call bar") + wrapped_functions = self.wrap([somefunction, otherfunction]) + wrapped_somefunction = wrapped_functions[0] + wrapped_otherfunction = wrapped_functions[1] + self.otel.assert_does_not_have_span_named("tool_call somefunction") + self.otel.assert_does_not_have_span_named("tool_call otherfunction") + somefunction() + otherfunction() + self.otel.assert_does_not_have_span_named("tool_call somefunction") + self.otel.assert_does_not_have_span_named("tool_call otherfunction") + wrapped_somefunction() + self.otel.assert_has_span_named("tool_call somefunction") + self.otel.assert_does_not_have_span_named("tool_call otherfunction") + wrapped_otherfunction() + self.otel.assert_has_span_named("tool_call otherfunction") def test_wraps_multiple_tool_functions_as_dict(self): - def foo(): + def somefunction(): pass - def bar(): + def otherfunction(): pass - wrapped_functions = self.wrap({"foo": foo, "bar": bar}) - wrapped_foo = wrapped_functions["foo"] - wrapped_bar = wrapped_functions["bar"] - self.otel.assert_does_not_have_span_named("tool_call foo") - self.otel.assert_does_not_have_span_named("tool_call bar") - foo() - bar() - self.otel.assert_does_not_have_span_named("tool_call foo") - self.otel.assert_does_not_have_span_named("tool_call bar") - wrapped_foo() - self.otel.assert_has_span_named("tool_call foo") - self.otel.assert_does_not_have_span_named("tool_call bar") - wrapped_bar() - self.otel.assert_has_span_named("tool_call bar") + 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("tool_call somefunction") + self.otel.assert_does_not_have_span_named("tool_call otherfunction") + somefunction() + otherfunction() + self.otel.assert_does_not_have_span_named("tool_call somefunction") + self.otel.assert_does_not_have_span_named("tool_call otherfunction") + wrapped_somefunction() + self.otel.assert_has_span_named("tool_call somefunction") + self.otel.assert_does_not_have_span_named("tool_call otherfunction") + wrapped_otherfunction() + self.otel.assert_has_span_named("tool_call otherfunction") def test_wraps_async_tool_function(self): - async def foo(): + async def somefunction(): pass - wrapped_foo = self.wrap(foo) - self.otel.assert_does_not_have_span_named("tool_call foo") - asyncio.run(foo()) - self.otel.assert_does_not_have_span_named("tool_call foo") - asyncio.run(wrapped_foo()) - self.otel.assert_has_span_named("tool_call foo") + wrapped_somefunction = self.wrap(somefunction) + self.otel.assert_does_not_have_span_named("tool_call somefunction") + asyncio.run(somefunction()) + self.otel.assert_does_not_have_span_named("tool_call somefunction") + asyncio.run(wrapped_somefunction()) + self.otel.assert_has_span_named("tool_call somefunction") def test_preserves_tool_dict(self): tool_dict = genai_types.ToolDict() From 9adbb375fbff7c699d263b50e15936e756cd3cbe Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Mon, 5 May 2025 15:43:22 -0400 Subject: [PATCH 08/17] Reformat with ruff. --- .../instrumentation/google_genai/tool_call_wrapper.py | 9 +++++++-- .../test_tool_call_instrumentation.py | 11 ++++++++--- .../tests/utils/test_tool_call_wrapper.py | 4 +++- 3 files changed, 18 insertions(+), 6 deletions(-) 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 2fe61c759e..800f062cf4 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 @@ -50,7 +50,9 @@ def _to_otel_value(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()} + 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__"): @@ -235,7 +237,10 @@ def wrapped( 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()} + 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/generate_content/test_tool_call_instrumentation.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/test_tool_call_instrumentation.py index 83ab556850..e6ceb7f2fb 100644 --- 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 @@ -114,7 +114,8 @@ def somefunction(someparam, otherparam=2): generated_span.attributes["code.function.params.someparam"], "123" ) self.assertEqual( - generated_span.attributes["code.function.params.otherparam"], "'abc'" + generated_span.attributes["code.function.params.otherparam"], + "'abc'", ) def test_tool_calls_do_not_record_parameter_values_if_not_enabled(self): @@ -145,8 +146,12 @@ def somefunction(someparam, otherparam=2): wrapped_somefunction(123, otherparam="abc") self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") - self.assertNotIn("code.function.params.someparam", generated_span.attributes) - self.assertNotIn("code.function.params.otherparam", generated_span.attributes) + self.assertNotIn( + "code.function.params.someparam", generated_span.attributes + ) + self.assertNotIn( + "code.function.params.otherparam", generated_span.attributes + ) def test_tool_calls_record_return_values_on_span_if_enabled(self): os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( 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 9161a73ff5..3154e55cc4 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 @@ -95,7 +95,9 @@ def somefunction(): def otherfunction(): pass - wrapped_functions = self.wrap({"somefunction": somefunction, "otherfunction": otherfunction}) + 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("tool_call somefunction") From b3b1f6d714ac159efe15d9f07cd20580ccbc9b37 Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Tue, 13 May 2025 15:59:53 -0400 Subject: [PATCH 09/17] Update to record function details only on the span. --- .../google_genai/custom_semconv.py | 34 ------ .../google_genai/otel_wrapper.py | 14 --- .../google_genai/tool_call_wrapper.py | 101 ++++-------------- .../test_tool_call_instrumentation.py | 32 ++++-- 4 files changed, 48 insertions(+), 133 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py index 5bd3d9654d..fcdf6b1c39 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/custom_semconv.py @@ -16,37 +16,3 @@ # Semantic Convention still being defined in: # https://github.com/open-telemetry/semantic-conventions/pull/2125 GCP_GENAI_OPERATION_CONFIG = "gcp.gen_ai.operation.config" - - -# Semantic Convention to be defined. -# https://github.com/open-telemetry/semantic-conventions/issues/2183 -TOOL_CALL_POSITIONAL_ARG_COUNT = "gen_ai.tool.positional_args.count" - - -# Semantic Convention to be defined. -# https://github.com/open-telemetry/semantic-conventions/issues/2183 -TOOL_CALL_KEYWORD_ARG_COUNT = "gen_ai.tool.keyword_args.count" - - -# Semantic Convention to be defined. -# https://github.com/open-telemetry/semantic-conventions/issues/2185 -FUNCTION_TOOL_CALL_START_EVENT_NAME = "function_call.start" -FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT = ( - "positional_argument_count" -) -FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT = ( - "keyword_argument_count" -) -FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS = "positional_arguments" -FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS = "keyword_arguments" - - -# Semantic Convention to be defined. -# https://github.com/open-telemetry/semantic-conventions/issues/2185 -FUNCTION_TOOL_CALL_END_EVENT_NAME = "function_call.end" -FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT = "result" - - -# Semantic Convention to be defined. -# https://github.com/open-telemetry/semantic-conventions/issues/2184 -CODE_MODULE = "code.module" diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/otel_wrapper.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/otel_wrapper.py index 321b36710a..b7dbb5de41 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/otel_wrapper.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/otel_wrapper.py @@ -20,10 +20,6 @@ from opentelemetry.semconv._incubating.metrics import gen_ai_metrics from opentelemetry.semconv.schemas import Schemas -from .custom_semconv import ( - FUNCTION_TOOL_CALL_END_EVENT_NAME, - FUNCTION_TOOL_CALL_START_EVENT_NAME, -) from .version import __version__ as _LIBRARY_VERSION _logger = logging.getLogger(__name__) @@ -91,16 +87,6 @@ def log_response_content(self, attributes, body): event_name = "gen_ai.choice" self._log_event(event_name, attributes, body) - def log_function_call_start(self, attributes, body): - _logger.debug("Recording function call start.") - event_name = FUNCTION_TOOL_CALL_START_EVENT_NAME - self._log_event(event_name, attributes, body) - - def log_function_call_end(self, attributes, body): - _logger.debug("Recording function call end.") - event_name = FUNCTION_TOOL_CALL_END_EVENT_NAME - self._log_event(event_name, attributes, body) - def _log_event(self, event_name, attributes, body): event = Event(event_name, body=body, attributes=attributes) self._event_logger.emit(event) 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 800f062cf4..ca5462db45 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 @@ -27,16 +27,6 @@ code_attributes, ) -from .custom_semconv import ( - CODE_MODULE, - FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT, - FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT, - FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT, - FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS, - FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS, - TOOL_CALL_KEYWORD_ARG_COUNT, - TOOL_CALL_POSITIONAL_ARG_COUNT, -) from .flags import is_content_recording_enabled from .otel_wrapper import OTelWrapper @@ -74,18 +64,17 @@ def _create_function_span_attributes( if extra_span_attributes: result.update(extra_span_attributes) result[code_attributes.CODE_FUNCTION_NAME] = wrapped_function.__name__ - result[CODE_MODULE] = wrapped_function.__module__ - result[TOOL_CALL_POSITIONAL_ARG_COUNT] = len(function_args) - result[TOOL_CALL_KEYWORD_ARG_COUNT] = len(function_kwargs) + 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_span_attributes( +def _record_function_call_arguments( otel_wrapper, wrapped_function, function_args, function_kwargs ): """Records the details about a function invocation as span attributes.""" - if not is_content_recording_enabled(): - return + include_values = is_content_recording_enabled() span = trace.get_current_span() signature = inspect.signature(wrapped_function) params = list(signature.parameters.values()) @@ -93,76 +82,30 @@ def _record_function_call_span_attributes( param_name = f"args[{index}]" if index < len(params): param_name = params[index].name - attribute_name = f"code.function.params.{param_name}" - span.set_attribute(attribute_name, _to_otel_value(entry)) + attribute_prefix = f"code.function.parameters.{param_name}" + type_attribute = f"{attribute_prefix}.type" + span.set_attribute(type_attribute, type(entry).__name__) + if include_values: + value_attribute = f"{attribute_prefix}.value" + span.set_attribute(value_attribute, _to_otel_value(entry)) for key, value in function_kwargs.items(): - attribute_name = f"code.function.params.{key}" - span.set_attribute(attribute_name, _to_otel_value(value)) + attribute_prefix = f"code.function.parameters.{key}" + type_attribute = f"{attribute_prefix}.type" + span.set_attribute(type_attribute, type(value).__name__) + if include_values: + value_attribute = f"{attribute_prefix}.value" + span.set_attribute(value_attribute, _to_otel_value(value)) -def _record_function_call_event( - otel_wrapper, wrapped_function, function_args, function_kwargs -): - """Records the details about a function invocation as a log event.""" - attributes = { - code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, - CODE_MODULE: wrapped_function.__module__, - FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT: len( - function_args - ), - FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT: len( - function_kwargs - ), - } - body = {} - if is_content_recording_enabled(): - body[FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS] = ( - _to_otel_value(function_args) - ) - body[FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS] = ( - _to_otel_value(function_kwargs) - ) - otel_wrapper.log_function_call_start(attributes, body) - - -def _record_function_call_arguments( - otel_wrapper, wrapped_function, function_args, function_kwargs -): - _record_function_call_span_attributes( - otel_wrapper, wrapped_function, function_args, function_kwargs - ) - _record_function_call_event( - otel_wrapper, wrapped_function, function_args, function_kwargs - ) - - -def _record_function_call_result_event(otel_wrapper, wrapped_function, result): - """Records the details about a function result as a log event.""" - attributes = { - code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__, - CODE_MODULE: wrapped_function.__module__, - } - body = {} - if is_content_recording_enabled(): - body[FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT] = _to_otel_value(result) - otel_wrapper.log_function_call_end(attributes, body) - - -def _record_function_call_result_span_attributes( +def _record_function_call_result( otel_wrapper, wrapped_function, result ): """Records the details about a function result as span attributes.""" - if not is_content_recording_enabled(): - return + include_values = is_content_recording_enabled() span = trace.get_current_span() - span.set_attribute("code.function.return_value", _to_otel_value(result)) - - -def _record_function_call_result(otel_wrapper, wrapped_function, result): - _record_function_call_result_event(otel_wrapper, wrapped_function, result) - _record_function_call_result_span_attributes( - otel_wrapper, wrapped_function, result - ) + span.set_attribute("code.function.return.type", type(result).__name__) + if include_values: + span.set_attribute("code.function.return.value", _to_otel_value(result)) def _wrap_sync_tool_function( 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 index e6ceb7f2fb..d8dad13271 100644 --- 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 @@ -111,10 +111,17 @@ def somefunction(someparam, otherparam=2): self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertEqual( - generated_span.attributes["code.function.params.someparam"], "123" + generated_span.attributes["code.function.parameters.someparam.type"], "int" ) self.assertEqual( - generated_span.attributes["code.function.params.otherparam"], + 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'", ) @@ -146,11 +153,18 @@ def somefunction(someparam, otherparam=2): wrapped_somefunction(123, otherparam="abc") self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call 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.params.someparam", generated_span.attributes + "code.function.parameters.someparam.value", generated_span.attributes ) self.assertNotIn( - "code.function.params.otherparam", generated_span.attributes + "code.function.parameters.otherparam.value", generated_span.attributes ) def test_tool_calls_record_return_values_on_span_if_enabled(self): @@ -182,7 +196,10 @@ def somefunction(x, y=2): self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertEqual( - generated_span.attributes["code.function.return_value"], "125" + generated_span.attributes["code.function.return.type"], "int" + ) + self.assertEqual( + generated_span.attributes["code.function.return.value"], "125" ) def test_tool_calls_do_not_record_return_values_if_not_enabled(self): @@ -213,6 +230,9 @@ def somefunction(x, y=2): wrapped_somefunction(123) self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") + self.assertEqual( + generated_span.attributes["code.function.return.type"], "int" + ) self.assertNotIn( - "code.function.return_value", generated_span.attributes + "code.function.return.value", generated_span.attributes ) From b8b84afb4942963c62557f25c1976cdae673b1f1 Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Tue, 13 May 2025 16:00:14 -0400 Subject: [PATCH 10/17] Reformat with ruff. --- .../google_genai/tool_call_wrapper.py | 8 ++--- .../test_tool_call_instrumentation.py | 33 ++++++++++++++----- 2 files changed, 29 insertions(+), 12 deletions(-) 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 ca5462db45..be822df90e 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 @@ -97,15 +97,15 @@ def _record_function_call_arguments( span.set_attribute(value_attribute, _to_otel_value(value)) -def _record_function_call_result( - otel_wrapper, wrapped_function, result -): +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_value(result)) + span.set_attribute( + "code.function.return.value", _to_otel_value(result) + ) def _wrap_sync_tool_function( 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 index d8dad13271..c40c53ecb8 100644 --- 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 @@ -111,17 +111,27 @@ def somefunction(someparam, otherparam=2): self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertEqual( - generated_span.attributes["code.function.parameters.someparam.type"], "int" + generated_span.attributes[ + "code.function.parameters.someparam.type" + ], + "int", ) self.assertEqual( - generated_span.attributes["code.function.parameters.otherparam.type"], + generated_span.attributes[ + "code.function.parameters.otherparam.type" + ], "str", ) self.assertEqual( - generated_span.attributes["code.function.parameters.someparam.value"], "123" + generated_span.attributes[ + "code.function.parameters.someparam.value" + ], + "123", ) self.assertEqual( - generated_span.attributes["code.function.parameters.otherparam.value"], + generated_span.attributes[ + "code.function.parameters.otherparam.value" + ], "'abc'", ) @@ -154,17 +164,24 @@ def somefunction(someparam, otherparam=2): self.otel.assert_has_span_named("tool_call somefunction") generated_span = self.otel.get_span_named("tool_call somefunction") self.assertEqual( - generated_span.attributes["code.function.parameters.someparam.type"], "int" + generated_span.attributes[ + "code.function.parameters.someparam.type" + ], + "int", ) self.assertEqual( - generated_span.attributes["code.function.parameters.otherparam.type"], + generated_span.attributes[ + "code.function.parameters.otherparam.type" + ], "str", ) self.assertNotIn( - "code.function.parameters.someparam.value", generated_span.attributes + "code.function.parameters.someparam.value", + generated_span.attributes, ) self.assertNotIn( - "code.function.parameters.otherparam.value", generated_span.attributes + "code.function.parameters.otherparam.value", + generated_span.attributes, ) def test_tool_calls_record_return_values_on_span_if_enabled(self): From f4f648b5feafe0927919f7b8407be26965349811 Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Tue, 13 May 2025 17:01:23 -0400 Subject: [PATCH 11/17] Fix lint issue with refactoring improvement. --- .../google_genai/tool_call_wrapper.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) 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 be822df90e..a639b04c99 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 @@ -70,6 +70,19 @@ def _create_function_span_attributes( 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_value(param_value)) + + def _record_function_call_arguments( otel_wrapper, wrapped_function, function_args, function_kwargs ): @@ -82,19 +95,9 @@ def _record_function_call_arguments( param_name = f"args[{index}]" if index < len(params): param_name = params[index].name - attribute_prefix = f"code.function.parameters.{param_name}" - type_attribute = f"{attribute_prefix}.type" - span.set_attribute(type_attribute, type(entry).__name__) - if include_values: - value_attribute = f"{attribute_prefix}.value" - span.set_attribute(value_attribute, _to_otel_value(entry)) + _record_function_call_argument(span, param_name, entry, include_values) for key, value in function_kwargs.items(): - attribute_prefix = f"code.function.parameters.{key}" - type_attribute = f"{attribute_prefix}.type" - span.set_attribute(type_attribute, type(value).__name__) - if include_values: - value_attribute = f"{attribute_prefix}.value" - span.set_attribute(value_attribute, _to_otel_value(value)) + _record_function_call_argument(span, key, value, include_values) def _record_function_call_result(otel_wrapper, wrapped_function, result): From c13766b117d0be50b202713a16b854c0b7b618fa Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Tue, 13 May 2025 17:01:43 -0400 Subject: [PATCH 12/17] Reformat with ruff. --- .../instrumentation/google_genai/tool_call_wrapper.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 a639b04c99..f33ad04194 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 @@ -71,10 +71,8 @@ def _create_function_span_attributes( def _record_function_call_argument( - span, - param_name, - param_value, - include_values): + 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__) From 071a39902c1b9d6e9c40c6767432e6eae391230c Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Mon, 19 May 2025 12:01:33 -0400 Subject: [PATCH 13/17] Improve attribute handling and align with 'execute_tool' span spec. --- .../google_genai/tool_call_wrapper.py | 44 +++++- .../test_tool_call_instrumentation.py | 52 +++--- .../tests/utils/test_tool_call_wrapper.py | 149 +++++++++++++++--- 3 files changed, 190 insertions(+), 55 deletions(-) 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 f33ad04194..e02a8851c4 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 @@ -14,6 +14,7 @@ import functools import inspect +import json from typing import Any, Callable, Optional, Union from google.genai.types import ( @@ -33,10 +34,18 @@ ToolFunction = Callable[..., Any] +def _is_primitive(value): + primitive_types = [str, int, bool, float] + for ptype in primitive_types: + if isinstance(value, ptype): + return True + return False + + def _to_otel_value(python_value): """Coerces parameters to something representable with Open Telemetry.""" - if python_value is None: - return None + 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): @@ -50,10 +59,31 @@ def _to_otel_value(python_value): 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"tool_call {function_name}" + return f"execute_tool {function_name}" def _create_function_span_attributes( @@ -63,6 +93,10 @@ def _create_function_span_attributes( 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) @@ -78,7 +112,7 @@ def _record_function_call_argument( 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_value(param_value)) + span.set_attribute(value_attribute, _to_otel_attribute(param_value)) def _record_function_call_arguments( @@ -105,7 +139,7 @@ def _record_function_call_result(otel_wrapper, wrapped_function, result): span.set_attribute("code.function.return.type", type(result).__name__) if include_values: span.set_attribute( - "code.function.return.value", _to_otel_value(result) + "code.function.return.value", _to_otel_attribute(result) ) 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 index c40c53ecb8..f040ae1fbf 100644 --- 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 @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os +from unittest.mock import patch import google.genai.types as genai_types @@ -43,10 +43,10 @@ def somefunction(somearg): tools = config.tools wrapped_somefunction = tools[0] - self.assertIsNone(self.otel.get_span_named("tool_call somefunction")) + self.assertIsNone(self.otel.get_span_named("execute_tool somefunction")) wrapped_somefunction(somearg="someparam") - self.otel.assert_has_span_named("tool_call somefunction") - generated_span = self.otel.get_span_named("tool_call somefunction") + 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.name"], "somefunction" ) @@ -74,18 +74,16 @@ def somefunction(somearg): tools = config.tools wrapped_somefunction = tools[0] - self.assertIsNone(self.otel.get_span_named("tool_call somefunction")) + self.assertIsNone(self.otel.get_span_named("execute_tool somefunction")) wrapped_somefunction(somearg="someparam") - self.otel.assert_has_span_named("tool_call somefunction") - generated_span = self.otel.get_span_named("tool_call somefunction") + 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.name"], "somefunction" ) + @patch.dict("os.environ", {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}) def test_tool_calls_record_parameter_values_on_span_if_enabled(self): - os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( - "true" - ) calls = [] def handle(*args, **kwargs): @@ -108,8 +106,8 @@ def somefunction(someparam, otherparam=2): tools = config.tools wrapped_somefunction = tools[0] wrapped_somefunction(123, otherparam="abc") - self.otel.assert_has_span_named("tool_call somefunction") - generated_span = self.otel.get_span_named("tool_call somefunction") + 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" @@ -126,19 +124,17 @@ def somefunction(someparam, otherparam=2): generated_span.attributes[ "code.function.parameters.someparam.value" ], - "123", + 123, ) self.assertEqual( generated_span.attributes[ "code.function.parameters.otherparam.value" ], - "'abc'", + "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): - os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( - "false" - ) calls = [] def handle(*args, **kwargs): @@ -161,8 +157,8 @@ def somefunction(someparam, otherparam=2): tools = config.tools wrapped_somefunction = tools[0] wrapped_somefunction(123, otherparam="abc") - self.otel.assert_has_span_named("tool_call somefunction") - generated_span = self.otel.get_span_named("tool_call somefunction") + 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" @@ -184,10 +180,8 @@ def somefunction(someparam, otherparam=2): 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): - os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( - "true" - ) calls = [] def handle(*args, **kwargs): @@ -210,19 +204,17 @@ def somefunction(x, y=2): tools = config.tools wrapped_somefunction = tools[0] wrapped_somefunction(123) - self.otel.assert_has_span_named("tool_call somefunction") - generated_span = self.otel.get_span_named("tool_call somefunction") + 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" + 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): - os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( - "false" - ) calls = [] def handle(*args, **kwargs): @@ -245,8 +237,8 @@ def somefunction(x, y=2): tools = config.tools wrapped_somefunction = tools[0] wrapped_somefunction(123) - self.otel.assert_has_span_named("tool_call somefunction") - generated_span = self.otel.get_span_named("tool_call somefunction") + 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" ) 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 3154e55cc4..5fa27572a6 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 @@ -14,6 +14,7 @@ import asyncio import unittest +from unittest.mock import patch from google.genai import types as genai_types @@ -60,11 +61,14 @@ def somefunction(): pass wrapped_somefunction = self.wrap(somefunction) - self.otel.assert_does_not_have_span_named("tool_call somefunction") + self.otel.assert_does_not_have_span_named("execute_tool somefunction") somefunction() - self.otel.assert_does_not_have_span_named("tool_call somefunction") + self.otel.assert_does_not_have_span_named("execute_tool somefunction") wrapped_somefunction() - self.otel.assert_has_span_named("tool_call 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(): @@ -76,17 +80,17 @@ def otherfunction(): wrapped_functions = self.wrap([somefunction, otherfunction]) wrapped_somefunction = wrapped_functions[0] wrapped_otherfunction = wrapped_functions[1] - self.otel.assert_does_not_have_span_named("tool_call somefunction") - self.otel.assert_does_not_have_span_named("tool_call 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("tool_call somefunction") - self.otel.assert_does_not_have_span_named("tool_call 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("tool_call somefunction") - self.otel.assert_does_not_have_span_named("tool_call otherfunction") + 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("tool_call otherfunction") + self.otel.assert_has_span_named("execute_tool otherfunction") def test_wraps_multiple_tool_functions_as_dict(self): def somefunction(): @@ -100,30 +104,135 @@ def otherfunction(): ) wrapped_somefunction = wrapped_functions["somefunction"] wrapped_otherfunction = wrapped_functions["otherfunction"] - self.otel.assert_does_not_have_span_named("tool_call somefunction") - self.otel.assert_does_not_have_span_named("tool_call 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("tool_call somefunction") - self.otel.assert_does_not_have_span_named("tool_call 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("tool_call somefunction") - self.otel.assert_does_not_have_span_named("tool_call otherfunction") + 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("tool_call 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("tool_call somefunction") + self.otel.assert_does_not_have_span_named("execute_tool somefunction") asyncio.run(somefunction()) - self.otel.assert_does_not_have_span_named("tool_call somefunction") + self.otel.assert_does_not_have_span_named("execute_tool somefunction") asyncio.run(wrapped_somefunction()) - self.otel.assert_has_span_named("tool_call 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.""" + 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.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\"]") From 70aa31b87698ae45790c06fafe0ca4b71e4ec86c Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Mon, 19 May 2025 12:13:00 -0400 Subject: [PATCH 14/17] Pass through the extra span arguments. --- .../google_genai/generate_content.py | 10 ++++++--- .../google_genai/tool_call_wrapper.py | 4 ++-- .../test_tool_call_instrumentation.py | 22 +++++++++++++++---- 3 files changed, 27 insertions(+), 9 deletions(-) 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 d503b5877f..675eb55cce 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 @@ -219,12 +219,14 @@ def _coerce_config_to_object( def _wrapped_config_with_tools( - otel_wrapper: OTelWrapper, config: GenerateContentConfig + otel_wrapper: OTelWrapper, + config: GenerateContentConfig, + **kwargs, ): if not config.tools: return config result = copy.copy(config) - result.tools = [wrapped_tool(tool, otel_wrapper) for tool in config.tools] + result.tools = [wrapped_tool(tool, otel_wrapper, **kwargs) for tool in config.tools] return result @@ -257,7 +259,9 @@ def wrapped_config( if config is None: return None return _wrapped_config_with_tools( - self._otel_wrapper, _coerce_config_to_object(config) + self._otel_wrapper, + _coerce_config_to_object(config), + extra_span_attributes={"gen_ai.system": self._genai_system}, ) def start_span_as_current_span( 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 e02a8851c4..036d00eef7 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 @@ -147,7 +147,7 @@ def _wrap_sync_tool_function( tool_function: ToolFunction, otel_wrapper: OTelWrapper, extra_span_attributes: Optional[dict[str, str]] = None, - **kwargs, + **unused_kwargs, ): @functools.wraps(tool_function) def wrapped_function(*args, **kwargs): @@ -172,7 +172,7 @@ def _wrap_async_tool_function( tool_function: ToolFunction, otel_wrapper: OTelWrapper, extra_span_attributes: Optional[dict[str, str]] = None, - **kwargs, + **unused_kwargs, ): @functools.wraps(tool_function) async def wrapped_function(*args, **kwargs): 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 index f040ae1fbf..5bd37e805a 100644 --- 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 @@ -44,12 +44,19 @@ def somefunction(somearg): wrapped_somefunction = tools[0] self.assertIsNone(self.otel.get_span_named("execute_tool somefunction")) - wrapped_somefunction(somearg="someparam") + 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["code.function.name"], "somefunction" + 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 = [] @@ -75,12 +82,19 @@ def somefunction(somearg): wrapped_somefunction = tools[0] self.assertIsNone(self.otel.get_span_named("execute_tool somefunction")) - wrapped_somefunction(somearg="someparam") + 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["code.function.name"], "somefunction" + 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): From 7d21b9213a5ef11325d47add411bfe0990d17ebb Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Mon, 19 May 2025 12:15:28 -0400 Subject: [PATCH 15/17] Fix lint issues. --- .../instrumentation/google_genai/tool_call_wrapper.py | 4 ++-- .../tests/utils/test_tool_call_wrapper.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) 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 036d00eef7..8232dce990 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 @@ -93,10 +93,10 @@ def _create_function_span_attributes( result = {} if extra_span_attributes: result.update(extra_span_attributes) - result["gen_ai.operation.name"] = "execute_tool" + 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["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) 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 5fa27572a6..d441f673eb 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 @@ -148,7 +148,6 @@ def somefunction(): def test_has_description_if_doc_string_present(self): def somefunction(): """An example tool call function.""" - pass wrapped_somefunction = self.wrap(somefunction) self.otel.assert_does_not_have_span_named("execute_tool somefunction") From 15fad8af9bbaa988ef4001e846dbb811fa106052 Mon Sep 17 00:00:00 2001 From: Michael Aaron Safyan Date: Mon, 19 May 2025 12:15:47 -0400 Subject: [PATCH 16/17] Reformat with ruff. --- .../google_genai/generate_content.py | 4 +- .../test_tool_call_instrumentation.py | 48 +++++++---- .../tests/utils/test_tool_call_wrapper.py | 81 ++++++++++++++----- 3 files changed, 97 insertions(+), 36 deletions(-) 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 675eb55cce..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 @@ -226,7 +226,9 @@ def _wrapped_config_with_tools( if not config.tools: return config result = copy.copy(config) - result.tools = [wrapped_tool(tool, otel_wrapper, **kwargs) for tool in config.tools] + result.tools = [ + wrapped_tool(tool, otel_wrapper, **kwargs) for tool in config.tools + ] return result 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 index 5bd37e805a..7e06422812 100644 --- 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 @@ -43,20 +43,22 @@ def somefunction(somearg): tools = config.tools wrapped_somefunction = tools[0] - self.assertIsNone(self.otel.get_span_named("execute_tool somefunction")) + 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.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) + generated_span.attributes["code.args.positional.count"], 1 + ) self.assertEqual( - generated_span.attributes["code.args.keyword.count"], 0) + generated_span.attributes["code.args.keyword.count"], 0 + ) def test_tool_calls_with_config_object_outputs_spans(self): calls = [] @@ -81,22 +83,27 @@ def somefunction(somearg): tools = config.tools wrapped_somefunction = tools[0] - self.assertIsNone(self.otel.get_span_named("execute_tool somefunction")) + 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.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) + generated_span.attributes["code.args.positional.count"], 1 + ) self.assertEqual( - generated_span.attributes["code.args.keyword.count"], 0) + generated_span.attributes["code.args.keyword.count"], 0 + ) - @patch.dict("os.environ", {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}) + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) def test_tool_calls_record_parameter_values_on_span_if_enabled(self): calls = [] @@ -147,7 +154,10 @@ def somefunction(someparam, otherparam=2): "abc", ) - @patch.dict("os.environ", {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "false"}) + @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 = [] @@ -194,7 +204,10 @@ def somefunction(someparam, otherparam=2): generated_span.attributes, ) - @patch.dict("os.environ", {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}) + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) def test_tool_calls_record_return_values_on_span_if_enabled(self): calls = [] @@ -227,7 +240,10 @@ def somefunction(x, y=2): generated_span.attributes["code.function.return.value"], 125 ) - @patch.dict("os.environ", {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "false"}) + @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 = [] 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 d441f673eb..3c8aee3f70 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 @@ -67,7 +67,9 @@ def 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.operation.name"], "execute_tool" + ) self.assertEqual(span.attributes["gen_ai.tool.name"], "somefunction") def test_wraps_multiple_tool_functions_as_list(self): @@ -156,9 +158,15 @@ def 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.") + self.assertEqual( + span.attributes["gen_ai.tool.description"], + "An example tool call function.", + ) - @patch.dict("os.environ", {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}) + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) def test_handles_primitive_int_arg(self): def somefunction(arg=None): pass @@ -170,10 +178,17 @@ def somefunction(arg=None): 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) + 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"}) + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) def test_handles_primitive_string_arg(self): def somefunction(arg=None): pass @@ -185,25 +200,41 @@ def somefunction(arg=None): 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") + 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"}) + @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'}) + somefunction({"key": "value"}) self.otel.assert_does_not_have_span_named("execute_tool somefunction") - wrapped_somefunction({'key': 'value'}) + 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\"}") + 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"}) + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) def test_handles_primitive_list_arg(self): def somefunction(arg=None): pass @@ -215,13 +246,20 @@ def somefunction(arg=None): 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") + 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]) + list(span.attributes["code.function.parameters.arg.value"]), + [1, 2, 3], + ) - @patch.dict("os.environ", {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}) + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, + ) def test_handles_heterogenous_list_arg(self): def somefunction(arg=None): pass @@ -233,5 +271,10 @@ def somefunction(arg=None): 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\"]") + self.assertEqual( + span.attributes["code.function.parameters.arg.type"], "list" + ) + self.assertEqual( + span.attributes["code.function.parameters.arg.value"], + '[123, "abc"]', + ) From 3af88c2b59d5fa56e17b94b7e16d6f71bdda52a6 Mon Sep 17 00:00:00 2001 From: Aaron Abbott Date: Mon, 19 May 2025 14:05:34 -0400 Subject: [PATCH 17/17] Update instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py --- .../instrumentation/google_genai/tool_call_wrapper.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 8232dce990..7b4cc1924a 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 @@ -35,11 +35,7 @@ def _is_primitive(value): - primitive_types = [str, int, bool, float] - for ptype in primitive_types: - if isinstance(value, ptype): - return True - return False + return isinstance(value, (str, int, bool, float)) def _to_otel_value(python_value):