Skip to content

Commit 2fc0ad1

Browse files
committed
Update recording mechanism to record more request options.
1 parent cd5a36a commit 2fc0ad1

File tree

4 files changed

+134
-73
lines changed

4 files changed

+134
-73
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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+
16+
# Prefix to use for LLM model request attributes that are unique GCP
17+
# (or that have not yet been formally defined in the GenAI/LLM SIG).
18+
CUSTOM_LLM_REQUEST_PREFIX = "gen_ai.gcp.request"

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

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,18 @@
1313
# limitations under the License.
1414

1515

16-
from typing import Dict, Optional, Set, Any, Callable, TypeAlias, Union, List
16+
from typing import Dict, Optional, Set, Any, Callable, Union, List, Sequence
1717

1818
import json
1919

20-
Primitive: TypeAlias = Union[bool, str, int, float]
21-
22-
BoolList: TypeAlias = list[bool]
23-
StringList: TypeAlias = list[str]
24-
IntList: TypeAlias = list[int]
25-
FloatList: TypeAlias = list[float]
26-
HomogenousPrimitiveList: TypeAlias = Union[BoolList, StringList, IntList, FloatList]
27-
28-
FlattenedValue: TypeAlias = Union[Primitive, HomogenousPrimitiveList]
29-
FlattenedDict: TypeAlias = Dict[str, FlattenedValue]
20+
Primitive = Union[bool, str, int, float]
21+
BoolList = list[bool]
22+
StringList = list[str]
23+
IntList = list[int]
24+
FloatList = list[float]
25+
HomogenousPrimitiveList = Union[BoolList, StringList, IntList, FloatList]
26+
FlattenedValue = Union[Primitive, HomogenousPrimitiveList]
27+
FlattenedDict = Dict[str, FlattenedValue]
3028

3129

