Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit dbdff31

Browse files
michaelsafyanaabmass
andauthoredMay 19, 2025··
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")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
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+
from unittest.mock import patch
16+
17+
import google.genai.types as genai_types
18+
19+
from .base import TestCase
20+
21+
22+
class ToolCallInstrumentationTestCase(TestCase):
23+
def test_tool_calls_with_config_dict_outputs_spans(self):
24+
calls = []
25+
26+
def handle(*args, **kwargs):
27+
calls.append((args, kwargs))
28+
return "some result"
29+
30+
def somefunction(somearg):
31+
print("somearg=%s", somearg)
32+
33+
self.mock_generate_content.side_effect = handle
34+
self.client.models.generate_content(
35+
model="some-model-name",
36+
contents="Some content",
37+
config={
38+
"tools": [somefunction],
39+
},
40+
)
41+
self.assertEqual(len(calls), 1)
42+
config = calls[0][1]["config"]
43+
tools = config.tools
44+
wrapped_somefunction = tools[0]
45+
46+
self.assertIsNone(
47+
self.otel.get_span_named("execute_tool somefunction")
48+
)
49+
wrapped_somefunction("someparam")
50+
self.otel.assert_has_span_named("execute_tool somefunction")
51+
generated_span = self.otel.get_span_named("execute_tool somefunction")
52+
self.assertIn("gen_ai.system", generated_span.attributes)
53+
self.assertEqual(
54+
generated_span.attributes["gen_ai.tool.name"], "somefunction"
55+
)
56+
self.assertEqual(
57+
generated_span.attributes["code.args.positional.count"], 1
58+
)
59+
self.assertEqual(
60+
generated_span.attributes["code.args.keyword.count"], 0
61+
)
62+
63+
def test_tool_calls_with_config_object_outputs_spans(self):
64+
calls = []
65+
66+
def handle(*args, **kwargs):
67+
calls.append((args, kwargs))
68+
return "some result"
69+
70+
def somefunction(somearg):
71+
print("somearg=%s", somearg)
72+
73+
self.mock_generate_content.side_effect = handle
74+
self.client.models.generate_content(
75+
model="some-model-name",
76+
contents="Some content",
77+
config=genai_types.GenerateContentConfig(
78+
tools=[somefunction],
79+
),
80+
)
81+
self.assertEqual(len(calls), 1)
82+
config = calls[0][1]["config"]
83+
tools = config.tools
84+
wrapped_somefunction = tools[0]
85+
86+
self.assertIsNone(
87+
self.otel.get_span_named("execute_tool somefunction")
88+
)
89+
wrapped_somefunction("someparam")
90+
self.otel.assert_has_span_named("execute_tool somefunction")
91+
generated_span = self.otel.get_span_named("execute_tool somefunction")
92+
self.assertIn("gen_ai.system", generated_span.attributes)
93+
self.assertEqual(
94+
generated_span.attributes["gen_ai.tool.name"], "somefunction"
95+
)
96+
self.assertEqual(
97+
generated_span.attributes["code.args.positional.count"], 1
98+
)
99+
self.assertEqual(
100+
generated_span.attributes["code.args.keyword.count"], 0
101+
)
102+
103+
@patch.dict(
104+
"os.environ",
105+
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
106+
)
107+
def test_tool_calls_record_parameter_values_on_span_if_enabled(self):
108+
calls = []
109+
110+
def handle(*args, **kwargs):
111+
calls.append((args, kwargs))
112+
return "some result"
113+
114+
def somefunction(someparam, otherparam=2):
115+
print("someparam=%s, otherparam=%s", someparam, otherparam)
116+
117+
self.mock_generate_content.side_effect = handle
118+
self.client.models.generate_content(
119+
model="some-model-name",
120+
contents="Some content",
121+
config={
122+
"tools": [somefunction],
123+
},
124+
)
125+
self.assertEqual(len(calls), 1)
126+
config = calls[0][1]["config"]
127+
tools = config.tools
128+
wrapped_somefunction = tools[0]
129+
wrapped_somefunction(123, otherparam="abc")
130+
self.otel.assert_has_span_named("execute_tool somefunction")
131+
generated_span = self.otel.get_span_named("execute_tool somefunction")
132+
self.assertEqual(
133+
generated_span.attributes[
134+
"code.function.parameters.someparam.type"
135+
],
136+
"int",
137+
)
138+
self.assertEqual(
139+
generated_span.attributes[
140+
"code.function.parameters.otherparam.type"
141+
],
142+
"str",
143+
)
144+
self.assertEqual(
145+
generated_span.attributes[
146+
"code.function.parameters.someparam.value"
147+
],
148+
123,
149+
)
150+
self.assertEqual(
151+
generated_span.attributes[
152+
"code.function.parameters.otherparam.value"
153+
],
154+
"abc",
155+
)
156+
157+
@patch.dict(
158+
"os.environ",
159+
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "false"},
160+
)
161+
def test_tool_calls_do_not_record_parameter_values_if_not_enabled(self):
162+
calls = []
163+
164+
def handle(*args, **kwargs):
165+
calls.append((args, kwargs))
166+
return "some result"
167+
168+
def somefunction(someparam, otherparam=2):
169+
print("someparam=%s, otherparam=%s", someparam, otherparam)
170+
171+
self.mock_generate_content.side_effect = handle
172+
self.client.models.generate_content(
173+
model="some-model-name",
174+
contents="Some content",
175+
config={
176+
"tools": [somefunction],
177+
},
178+
)
179+
self.assertEqual(len(calls), 1)
180+
config = calls[0][1]["config"]
181+
tools = config.tools
182+
wrapped_somefunction = tools[0]
183+
wrapped_somefunction(123, otherparam="abc")
184+
self.otel.assert_has_span_named("execute_tool somefunction")
185+
generated_span = self.otel.get_span_named("execute_tool somefunction")
186+
self.assertEqual(
187+
generated_span.attributes[
188+
"code.function.parameters.someparam.type"
189+
],
190+
"int",
191+
)
192+
self.assertEqual(
193+
generated_span.attributes[
194+
"code.function.parameters.otherparam.type"
195+
],
196+
"str",
197+
)
198+
self.assertNotIn(
199+
"code.function.parameters.someparam.value",
200+
generated_span.attributes,
201+
)
202+
self.assertNotIn(
203+
"code.function.parameters.otherparam.value",
204+
generated_span.attributes,
205+
)
206+
207+
@patch.dict(
208+
"os.environ",
209+
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
210+
)
211+
def test_tool_calls_record_return_values_on_span_if_enabled(self):
212+
calls = []
213+
214+
def handle(*args, **kwargs):
215+
calls.append((args, kwargs))
216+
return "some result"
217+
218+
def somefunction(x, y=2):
219+
return x + y
220+
221+
self.mock_generate_content.side_effect = handle
222+
self.client.models.generate_content(
223+
model="some-model-name",
224+
contents="Some content",
225+
config={
226+
"tools": [somefunction],
227+
},
228+
)
229+
self.assertEqual(len(calls), 1)
230+
config = calls[0][1]["config"]
231+
tools = config.tools
232+
wrapped_somefunction = tools[0]
233+
wrapped_somefunction(123)
234+
self.otel.assert_has_span_named("execute_tool somefunction")
235+
generated_span = self.otel.get_span_named("execute_tool somefunction")
236+
self.assertEqual(
237+
generated_span.attributes["code.function.return.type"], "int"
238+
)
239+
self.assertEqual(
240+
generated_span.attributes["code.function.return.value"], 125
241+
)
242+
243+
@patch.dict(
244+
"os.environ",
245+
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "false"},
246+
)
247+
def test_tool_calls_do_not_record_return_values_if_not_enabled(self):
248+
calls = []
249+
250+
def handle(*args, **kwargs):
251+
calls.append((args, kwargs))
252+
return "some result"
253+
254+
def somefunction(x, y=2):
255+
return x + y
256+
257+
self.mock_generate_content.side_effect = handle
258+
self.client.models.generate_content(
259+
model="some-model-name",
260+
contents="Some content",
261+
config={
262+
"tools": [somefunction],
263+
},
264+
)
265+
self.assertEqual(len(calls), 1)
266+
config = calls[0][1]["config"]
267+
tools = config.tools
268+
wrapped_somefunction = tools[0]
269+
wrapped_somefunction(123)
270+
self.otel.assert_has_span_named("execute_tool somefunction")
271+
generated_span = self.otel.get_span_named("execute_tool somefunction")
272+
self.assertEqual(
273+
generated_span.attributes["code.function.return.type"], "int"
274+
)
275+
self.assertNotIn(
276+
"code.function.return.value", generated_span.attributes
277+
)

