Skip to content

Commit 3ae55b6

Browse files
committed
Add tests as well as the ability to record function details in span attributes.
1 parent 3ab39c7 commit 3ae55b6

File tree

4 files changed

+276
-26
lines changed

4 files changed

+276
-26
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
# Semantic Convention to be defined.
3232
# https://github.com/open-telemetry/semantic-conventions/issues/2185
33-
FUNCTION_TOOL_CALL_START_EVENT_NAME = "function_start"
33+
FUNCTION_TOOL_CALL_START_EVENT_NAME = "function_call.start"
3434
FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT = "positional_argument_count"
3535
FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT = "keyword_argument_count"
3636
FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS = "positional_arguments"
@@ -39,7 +39,7 @@
3939

4040
# Semantic Convention to be defined.
4141
# https://github.com/open-telemetry/semantic-conventions/issues/2185
42-
FUNCTION_TOOL_CALL_END_EVENT_NAME = "function_end"
42+
FUNCTION_TOOL_CALL_END_EVENT_NAME = "function_call.end"
4343
FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT = "result"
4444

4545

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

Lines changed: 69 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -81,37 +81,82 @@ def _create_function_span_attributes(
8181
return result
8282

8383

84+
def _record_function_call_span_attributes(
85+
otel_wrapper,
86+
wrapped_function,
87+
function_args,
88+
function_kwargs):
89+
"""Records the details about a function invocation as span attributes."""
90+
if not is_content_recording_enabled():
91+
return
92+
span = trace.get_current_span()
93+
signature = inspect.signature(wrapped_function)
94+
params = list(signature.parameters.values())
95+
for index, entry in enumerate(function_args):
96+
param_name = f"args[{index}]"
97+
if index < len(params):
98+
param_name = params[index].name
99+
attribute_name = f"code.function.params.{param_name}"
100+
span.set_attribute(attribute_name, _to_otel_value(entry))
101+
for key, value in function_kwargs.items():
102+
attribute_name = f"code.function.params.{key}"
103+
span.set_attribute(attribute_name, _to_otel_value(value))
104+
105+
84106
def _record_function_call_event(
85107
otel_wrapper,
86108
wrapped_function,
87109
function_args,
88110
function_kwargs):
89-
"""Records the details about a function invocation as a log event."""
90-
attributes = {
91-
code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__,
92-
CODE_MODULE: wrapped_function.__module__,
93-
FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT: len(function_args),
94-
FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT: len(function_kwargs)
95-
}
96-
body = {}
97-
if is_content_recording_enabled():
98-
body[FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS] = _to_otel_value(function_args)
99-
body[FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS] = _to_otel_value(function_kwargs)
100-
otel_wrapper.log_function_call_start(attributes, body)
111+
"""Records the details about a function invocation as a log event."""
112+
attributes = {
113+
code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__,
114+
CODE_MODULE: wrapped_function.__module__,
115+
FUNCTION_TOOL_CALL_START_EVENT_ATTRS_POSITIONAL_ARGS_COUNT: len(function_args),
116+
FUNCTION_TOOL_CALL_START_EVENT_ATTRS_KEYWORD_ARGS_COUNT: len(function_kwargs)
117+
}
118+
body = {}
119+
if is_content_recording_enabled():
120+
body[FUNCTION_TOOL_CALL_START_EVENT_BODY_POSITIONAL_ARGS] = _to_otel_value(function_args)
121+
body[FUNCTION_TOOL_CALL_START_EVENT_BODY_KEYWORD_ARGS] = _to_otel_value(function_kwargs)
122+
otel_wrapper.log_function_call_start(attributes, body)
123+
124+
125+
def _record_function_call_arguments(
126+
otel_wrapper,
127+
wrapped_function,
128+
function_args,
129+
function_kwargs):
130+
_record_function_call_span_attributes(otel_wrapper, wrapped_function, function_args, function_kwargs)
131+
_record_function_call_event(otel_wrapper, wrapped_function, function_args, function_kwargs)
101132

102133

103134
def _record_function_call_result_event(
104135
otel_wrapper,
105136
wrapped_function,
106137
result):
107-
attributes = {
108-
code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__,
109-
CODE_MODULE: wrapped_function.__module__,
110-
}
111-
body = {}
112-
if is_content_recording_enabled():
113-
body[FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT] = _to_otel_value(result)
114-
otel_wrapper.log_function_call_end(attributes, body)
138+
"""Records the details about a function result as a log event."""
139+
attributes = {
140+
code_attributes.CODE_FUNCTION_NAME: wrapped_function.__name__,
141+
CODE_MODULE: wrapped_function.__module__,
142+
}
143+
body = {}
144+
if is_content_recording_enabled():
145+
body[FUNCTION_TOOL_CALL_END_EVENT_BODY_RESULT] = _to_otel_value(result)
146+
otel_wrapper.log_function_call_end(attributes, body)
147+
148+
149+
def _record_function_call_result_span_attributes(otel_wrapper, wrapped_function, result):
150+
"""Records the details about a function result as span attributes."""
151+
if not is_content_recording_enabled():
152+
return
153+
span = trace.get_current_span()
154+
span.set_attribute("code.function.return_value", _to_otel_value(result))
155+
156+
157+
def _record_function_call_result(otel_wrapper, wrapped_function, result):
158+
_record_function_call_result_event(otel_wrapper, wrapped_function, result)
159+
_record_function_call_result_span_attributes(otel_wrapper, wrapped_function, result)
115160

116161

117162
def _wrap_sync_tool_function(
@@ -124,9 +169,9 @@ def wrapped_function(*args, **kwargs):
124169
span_name = _create_function_span_name(tool_function)
125170
attributes = _create_function_span_attributes(tool_function, args, kwargs, extra_span_attributes)
126171
with otel_wrapper.start_as_current_span(span_name, attributes=attributes):
127-
_record_function_call_event(otel_wrapper, tool_function, args, kwargs)
172+
_record_function_call_arguments(otel_wrapper, tool_function, args, kwargs)
128173
result = tool_function(*args, **kwargs)
129-
_record_function_call_result_event(otel_wrapper, tool_function, result)
174+
_record_function_call_result(otel_wrapper, tool_function, result)
130175
return result
131176
return wrapped_function
132177

@@ -141,9 +186,9 @@ async def wrapped_function(*args, **kwargs):
141186
span_name = _create_function_span_name(tool_function)
142187
attributes = _create_function_span_attributes(tool_function, args, kwargs, extra_span_attributes)
143188
with otel_wrapper.start_as_current_span(span_name, attributes=attributes):
144-
_record_function_call_event(otel_wrapper, tool_function, args, kwargs)
189+
_record_function_call_arguments(otel_wrapper, tool_function, args, kwargs)
145190
result = await tool_function(*args, **kwargs)
146-
_record_function_call_result_event(otel_wrapper, tool_function, result)
191+
_record_function_call_result(otel_wrapper, tool_function, result)
147192
return result
148193
return wrapped_function
149194

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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 os
16+
import unittest
17+
18+
import google.genai.types as genai_types
19+
20+
from .base import TestCase
21+
22+
class ToolCallInstrumentationTestCase(TestCase):
23+
24+
def test_tool_calls_with_config_dict_outputs_spans(self):
25+
calls = []
26+
def handle(*args, **kwargs):
27+
calls.append((args, kwargs))
28+
return "some result"
29+
def somefunction(somearg):
30+
print("somearg=%s", somearg)
31+
self.mock_generate_content.side_effect = handle
32+
self.client.models.generate_content(
33+
model="some-model-name",
34+
contents="Some content",
35+
config={
36+
"tools": [somefunction],
37+
},
38+
)
39+
self.assertEqual(len(calls), 1)
40+
config = calls[0][1]["config"]
41+
tools = config.tools
42+
wrapped_somefunction = tools[0]
43+
44+
self.assertIsNone(
45+
self.otel.get_span_named("tool_call somefunction"))
46+
wrapped_somefunction(somearg="foo")
47+
self.otel.assert_has_span_named("tool_call somefunction")
48+
generated_span = self.otel.get_span_named("tool_call somefunction")
49+
self.assertEqual(
50+
generated_span.attributes["code.function.name"],
51+
"somefunction")
52+
53+
def test_tool_calls_with_config_object_outputs_spans(self):
54+
calls = []
55+
def handle(*args, **kwargs):
56+
calls.append((args, kwargs))
57+
return "some result"
58+
def somefunction(somearg):
59+
print("somearg=%s", somearg)
60+
self.mock_generate_content.side_effect = handle
61+
self.client.models.generate_content(
62+
model="some-model-name",
63+
contents="Some content",
64+
config=genai_types.GenerateContentConfig(
65+
tools = [somefunction],
66+
)
67+
)
68+
self.assertEqual(len(calls), 1)
69+
config = calls[0][1]["config"]
70+
tools = config.tools
71+
wrapped_somefunction = tools[0]
72+
73+
self.assertIsNone(
74+
self.otel.get_span_named("tool_call somefunction"))
75+
wrapped_somefunction(somearg="foo")
76+
self.otel.assert_has_span_named("tool_call somefunction")
77+
generated_span = self.otel.get_span_named("tool_call somefunction")
78+
self.assertEqual(
79+
generated_span.attributes["code.function.name"],
80+
"somefunction")
81+
82+
def test_tool_calls_record_parameter_values_on_span_if_enabled(self):
83+
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"
84+
calls = []
85+
def handle(*args, **kwargs):
86+
calls.append((args, kwargs))
87+
return "some result"
88+
def somefunction(foo, bar=2):
89+
print("foo=%s, bar=%s", foo, bar)
90+
self.mock_generate_content.side_effect = handle
91+
self.client.models.generate_content(
92+
model="some-model-name",
93+
contents="Some content",
94+
config={
95+
"tools": [somefunction],
96+
},
97+
)
98+
self.assertEqual(len(calls), 1)
99+
config = calls[0][1]["config"]
100+
tools = config.tools
101+
wrapped_somefunction = tools[0]
102+
wrapped_somefunction(123, bar="abc")
103+
self.otel.assert_has_span_named("tool_call somefunction")
104+
generated_span = self.otel.get_span_named("tool_call somefunction")
105+
self.assertEqual(
106+
generated_span.attributes["code.function.params.foo"],
107+
"123")
108+
self.assertEqual(
109+
generated_span.attributes["code.function.params.bar"],
110+
"'abc'")
111+
112+
def test_tool_calls_do_not_record_parameter_values_if_not_enabled(self):
113+
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "false"
114+
calls = []
115+
def handle(*args, **kwargs):
116+
calls.append((args, kwargs))
117+
return "some result"
118+
def somefunction(foo, bar=2):
119+
print("foo=%s, bar=%s", foo, bar)
120+
self.mock_generate_content.side_effect = handle
121+
self.client.models.generate_content(
122+
model="some-model-name",
123+
contents="Some content",
124+
config={
125+
"tools": [somefunction],
126+
},
127+
)
128+
self.assertEqual(len(calls), 1)
129+
config = calls[0][1]["config"]
130+
tools = config.tools
131+
wrapped_somefunction = tools[0]
132+
wrapped_somefunction(123, bar="abc")
133+
self.otel.assert_has_span_named("tool_call somefunction")
134+
generated_span = self.otel.get_span_named("tool_call somefunction")
135+
self.assertNotIn(
136+
"code.function.params.foo", generated_span.attributes)
137+
self.assertNotIn(
138+
"code.function.params.bar", generated_span.attributes)
139+
140+
def test_tool_calls_record_return_values_on_span_if_enabled(self):
141+
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"
142+
calls = []
143+
def handle(*args, **kwargs):
144+
calls.append((args, kwargs))
145+
return "some result"
146+
def somefunction(x, y=2):
147+
return x + y
148+
self.mock_generate_content.side_effect = handle
149+
self.client.models.generate_content(
150+
model="some-model-name",
151+
contents="Some content",
152+
config={
153+
"tools": [somefunction],
154+
},
155+
)
156+
self.assertEqual(len(calls), 1)
157+
config = calls[0][1]["config"]
158+
tools = config.tools
159+
wrapped_somefunction = tools[0]
160+
wrapped_somefunction(123)
161+
self.otel.assert_has_span_named("tool_call somefunction")
162+
generated_span = self.otel.get_span_named("tool_call somefunction")
163+
self.assertEqual(
164+
generated_span.attributes["code.function.return_value"],
165+
"125")
166+
167+
def test_tool_calls_do_not_record_return_values_if_not_enabled(self):
168+
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "false"
169+
calls = []
170+
def handle(*args, **kwargs):
171+
calls.append((args, kwargs))
172+
return "some result"
173+
def somefunction(x, y=2):
174+
return x + y
175+
self.mock_generate_content.side_effect = handle
176+
self.client.models.generate_content(
177+
model="some-model-name",
178+
contents="Some content",
179+
config={
180+
"tools": [somefunction],
181+
},
182+
)
183+
self.assertEqual(len(calls), 1)
184+
config = calls[0][1]["config"]
185+
tools = config.tools
186+
wrapped_somefunction = tools[0]
187+
wrapped_somefunction(123)
188+
self.otel.assert_has_span_named("tool_call somefunction")
189+
generated_span = self.otel.get_span_named("tool_call somefunction")
190+
self.assertNotIn(
191+
"code.function.return_value", generated_span.attributes)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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+

0 commit comments

Comments
 (0)