3230
def _concat_key(prefix: Optional[str], suffix: str):
@@ -84,7 +82,9 @@ def _flatten_value(
8482
return {key: value}
8583
flatten_func = _get_flatten_func(flatten_functions, key_names)
8684
if flatten_func is not None:
87-
return flatten_func(key, value, exclude_keys=exclude_keys, rename_keys=rename_keys, flatten_functions=flatten_functions)
85+
func_output = flatten_func(key, value, exclude_keys=exclude_keys, rename_keys=rename_keys, flatten_functions=flatten_functions)
86+
if func_output is not None:
87+
return {key: func_output}
8888
if isinstance(value, dict):
8989
return _flatten_dict(value, key_prefix=key, exclude_keys=exclude_keys, rename_keys=rename_keys, flatten_functions=flatten_functions)
9090
if isinstance(value, list):
@@ -108,6 +108,8 @@ def _flatten_dict(
108108
flatten_functions: Dict[str, Callable]) -> FlattenedDict:
109109
result = {}
110110
for key, value in d.items():
111+
if key in exclude_keys:
112+
continue
111113
full_key = _concat_key(key_prefix, key)
112114
flattened = _flatten_value(full_key, value, exclude_keys=exclude_keys, rename_keys=rename_keys, flatten_functions=flatten_functions)
113115
result.update(flattened)
@@ -134,7 +136,7 @@ def _flatten_list(
134136
def flatten_dict(
135137
d: Dict[str, Any],
136138
key_prefix: Optional[str] = None,
137-
exclude_keys: Optional[Union[List[str]|Set[str]]] = None,
139+
exclude_keys: Optional[Sequence[str]] = None,
138140
rename_keys: Optional[Dict[str, str]] = None,
139141
flatten_functions: Optional[Dict[str, Callable]] = None):
140142
key_prefix = key_prefix or ""
@@ -143,4 +145,5 @@ def flatten_dict(
143145
elif isinstance(exclude_keys, list):
144146
exclude_keys = set(exclude_keys)
145147
rename_keys = rename_keys or {}
148+
flatten_functions = flatten_functions or {}
146149
return _flatten_dict(d, key_prefix=key_prefix, exclude_keys=exclude_keys, rename_keys=rename_keys, flatten_functions=flatten_functions)

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

Lines changed: 47 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141

4242
from .flags import is_content_recording_enabled
4343
from .otel_wrapper import OTelWrapper
44+
from .dict_util import flatten_dict
45+
from .custom_semconv import CUSTOM_LLM_REQUEST_PREFIX
4446

4547
_logger = logging.getLogger(__name__)
4648

@@ -129,21 +131,45 @@ def _determine_genai_system(models_object: Union[Models, AsyncModels]):
129131
return _get_gemini_system_name()
130132

131133

132-
def _get_config_property(
133-
config: Optional[GenerateContentConfigOrDict], path: str
134-
) -> Any:
134+
def _to_dict(value: object):
135+
if isinstance(value, dict):
136+
return value
137+
if hasattr(value, "model_dump"):
138+
return value.model_dump()
139+
return json.loads(json.dumps(value))
140+
141+
142+
def _add_request_options_to_span(span, config: Optional[GenerateContentConfigOrDict]):
135143
if config is None:
136-
return None
137-
path_segments = path.split(".")
138-
current_context: Any = config
139-
for path_segment in path_segments:
140-
if current_context is None:
141-
return None
142-
if isinstance(current_context, dict):
143-
current_context = current_context.get(path_segment)
144-
else:
145-
current_context = getattr(current_context, path_segment)
146-
return current_context
144+
return
145+
span_context = span.get_span_context()
146+
if not span_context.trace_flags.sampled:
147+
return
148+
attributes = flatten_dict(
149+
_to_dict(config),
150+
key_prefix=CUSTOM_LLM_REQUEST_PREFIX,
151+
exclude_keys=[
152+
# System instruction can be overly long for a span attribute.
153+
# Additionally, it is recorded as an event (log), instead.
154+
"gen_ai.gcp.request.system_instruction",
155+
# Headers could include sensitive information, therefore it is
156+
# best that we not record these options.
157+
"gen_ai.gcp.request.http_options.headers",
158+
],
159+
rename_keys={
160+
# TODO: add more entries here as more semantic conventions are
161+
# generalized to cover more of the available config options.
162+
"gen_ai.gcp.request.temperature": "gen_ai.request.temperature",
163+
"gen_ai.gcp.request.top_k": "gen_ai.request.top_k",
164+
"gen_ai.gcp.request.top_p": "gen_ai.request.top_p",
165+
"gen_ai.gcp.request.candidate_count": "gen_ai.request.choice.count",
166+
"gen_ai.gcp.request.max_output_tokens": "gen_ai.request.max_tokens",
167+
"gen_ai.gcp.request.stop_sequences": "gen_ai.request.stop_sequences",
168+
"gen_ai.gcp.request.presence_penalty": "gen_ai.request.presence_penalty",
169+
"gen_ai.gcp.request.seed": "gen_ai.request.seed",
170+
}
171+
)
172+
span.set_attributes(attributes)
147173

148174

149175
def _get_response_property(response: GenerateContentResponse, path: str):
@@ -159,44 +185,6 @@ def _get_response_property(response: GenerateContentResponse, path: str):
159185
return current_context
160186

161187

162-
def _get_temperature(config: Optional[GenerateContentConfigOrDict]):
163-
return _get_config_property(config, "temperature")
164-
165-
166-
def _get_top_k(config: Optional[GenerateContentConfigOrDict]):
167-
return _get_config_property(config, "top_k")
168-
169-
170-
def _get_top_p(config: Optional[GenerateContentConfigOrDict]):
171-
return _get_config_property(config, "top_p")
172-
173-
174-
# A map from define attributes to the function that can obtain
175-
# the relevant information from the request object.
176-
#
177-
# TODO: expand this to cover a larger set of the available
178-
# span attributes from GenAI semantic conventions.
179-
#
180-
# TODO: define semantic conventions for attributes that
181-
# are relevant for the Google GenAI SDK which are not
182-
# currently covered by the existing semantic conventions.
183-
#
184-
# See also: TODOS.md
185-
_SPAN_ATTRIBUTE_TO_CONFIG_EXTRACTOR = {
186-
gen_ai_attributes.GEN_AI_REQUEST_TEMPERATURE: _get_temperature,
187-
gen_ai_attributes.GEN_AI_REQUEST_TOP_K: _get_top_k,
188-
gen_ai_attributes.GEN_AI_REQUEST_TOP_P: _get_top_p,
189-
}
190-
191-
192-
def _to_dict(value: object):
193-
if isinstance(value, dict):
194-
return value
195-
if hasattr(value, "model_dump"):
196-
return value.model_dump()
197-
return json.loads(json.dumps(value))
198-
199-
200188
class _GenerateContentInstrumentationHelper:
201189
def __init__(
202190
self,
@@ -237,13 +225,7 @@ def process_request(
237225
config: Optional[GenerateContentConfigOrDict],
238226
):
239227
span = trace.get_current_span()
240-
for (
241-
attribute_key,
242-
extractor,
243-
) in _SPAN_ATTRIBUTE_TO_CONFIG_EXTRACTOR.items():
244-
attribute_value = extractor(config)
245-
if attribute_value is not None:
246-
span.set_attribute(attribute_key, attribute_value)
228+
_add_request_options_to_span(span, config)
247229
self._maybe_log_system_instruction(config=config)
248230
self._maybe_log_user_prompt(contents)
249231

@@ -317,7 +299,12 @@ def _maybe_update_error_type(self, response: GenerateContentResponse):
317299
def _maybe_log_system_instruction(
318300
self, config: Optional[GenerateContentConfigOrDict] = None
319301
):
320-
system_instruction = _get_config_property(config, "system_instruction")
302+
system_instruction = None
303+
if config is not None:
304+
if isinstance(config, dict):
305+
system_instruction = config["system_instruction"]
306+
else:
307+
system_instruction = config.system_instruction
321308
if not system_instruction:
322309
return
323310
attributes = {

instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_dict_util.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def test_flatten_empty_dict():
2020
d = {}
2121
assert dict_util.flatten_dict(d) == d
2222

23+
2324
def test_flatten_simple_dict():
2425
d = {
2526
"int_key": 1,
@@ -30,6 +31,31 @@ def test_flatten_simple_dict():
3031
assert dict_util.flatten_dict(d) == d
3132

3233

34+
def test_flatten_nested_dict():
35+
d = {
36+
"int_key": 1,
37+
"string_key": "somevalue",
38+
"float_key": 3.14,
39+
"bool_key": True,
40+
"object_key": {
41+
"nested": {
42+
"foo": 1,
43+
"bar": "baz",
44+
},
45+
"qux": 54321
46+
}
47+
}
48+
assert dict_util.flatten_dict(d) == {
49+
"int_key": 1,
50+
"string_key": "somevalue",
51+
"float_key": 3.14,
52+
"bool_key": True,
53+
"object_key.nested.foo": 1,
54+
"object_key.nested.bar": "baz",
55+
"object_key.qux": 54321,
56+
}
57+
58+
3359
def test_flatten_with_key_exclusion():
3460
d = {
3561
"int_key": 1,
@@ -80,3 +106,30 @@ def test_flatten_with_prefixing():
80106
"someprefix.float_key": 3.14,
81107
"someprefix.bool_key": True
82108
}
109+
110+
111+
def test_flatten_with_custom_flatten_func():
112+
def summarize_int_list(key, value, **kwargs):
113+
total = 0
114+
for item in value:
115+
total += item
116+
avg = total / len(value)
117+
return f"{len(value)} items (total: {total}, average: {avg})"
118+
flatten_functions = {
119+
"some.deeply.nested.key": summarize_int_list
120+
}
121+
d = {
122+
"some": {
123+
"deeply": {
124+
"nested": {
125+
"key": [1, 2, 3, 4, 5, 6, 7, 8, 9],
126+
},
127+
},
128+
},
129+
"other": [1, 2, 3, 4, 5, 6, 7, 8, 9]
130+
}
131+
output = dict_util.flatten_dict(d, flatten_functions=flatten_functions)
132+
assert output == {
133+
"some.deeply.nested.key": "9 items (total: 45, average: 5.0)",
134+
"other": [1, 2, 3, 4, 5, 6, 7, 8, 9],
135+
}

0 commit comments

Comments
 (0)