Skip to content

Commit dbdff31

Browse files
feat(google-genai): add instrumentation to supplied tool call functions (#3446)
* Begin work to instrument tool calls. * Add tests as well as the ability to record function details in span attributes. * Add tests for the tool call wrapper utility. * Update the changelog. * Reformat with ruff. * Switch to dictionary comprehension per lint output. * Address generic names foo, bar flagged by lint. * Reformat with ruff. * Update to record function details only on the span. * Reformat with ruff. * Fix lint issue with refactoring improvement. * Reformat with ruff. * Improve attribute handling and align with 'execute_tool' span spec. * Pass through the extra span arguments. * Fix lint issues. * Reformat with ruff. * Update instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py --------- Co-authored-by: Aaron Abbott <[email protected]>
1 parent ef2b546 commit dbdff31

File tree

7 files changed

+824
-4
lines changed

7 files changed

+824
-4
lines changed

instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
- Add automatic instrumentation to tool call functions ([#3446](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3446))
11+
1012
## Version 0.2b0 (2025-04-28)
1113

1214
- Add more request configuration options to the span attributes ([#3374](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3374))

instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import copy
1516
import functools
1617
import json
1718
import logging
@@ -28,6 +29,7 @@
2829
ContentListUnionDict,
2930
ContentUnion,
3031
ContentUnionDict,
32+
GenerateContentConfig,
3133
GenerateContentConfigOrDict,
3234
GenerateContentResponse,
3335
)
@@ -44,6 +46,7 @@
4446
from .dict_util import flatten_dict
4547
from .flags import is_content_recording_enabled
4648
from .otel_wrapper import OTelWrapper
49+
from .tool_call_wrapper import wrapped as wrapped_tool
4750

4851
_logger = logging.getLogger(__name__)
4952

@@ -206,6 +209,29 @@ def _get_response_property(response: GenerateContentResponse, path: str):
206209
return current_context
207210

208211

212+
def _coerce_config_to_object(
213+
config: GenerateContentConfigOrDict,
214+
) -> GenerateContentConfig:
215+
if isinstance(config, GenerateContentConfig):
216+
return config
217+
# Input must be a dictionary; convert by invoking the constructor.
218+
return GenerateContentConfig(**config)
219+
220+
221+
def _wrapped_config_with_tools(
222+
otel_wrapper: OTelWrapper,
223+
config: GenerateContentConfig,
224+
**kwargs,
225+
):
226+
if not config.tools:
227+
return config
228+
result = copy.copy(config)
229+
result.tools = [
230+
wrapped_tool(tool, otel_wrapper, **kwargs) for tool in config.tools
231+
]
232+
return result
233+
234+
209235
class _GenerateContentInstrumentationHelper:
210236
def __init__(
211237
self,
@@ -229,6 +255,17 @@ def __init__(
229255
generate_content_config_key_allowlist or AllowList()
230256
)
231257

258+
def wrapped_config(
259+
self, config: Optional[GenerateContentConfigOrDict]
260+
) -> Optional[GenerateContentConfig]:
261+
if config is None:
262+
return None
263+
return _wrapped_config_with_tools(
264+
self._otel_wrapper,
265+
_coerce_config_to_object(config),
266+
extra_span_attributes={"gen_ai.system": self._genai_system},
267+
)
268+
232269
def start_span_as_current_span(
233270
self, model_name, function_name, end_on_exit=True
234271
):
@@ -556,7 +593,7 @@ def instrumented_generate_content(
556593
self,
557594
model=model,
558595
contents=contents,
559-
config=config,
596+
config=helper.wrapped_config(config),
560597
**kwargs,
561598
)
562599
helper.process_response(response)
@@ -601,7 +638,7 @@ def instrumented_generate_content_stream(
601638
self,
602639
model=model,
603640
contents=contents,
604-
config=config,
641+
config=helper.wrapped_config(config),
605642
**kwargs,
606643
):
607644
helper.process_response(response)
@@ -646,7 +683,7 @@ async def instrumented_generate_content(
646683
self,
647684
model=model,
648685
contents=contents,
649-
config=config,
686+
config=helper.wrapped_config(config),
650687
**kwargs,
651688
)
652689
helper.process_response(response)
@@ -694,7 +731,7 @@ async def instrumented_generate_content_stream(
694731
self,
695732
model=model,
696733
contents=contents,
697-
config=config,
734+
config=helper.wrapped_config(config),
698735
**kwargs,
699736
)
700737
except Exception as error: # pylint: disable=broad-exception-caught
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import functools
16+
import inspect
17+
import json
18+
from typing import Any, Callable, Optional, Union
19+
20+
from google.genai.types import (
21+
ToolListUnion,
22+
ToolListUnionDict,
23+
ToolOrDict,
24+
)
25+
26+
from opentelemetry import trace
27+
from opentelemetry.semconv._incubating.attributes import (
28+
code_attributes,
29+
)
30+
31+
from .flags import is_content_recording_enabled
32+
from .otel_wrapper import OTelWrapper
33+
34+
ToolFunction = Callable[..., Any]
35+
36+
37+
def _is_primitive(value):
38+
return isinstance(value, (str, int, bool, float))
39+
40+
41+
def _to_otel_value(python_value):
42+
"""Coerces parameters to something representable with Open Telemetry."""
43+
if python_value is None or _is_primitive(python_value):
44+
return python_value
45+
if isinstance(python_value, list):
46+
return [_to_otel_value(x) for x in python_value]
47+
if isinstance(python_value, dict):
48+
return {
49+
key: _to_otel_value(val) for (key, val) in python_value.items()
50+
}
51+
if hasattr(python_value, "model_dump"):
52+
return python_value.model_dump()
53+
if hasattr(python_value, "__dict__"):
54+
return _to_otel_value(python_value.__dict__)
55+
return repr(python_value)
56+
57+
58+
def _is_homogenous_primitive_list(value):
59+
if not isinstance(value, list):
60+
return False
61+
if not value:
62+
return True
63+
if not _is_primitive(value[0]):
64+
return False
65+
first_type = type(value[0])
66+
for entry in value[1:]:
67+
if not isinstance(entry, first_type):
68+
return False
69+
return True
70+
71+
72+
def _to_otel_attribute(python_value):
73+
otel_value = _to_otel_value(python_value)
74+
if _is_primitive(otel_value) or _is_homogenous_primitive_list(otel_value):
75+
return otel_value
76+
return json.dumps(otel_value)
77+
78+
79+
def _create_function_span_name(wrapped_function):
80+
"""Constructs the span name for a given local function tool call."""
81+
function_name = wrapped_function.__name__
82+
return f"execute_tool {function_name}"
83+
84+
85+
def _create_function_span_attributes(
86+
wrapped_function, function_args, function_kwargs, extra_span_attributes
87+
):
88+
"""Creates the attributes for a tool call function span."""
89+
result = {}
90+
if extra_span_attributes:
91+
result.update(extra_span_attributes)
92+
result["gen_ai.operation.name"] = "execute_tool"
93+
result["gen_ai.tool.name"] = wrapped_function.__name__
94+
if wrapped_function.__doc__:
95+
result["gen_ai.tool.description"] = wrapped_function.__doc__
96+
result[code_attributes.CODE_FUNCTION_NAME] = wrapped_function.__name__
97+
result["code.module"] = wrapped_function.__module__
98+
result["code.args.positional.count"] = len(function_args)
99+
result["code.args.keyword.count"] = len(function_kwargs)
100+
return result
101+
102+
103+
def _record_function_call_argument(
104+
span, param_name, param_value, include_values
105+
):
106+
attribute_prefix = f"code.function.parameters.{param_name}"
107+
type_attribute = f"{attribute_prefix}.type"
108+
span.set_attribute(type_attribute, type(param_value).__name__)
109+
if include_values:
110+
value_attribute = f"{attribute_prefix}.value"
111+
span.set_attribute(value_attribute, _to_otel_attribute(param_value))
112+
113+
114+
def _record_function_call_arguments(
115+
otel_wrapper, wrapped_function, function_args, function_kwargs
116+
):
117+
"""Records the details about a function invocation as span attributes."""
118+
include_values = is_content_recording_enabled()
119+
span = trace.get_current_span()
120+
signature = inspect.signature(wrapped_function)
121+
params = list(signature.parameters.values())
122+
for index, entry in enumerate(function_args):
123+
param_name = f"args[{index}]"
124+
if index < len(params):
125+
param_name = params[index].name
126+
_record_function_call_argument(span, param_name, entry, include_values)
127+
for key, value in function_kwargs.items():
128+
_record_function_call_argument(span, key, value, include_values)
129+
130+
131+
def _record_function_call_result(otel_wrapper, wrapped_function, result):
132+
"""Records the details about a function result as span attributes."""
133+
include_values = is_content_recording_enabled()
134+
span = trace.get_current_span()
135+
span.set_attribute("code.function.return.type", type(result).__name__)
136+
if include_values:
137+
span.set_attribute(
138+
"code.function.return.value", _to_otel_attribute(result)
139+
)
140+
141+
142+
def _wrap_sync_tool_function(
143+
tool_function: ToolFunction,
144+
otel_wrapper: OTelWrapper,
145+
extra_span_attributes: Optional[dict[str, str]] = None,
146+
**unused_kwargs,
147+
):
148+
@functools.wraps(tool_function)
149+
def wrapped_function(*args, **kwargs):
150+
span_name = _create_function_span_name(tool_function)
151+
attributes = _create_function_span_attributes(
152+
tool_function, args, kwargs, extra_span_attributes
153+
)
154+
with otel_wrapper.start_as_current_span(
155+
span_name, attributes=attributes
156+
):
157+
_record_function_call_arguments(
158+
otel_wrapper, tool_function, args, kwargs
159+
)
160+
result = tool_function(*args, **kwargs)
161+
_record_function_call_result(otel_wrapper, tool_function, result)
162+
return result
163+
164+
return wrapped_function
165+
166+
167+
def _wrap_async_tool_function(
168+
tool_function: ToolFunction,
169+
otel_wrapper: OTelWrapper,
170+
extra_span_attributes: Optional[dict[str, str]] = None,
171+
**unused_kwargs,
172+
):
173+
@functools.wraps(tool_function)
174+
async def wrapped_function(*args, **kwargs):
175+
span_name = _create_function_span_name(tool_function)
176+
attributes = _create_function_span_attributes(
177+
tool_function, args, kwargs, extra_span_attributes
178+
)
179+
with otel_wrapper.start_as_current_span(
180+
span_name, attributes=attributes
181+
):
182+
_record_function_call_arguments(
183+
otel_wrapper, tool_function, args, kwargs
184+
)
185+
result = await tool_function(*args, **kwargs)
186+
_record_function_call_result(otel_wrapper, tool_function, result)
187+
return result
188+
189+
return wrapped_function
190+
191+
192+
def _wrap_tool_function(
193+
tool_function: ToolFunction, otel_wrapper: OTelWrapper, **kwargs
194+
):
195+
if inspect.iscoroutinefunction(tool_function):
196+
return _wrap_async_tool_function(tool_function, otel_wrapper, **kwargs)
197+
return _wrap_sync_tool_function(tool_function, otel_wrapper, **kwargs)
198+
199+
200+
def wrapped(
201+
tool_or_tools: Optional[
202+
Union[ToolFunction, ToolOrDict, ToolListUnion, ToolListUnionDict]
203+
],
204+
otel_wrapper: OTelWrapper,
205+
**kwargs,
206+
):
207+
if tool_or_tools is None:
208+
return None
209+
if isinstance(tool_or_tools, list):
210+
return [
211+
wrapped(item, otel_wrapper, **kwargs) for item in tool_or_tools
212+
]
213+
if isinstance(tool_or_tools, dict):
214+
return {
215+
key: wrapped(value, otel_wrapper, **kwargs)
216+
for (key, value) in tool_or_tools.items()
217+
}
218+
if callable(tool_or_tools):
219+
return _wrap_tool_function(tool_or_tools, otel_wrapper, **kwargs)
220+
return tool_or_tools

instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/common/otel_mocker.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ def assert_has_span_named(self, name):
170170
span is not None
171171
), f'Could not find span named "{name}"; finished spans: {finished_spans}'
172172

173+
def assert_does_not_have_span_named(self, name):
174+
span = self.get_span_named(name)
175+
assert span is None, f"Found unexpected span named {name}"
176+
173177
def get_event_named(self, event_name):
174178
for event in self.get_finished_logs():
175179
event_name_attr = event.attributes.get("event.name")

0 commit comments

Comments
 (0)