Skip to content

Commit 2c0dd4e

Browse files
committed
Add tests to verify instrumentation of the tool calls.
1 parent cbbfea8 commit 2c0dd4e

File tree

4 files changed

+168
-17
lines changed

4 files changed

+168
-17
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,7 @@ def _wrapped_tool(otel_wrapper: OTelWrapper, tool: ToolListUnionDict):
540540
if inspect.iscoroutinefunction(tool):
541541
return tool
542542
tool_name = tool.__name__
543-
should_record_contents = flags.is_content_recording_enabled()
543+
should_record_contents = is_content_recording_enabled()
544544
@functools.wraps(tool)
545545
def wrapped_tool(*args, **kwargs):
546546
with otel_wrapper.start_as_current_span(
@@ -561,7 +561,7 @@ def _wrapped_config_with_tools(
561561
otel_wrapper: OTelWrapper,
562562
config: GenerateContentConfig) -> GenerateContentConfig:
563563
result = copy.copy(config)
564-
result.tool = [_wrapped_tool(otel_wrapper, tool) for tool in config.tools]
564+
result.tools = [_wrapped_tool(otel_wrapper, tool) for tool in config.tools]
565565
return result
566566

567567

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

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

15+
from typing import Optional
16+
1517
import os
1618
import unittest
19+
import unittest.mock
1720

1821
import google.genai
22+
import google.genai.types as genai_types
23+
from google.genai.models import Models, AsyncModels
1924

2025
from .instrumentation_context import InstrumentationContext
2126
from .otel_mocker import OTelMocker
@@ -28,6 +33,7 @@ def refresh(self, request):
2833

2934

3035
class TestCase(unittest.TestCase):
36+
3137
def setUp(self):
3238
self._otel = OTelMocker()
3339
self._otel.install()
@@ -40,11 +46,31 @@ def setUp(self):
4046
self._client = None
4147
self._uses_vertex = False
4248
self._credentials = _FakeCredentials()
49+
self._generate_content_mock = None
50+
self._generate_content_stream_mock = None
51+
self._original_generate_content = Models.generate_content
52+
self._original_generate_content_stream = Models.generate_content_stream
53+
self._original_async_generate_content = AsyncModels.generate_content
54+
self._original_async_generate_content_stream = (
55+
AsyncModels.generate_content_stream
56+
)
4357

4458
def _lazy_init(self):
4559
self._instrumentation_context = InstrumentationContext()
4660
self._instrumentation_context.install()
4761

62+
@property
63+
def mock_generate_content(self):
64+
if self._generate_content_mock is None:
65+
self._create_mocks()
66+
return self._generate_content_mock
67+
68+
@property
69+
def mock_generate_content_stream(self):
70+
if self._generate_content_stream_mock is None:
71+
self._create_mocks()
72+
return self._generate_content_stream_mock
73+
4874
@property
4975
def client(self):
5076
if self._client is None:
@@ -62,6 +88,81 @@ def otel(self):
6288
def set_use_vertex(self, use_vertex):
6389
self._uses_vertex = use_vertex
6490

91+
def generate_content_response(
92+
self,
93+
part: Optional[genai_types.Part] = None,
94+
parts: Optional[list[genai_types.Part]] = None,
95+
content: Optional[genai_types.Content] = None,
96+
candidate: Optional[genai_types.Candidate] = None,
97+
candidates: Optional[list[genai_types.Candidate]] = None,
98+
text: Optional[str] = None):
99+
if text is None:
100+
text = 'Some response text'
101+
if part is None:
102+
part = genai_types.Part(text=text)
103+
if parts is None:
104+
parts = [part]
105+
if content is None:
106+
content = genai_types.Content(parts=parts, role='model')
107+
if candidate is None:
108+
candidate = genai_types.Candidate(content=content)
109+
if candidates is None:
110+
candidates = [candidate]
111+
return genai_types.GenerateContentResponse(candidates=candidates)
112+
113+
def _create_mocks(self):
114+
print("Initializing mocks.")
115+
if self._client is not None:
116+
self._client = None
117+
if self._instrumentation_context is not None:
118+
self._instrumentation_context.uninstall()
119+
self._instrumentation_context = None
120+
self._generate_content_mock = unittest.mock.MagicMock()
121+
self._generate_content_stream_mock = unittest.mock.MagicMock()
122+
123+
def convert_response(arg):
124+
if isinstance(arg, genai_types.GenerateContentResponse):
125+
return arg
126+
if isinstance(arg, str):
127+
return self.generate_content_response(text=arg)
128+
if isinstance(arg, dict):
129+
try:
130+
return genai_types.GenerateContentResponse(**arg)
131+
except Exception:
132+
return self.generate_content_response(**arg)
133+
return arg
134+
135+
def default_stream(*args, **kwargs):
136+
result = self._generate_content_mock(*args, **kwargs)
137+
yield result
138+
self._generate_content_stream_mock.side_effect = default_stream
139+
140+
def sync_variant(*args, **kwargs):
141+
return convert_response(self._generate_content_mock(*args, **kwargs))
142+
143+
def sync_stream_variant(*args, **kwargs):
144+
print("Calling sync stream variant")
145+
for result in self._generate_content_stream_mock(*args, **kwargs):
146+
yield convert_response(result)
147+
148+
async def async_variant(*args, **kwargs):
149+
print("Calling async non-streaming variant")
150+
return sync_variant(*args, **kwargs)
151+
152+
async def async_stream_variant(*args, **kwargs):
153+
print("Calling async stream variant")
154+
async def gen():
155+
for result in sync_stream_variant(*args, **kwargs):
156+
yield result
157+
class GeneratorProvider:
158+
def __aiter__(self):
159+
return gen()
160+
return GeneratorProvider()
161+
Models.generate_content = sync_variant
162+
Models.generate_content_stream = sync_stream_variant
163+
AsyncModels.generate_content = async_variant
164+
AsyncModels.generate_content_stream = async_stream_variant
165+
65166
def _create_client(self):
66167
self._lazy_init()
67168
if self._uses_vertex:
@@ -77,5 +178,16 @@ def _create_client(self):
77178
def tearDown(self):
78179
if self._instrumentation_context is not None:
79180
self._instrumentation_context.uninstall()
181+
if self._generate_content_mock is None:
182+
assert Models.generate_content == self._original_generate_content
183+
assert Models.generate_content_stream == self._original_generate_content_stream
184+
assert AsyncModels.generate_content == self._original_async_generate_content
185+
assert AsyncModels.generate_content_stream == self._original_async_generate_content_stream
80186
self._requests.uninstall()
81187
self._otel.uninstall()
188+
Models.generate_content = self._original_generate_content
189+
Models.generate_content_stream = self._original_generate_content_stream
190+
AsyncModels.generate_content = self._original_async_generate_content
191+
AsyncModels.generate_content_stream = (
192+
self._original_async_generate_content_stream
193+
)

instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py

Lines changed: 33 additions & 2 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+
from google.genai import types as genai_types
1516
import json
1617
import os
1718
import unittest
@@ -36,8 +37,10 @@ def generate_content(self, *args, **kwargs):
3637
def expected_function_name(self):
3738
raise NotImplementedError("Must implement 'expected_function_name'.")
3839

39-
def configure_valid_response(self, *args, **kwargs):
40-
self.requests.add_response(create_valid_response(*args, **kwargs))
40+
def configure_valid_response(self, *args, if_matches=None, **kwargs):
41+
self.requests.add_response(
42+
create_valid_response(*args, **kwargs),
43+
if_matches=if_matches)
4144

4245
def test_instrumentation_does_not_break_core_functionality(self):
4346
self.configure_valid_response(response_text="Yep, it works!")
@@ -197,3 +200,31 @@ def test_records_metrics_data(self):
197200
self.otel.assert_has_metrics_data_named(
198201
"gen_ai.client.operation.duration"
199202
)
203+
204+
def test_autoinstruments_tools(self):
205+
def factorial(n: int):
206+
result = 1
207+
while n > 1:
208+
result *= n
209+
n -= 1
210+
return result
211+
212+
def generate_content_impl(*args, **kwargs):
213+
config = kwargs['config']
214+
tools = config.tools
215+
assert len(tools) == 1
216+
factorial_tool = tools[0]
217+
return str(factorial_tool(5))
218+
219+
self.mock_generate_content.side_effect = generate_content_impl
220+
221+
response=self.generate_content(
222+
model="gemini-2.0-flash",
223+
contents="Compute 5 factorial",
224+
config=genai_types.GenerateContentConfig(tools=[factorial]))
225+
self.assertEqual(response.text, '120')
226+
self.otel.assert_has_span_named("generate_content gemini-2.0-flash")
227+
self.otel.assert_has_span_named("tool_call factorial")
228+
generate_content_span = self.otel.get_span_named("generate_content gemini-2.0-flash")
229+
tool_call_span = self.otel.get_span_named("tool_call factorial")
230+
self.assertEqual(tool_call_span.parent.span_id, generate_content_span.context.span_id)

instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/util.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,33 @@
1414

1515

1616
def create_valid_response(
17-
response_text="The model response", input_tokens=10, output_tokens=20
17+
response_text="The model response",
18+
input_tokens=10,
19+
output_tokens=20,
20+
part=None,
21+
parts=None,
22+
candidate=None,
23+
candidates=None
1824
):
25+
if part is None:
26+
part = {"text": response_text}
27+
if parts is None:
28+
parts = [part]
29+
if candidate is None:
30+
candidate = {
31+
"content": {
32+
"role": "model",
33+
"parts": parts,
34+
}
35+
}
36+
if candidates is None:
37+
candidates = [candidate]
1938
return {
2039
"modelVersion": "gemini-2.0-flash-test123",
2140
"usageMetadata": {
2241
"promptTokenCount": input_tokens,
2342
"candidatesTokenCount": output_tokens,
2443
"totalTokenCount": input_tokens + output_tokens,
2544
},
26-
"candidates": [
27-
{
28-
"content": {
29-
"role": "model",
30-
"parts": [
31-
{
32-
"text": response_text,
33-
}
34-
],
35-
}
36-
}
37-
],
45+
"candidates": candidates,
3846
}

0 commit comments

Comments
 (0)