‎instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/__init__.py

Whitespace-only changes.
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
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 asyncio
16+
import unittest
17+
from unittest.mock import patch
18+
19+
from google.genai import types as genai_types
20+
21+
from opentelemetry._events import get_event_logger_provider
22+
from opentelemetry.instrumentation.google_genai import (
23+
otel_wrapper,
24+
tool_call_wrapper,
25+
)
26+
from opentelemetry.metrics import get_meter_provider
27+
from opentelemetry.trace import get_tracer_provider
28+
29+
from ..common import otel_mocker
30+
31+
32+
class TestCase(unittest.TestCase):
33+
def setUp(self):
34+
self._otel = otel_mocker.OTelMocker()
35+
self._otel.install()
36+
self._otel_wrapper = otel_wrapper.OTelWrapper.from_providers(
37+
get_tracer_provider(),
38+
get_event_logger_provider(),
39+
get_meter_provider(),
40+
)
41+
42+
@property
43+
def otel(self):
44+
return self._otel
45+
46+
@property
47+
def otel_wrapper(self):
48+
return self._otel_wrapper
49+
50+
def wrap(self, tool_or_tools, **kwargs):
51+
return tool_call_wrapper.wrapped(
52+
tool_or_tools, self.otel_wrapper, **kwargs
53+
)
54+
55+
def test_wraps_none(self):
56+
result = self.wrap(None)
57+
self.assertIsNone(result)
58+
59+
def test_wraps_single_tool_function(self):
60+
def somefunction():
61+
pass
62+
63+
wrapped_somefunction = self.wrap(somefunction)
64+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
65+
somefunction()
66+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
67+
wrapped_somefunction()
68+
self.otel.assert_has_span_named("execute_tool somefunction")
69+
span = self.otel.get_span_named("execute_tool somefunction")
70+
self.assertEqual(
71+
span.attributes["gen_ai.operation.name"], "execute_tool"
72+
)
73+
self.assertEqual(span.attributes["gen_ai.tool.name"], "somefunction")
74+
75+
def test_wraps_multiple_tool_functions_as_list(self):
76+
def somefunction():
77+
pass
78+
79+
def otherfunction():
80+
pass
81+
82+
wrapped_functions = self.wrap([somefunction, otherfunction])
83+
wrapped_somefunction = wrapped_functions[0]
84+
wrapped_otherfunction = wrapped_functions[1]
85+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
86+
self.otel.assert_does_not_have_span_named("execute_tool otherfunction")
87+
somefunction()
88+
otherfunction()
89+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
90+
self.otel.assert_does_not_have_span_named("execute_tool otherfunction")
91+
wrapped_somefunction()
92+
self.otel.assert_has_span_named("execute_tool somefunction")
93+
self.otel.assert_does_not_have_span_named("execute_tool otherfunction")
94+
wrapped_otherfunction()
95+
self.otel.assert_has_span_named("execute_tool otherfunction")
96+
97+
def test_wraps_multiple_tool_functions_as_dict(self):
98+
def somefunction():
99+
pass
100+
101+
def otherfunction():
102+
pass
103+
104+
wrapped_functions = self.wrap(
105+
{"somefunction": somefunction, "otherfunction": otherfunction}
106+
)
107+
wrapped_somefunction = wrapped_functions["somefunction"]
108+
wrapped_otherfunction = wrapped_functions["otherfunction"]
109+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
110+
self.otel.assert_does_not_have_span_named("execute_tool otherfunction")
111+
somefunction()
112+
otherfunction()
113+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
114+
self.otel.assert_does_not_have_span_named("execute_tool otherfunction")
115+
wrapped_somefunction()
116+
self.otel.assert_has_span_named("execute_tool somefunction")
117+
self.otel.assert_does_not_have_span_named("execute_tool otherfunction")
118+
wrapped_otherfunction()
119+
self.otel.assert_has_span_named("execute_tool otherfunction")
120+
121+
def test_wraps_async_tool_function(self):
122+
async def somefunction():
123+
pass
124+
125+
wrapped_somefunction = self.wrap(somefunction)
126+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
127+
asyncio.run(somefunction())
128+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
129+
asyncio.run(wrapped_somefunction())
130+
self.otel.assert_has_span_named("execute_tool somefunction")
131+
132+
def test_preserves_tool_dict(self):
133+
tool_dict = genai_types.ToolDict()
134+
wrapped_tool_dict = self.wrap(tool_dict)
135+
self.assertEqual(tool_dict, wrapped_tool_dict)
136+
137+
def test_does_not_have_description_if_no_doc_string(self):
138+
def somefunction():
139+
pass
140+
141+
wrapped_somefunction = self.wrap(somefunction)
142+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
143+
somefunction()
144+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
145+
wrapped_somefunction()
146+
self.otel.assert_has_span_named("execute_tool somefunction")
147+
span = self.otel.get_span_named("execute_tool somefunction")
148+
self.assertNotIn("gen_ai.tool.description", span.attributes)
149+
150+
def test_has_description_if_doc_string_present(self):
151+
def somefunction():
152+
"""An example tool call function."""
153+
154+
wrapped_somefunction = self.wrap(somefunction)
155+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
156+
somefunction()
157+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
158+
wrapped_somefunction()
159+
self.otel.assert_has_span_named("execute_tool somefunction")
160+
span = self.otel.get_span_named("execute_tool somefunction")
161+
self.assertEqual(
162+
span.attributes["gen_ai.tool.description"],
163+
"An example tool call function.",
164+
)
165+
166+
@patch.dict(
167+
"os.environ",
168+
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
169+
)
170+
def test_handles_primitive_int_arg(self):
171+
def somefunction(arg=None):
172+
pass
173+
174+
wrapped_somefunction = self.wrap(somefunction)
175+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
176+
somefunction(12345)
177+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
178+
wrapped_somefunction(12345)
179+
self.otel.assert_has_span_named("execute_tool somefunction")
180+
span = self.otel.get_span_named("execute_tool somefunction")
181+
self.assertEqual(
182+
span.attributes["code.function.parameters.arg.type"], "int"
183+
)
184+
self.assertEqual(
185+
span.attributes["code.function.parameters.arg.value"], 12345
186+
)
187+
188+
@patch.dict(
189+
"os.environ",
190+
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
191+
)
192+
def test_handles_primitive_string_arg(self):
193+
def somefunction(arg=None):
194+
pass
195+
196+
wrapped_somefunction = self.wrap(somefunction)
197+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
198+
somefunction("a string value")
199+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
200+
wrapped_somefunction("a string value")
201+
self.otel.assert_has_span_named("execute_tool somefunction")
202+
span = self.otel.get_span_named("execute_tool somefunction")
203+
self.assertEqual(
204+
span.attributes["code.function.parameters.arg.type"], "str"
205+
)
206+
self.assertEqual(
207+
span.attributes["code.function.parameters.arg.value"],
208+
"a string value",
209+
)
210+
211+
@patch.dict(
212+
"os.environ",
213+
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
214+
)
215+
def test_handles_dict_arg(self):
216+
def somefunction(arg=None):
217+
pass
218+
219+
wrapped_somefunction = self.wrap(somefunction)
220+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
221+
somefunction({"key": "value"})
222+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
223+
wrapped_somefunction({"key": "value"})
224+
self.otel.assert_has_span_named("execute_tool somefunction")
225+
span = self.otel.get_span_named("execute_tool somefunction")
226+
self.assertEqual(
227+
span.attributes["code.function.parameters.arg.type"], "dict"
228+
)
229+
self.assertEqual(
230+
span.attributes["code.function.parameters.arg.value"],
231+
'{"key": "value"}',
232+
)
233+
234+
@patch.dict(
235+
"os.environ",
236+
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
237+
)
238+
def test_handles_primitive_list_arg(self):
239+
def somefunction(arg=None):
240+
pass
241+
242+
wrapped_somefunction = self.wrap(somefunction)
243+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
244+
somefunction([1, 2, 3])
245+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
246+
wrapped_somefunction([1, 2, 3])
247+
self.otel.assert_has_span_named("execute_tool somefunction")
248+
span = self.otel.get_span_named("execute_tool somefunction")
249+
self.assertEqual(
250+
span.attributes["code.function.parameters.arg.type"], "list"
251+
)
252+
# A conversion is required here, because the Open Telemetry code converts the
253+
# list into a tuple. (But this conversion isn't happening in "tool_call_wrapper.py").
254+
self.assertEqual(
255+
list(span.attributes["code.function.parameters.arg.value"]),
256+
[1, 2, 3],
257+
)
258+
259+
@patch.dict(
260+
"os.environ",
261+
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
262+
)
263+
def test_handles_heterogenous_list_arg(self):
264+
def somefunction(arg=None):
265+
pass
266+
267+
wrapped_somefunction = self.wrap(somefunction)
268+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
269+
somefunction([123, "abc"])
270+
self.otel.assert_does_not_have_span_named("execute_tool somefunction")
271+
wrapped_somefunction([123, "abc"])
272+
self.otel.assert_has_span_named("execute_tool somefunction")
273+
span = self.otel.get_span_named("execute_tool somefunction")
274+
self.assertEqual(
275+
span.attributes["code.function.parameters.arg.type"], "list"
276+
)
277+
self.assertEqual(
278+
span.attributes["code.function.parameters.arg.value"],
279+
'[123, "abc"]',
280+
)

0 commit comments

Comments
 (0)
Please sign in to comment.