Skip to content

feat(google-genai): add instrumentation to supplied tool call functions #3446

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
daed69e
Begin work to instrument tool calls.
michaelsafyan Apr 25, 2025
3ab39c7
Merge branch 'main' into google_genai_tool_call_wrapper
michaelsafyan Apr 29, 2025
3ae55b6
Add tests as well as the ability to record function details in span a…
michaelsafyan Apr 29, 2025
a38b798
Merge branch 'open-telemetry:main' into google_genai_tool_call_wrapper
michaelsafyan May 5, 2025
13ee90d
Add tests for the tool call wrapper utility.
michaelsafyan May 5, 2025
29b1594
Update the changelog.
michaelsafyan May 5, 2025
a1abb6b
Reformat with ruff.
michaelsafyan May 5, 2025
3011c84
Switch to dictionary comprehension per lint output.
michaelsafyan May 5, 2025
dbfcbe2
Address generic names foo, bar flagged by lint.
michaelsafyan May 5, 2025
9adbb37
Reformat with ruff.
michaelsafyan May 5, 2025
25362ac
Merge branch 'main' into google_genai_tool_call_wrapper
michaelsafyan May 13, 2025
b3b1f6d
Update to record function details only on the span.
michaelsafyan May 13, 2025
b8b84af
Reformat with ruff.
michaelsafyan May 13, 2025
f4f648b
Fix lint issue with refactoring improvement.
michaelsafyan May 13, 2025
c13766b
Reformat with ruff.
michaelsafyan May 13, 2025
6ecea10
Merge branch 'main' into google_genai_tool_call_wrapper
michaelsafyan May 19, 2025
071a399
Improve attribute handling and align with 'execute_tool' span spec.
michaelsafyan May 19, 2025
70aa31b
Pass through the extra span arguments.
michaelsafyan May 19, 2025
7d21b92
Fix lint issues.
michaelsafyan May 19, 2025
15fad8a
Reformat with ruff.
michaelsafyan May 19, 2025
3af88c2
Update instrumentation-genai/opentelemetry-instrumentation-google-gen…
aabmass May 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,7 @@
ContentListUnionDict,
ContentUnion,
ContentUnionDict,
GenerateContentConfig,
GenerateContentConfigOrDict,
GenerateContentResponse,
)
Expand All @@ -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__)

Expand Down Expand Up @@ -206,6 +209,29 @@ def _get_response_property(response: GenerateContentResponse, path: str):
return current_context


def _coerce_config_to_object(
config: GenerateContentConfigOrDict,
) -> GenerateContentConfig:
if isinstance(config, GenerateContentConfig):
return config
# Input must be a dictionary; convert by invoking the constructor.
return GenerateContentConfig(**config)


def _wrapped_config_with_tools(
otel_wrapper: OTelWrapper,
config: GenerateContentConfig,
**kwargs,
):
if not config.tools:
return config
result = copy.copy(config)
result.tools = [
wrapped_tool(tool, otel_wrapper, **kwargs) for tool in config.tools
]
return result


class _GenerateContentInstrumentationHelper:
def __init__(
self,
Expand All @@ -229,6 +255,17 @@ def __init__(
generate_content_config_key_allowlist or AllowList()
)

def wrapped_config(
self, config: Optional[GenerateContentConfigOrDict]
) -> Optional[GenerateContentConfig]:
if config is None:
return None
return _wrapped_config_with_tools(
self._otel_wrapper,
_coerce_config_to_object(config),
extra_span_attributes={"gen_ai.system": self._genai_system},
)

def start_span_as_current_span(
self, model_name, function_name, end_on_exit=True
):
Expand Down Expand Up @@ -556,7 +593,7 @@ def instrumented_generate_content(
self,
model=model,
contents=contents,
config=config,
config=helper.wrapped_config(config),
**kwargs,
)
helper.process_response(response)
Expand Down Expand Up @@ -601,7 +638,7 @@ def instrumented_generate_content_stream(
self,
model=model,
contents=contents,
config=config,
config=helper.wrapped_config(config),
**kwargs,
):
helper.process_response(response)
Expand Down Expand Up @@ -646,7 +683,7 @@ async def instrumented_generate_content(
self,
model=model,
contents=contents,
config=config,
config=helper.wrapped_config(config),
**kwargs,
)
helper.process_response(response)
Expand Down Expand Up @@ -694,7 +731,7 @@ async def instrumented_generate_content_stream(
self,
model=model,
contents=contents,
config=config,
config=helper.wrapped_config(config),
**kwargs,
)
except Exception as error: # pylint: disable=broad-exception-caught
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import functools
import inspect
import json
from typing import Any, Callable, Optional, Union

from google.genai.types import (
ToolListUnion,
ToolListUnionDict,
ToolOrDict,
)

from opentelemetry import trace
from opentelemetry.semconv._incubating.attributes import (
code_attributes,
)

from .flags import is_content_recording_enabled
from .otel_wrapper import OTelWrapper

ToolFunction = Callable[..., Any]


def _is_primitive(value):
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 or _is_primitive(python_value):
return python_value
if isinstance(python_value, list):
return [_to_otel_value(x) for x in python_value]
if isinstance(python_value, dict):
return {
key: _to_otel_value(val) for (key, val) in python_value.items()
}
if hasattr(python_value, "model_dump"):
return python_value.model_dump()
if hasattr(python_value, "__dict__"):
return _to_otel_value(python_value.__dict__)
return repr(python_value)


def _is_homogenous_primitive_list(value):
if not isinstance(value, list):
return False
if not value:
return True
if not _is_primitive(value[0]):
return False
first_type = type(value[0])
for entry in value[1:]:
if not isinstance(entry, first_type):
return False
return True


def _to_otel_attribute(python_value):
otel_value = _to_otel_value(python_value)
if _is_primitive(otel_value) or _is_homogenous_primitive_list(otel_value):
return otel_value
return json.dumps(otel_value)


def _create_function_span_name(wrapped_function):
"""Constructs the span name for a given local function tool call."""
function_name = wrapped_function.__name__
return f"execute_tool {function_name}"


def _create_function_span_attributes(
wrapped_function, function_args, function_kwargs, extra_span_attributes
):
"""Creates the attributes for a tool call function span."""
result = {}
if extra_span_attributes:
result.update(extra_span_attributes)
result["gen_ai.operation.name"] = "execute_tool"
result["gen_ai.tool.name"] = wrapped_function.__name__
if wrapped_function.__doc__:
result["gen_ai.tool.description"] = wrapped_function.__doc__
result[code_attributes.CODE_FUNCTION_NAME] = wrapped_function.__name__
result["code.module"] = wrapped_function.__module__
result["code.args.positional.count"] = len(function_args)
result["code.args.keyword.count"] = len(function_kwargs)
return result


def _record_function_call_argument(
span, param_name, param_value, include_values
):
attribute_prefix = f"code.function.parameters.{param_name}"
type_attribute = f"{attribute_prefix}.type"
span.set_attribute(type_attribute, type(param_value).__name__)
if include_values:
value_attribute = f"{attribute_prefix}.value"
span.set_attribute(value_attribute, _to_otel_attribute(param_value))


def _record_function_call_arguments(
otel_wrapper, wrapped_function, function_args, function_kwargs
):
"""Records the details about a function invocation as span attributes."""
include_values = is_content_recording_enabled()
span = trace.get_current_span()
signature = inspect.signature(wrapped_function)
params = list(signature.parameters.values())
for index, entry in enumerate(function_args):
param_name = f"args[{index}]"
if index < len(params):
param_name = params[index].name
_record_function_call_argument(span, param_name, entry, include_values)
for key, value in function_kwargs.items():
_record_function_call_argument(span, key, value, include_values)


def _record_function_call_result(otel_wrapper, wrapped_function, result):
"""Records the details about a function result as span attributes."""
include_values = is_content_recording_enabled()
span = trace.get_current_span()
span.set_attribute("code.function.return.type", type(result).__name__)
if include_values:
span.set_attribute(
"code.function.return.value", _to_otel_attribute(result)
)


def _wrap_sync_tool_function(
tool_function: ToolFunction,
otel_wrapper: OTelWrapper,
extra_span_attributes: Optional[dict[str, str]] = None,
**unused_kwargs,
):
@functools.wraps(tool_function)
def wrapped_function(*args, **kwargs):
span_name = _create_function_span_name(tool_function)
attributes = _create_function_span_attributes(
tool_function, args, kwargs, extra_span_attributes
)
with otel_wrapper.start_as_current_span(
span_name, attributes=attributes
):
_record_function_call_arguments(
otel_wrapper, tool_function, args, kwargs
)
result = tool_function(*args, **kwargs)
_record_function_call_result(otel_wrapper, tool_function, result)
return result

return wrapped_function


def _wrap_async_tool_function(
tool_function: ToolFunction,
otel_wrapper: OTelWrapper,
extra_span_attributes: Optional[dict[str, str]] = None,
**unused_kwargs,
):
@functools.wraps(tool_function)
async def wrapped_function(*args, **kwargs):
span_name = _create_function_span_name(tool_function)
attributes = _create_function_span_attributes(
tool_function, args, kwargs, extra_span_attributes
)
with otel_wrapper.start_as_current_span(
span_name, attributes=attributes
):
_record_function_call_arguments(
otel_wrapper, tool_function, args, kwargs
)
result = await tool_function(*args, **kwargs)
_record_function_call_result(otel_wrapper, tool_function, result)
return result

return wrapped_function


def _wrap_tool_function(
tool_function: ToolFunction, otel_wrapper: OTelWrapper, **kwargs
):
if inspect.iscoroutinefunction(tool_function):
return _wrap_async_tool_function(tool_function, otel_wrapper, **kwargs)
return _wrap_sync_tool_function(tool_function, otel_wrapper, **kwargs)


def wrapped(
tool_or_tools: Optional[
Union[ToolFunction, ToolOrDict, ToolListUnion, ToolListUnionDict]
],
otel_wrapper: OTelWrapper,
**kwargs,
):
if tool_or_tools is None:
return None
if isinstance(tool_or_tools, list):
return [
wrapped(item, otel_wrapper, **kwargs) for item in tool_or_tools
]
if isinstance(tool_or_tools, dict):
return {
key: wrapped(value, otel_wrapper, **kwargs)
for (key, value) in tool_or_tools.items()
}
if callable(tool_or_tools):
return _wrap_tool_function(tool_or_tools, otel_wrapper, **kwargs)
return tool_or_tools
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading