Skip to content

Commit 99ef6bc

Browse files
[google-genai] Standardize tool-call argument serialization (open-telemetry#791)
* Standardize Google GenAI tool argument serialization Pass call-time bound argument dictionaries to the shared GenAI serializer for sync and async tools, preserving content gating and tool behavior. Assisted-by: GPT-6 Astra Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Remove tool argument README additions Assisted-by: GPT-6 Astra Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix changelog and generated table checks for PR 791 Assisted-by: GPT-6 Astra Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Resolve recursive OpenTelemetry aliases in Sphinx docs Assisted-by: GPT-6 Astra Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Preserve object normalization in Google GenAI tool arguments Restore recursive model and object normalization before shared serialization while preserving call-time snapshots and byte encoding. Assisted-by: GPT-6 Astra Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address Google GenAI tool argument review feedback Remove the redundant Sphinx workaround and assert the exact tool argument JSON format for sync and async calls. Assisted-by: GPT-6 Astra Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent e1a1df0 commit 99ef6bc

3 files changed

Lines changed: 375 additions & 99 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Record tool-call arguments as bound parameter dictionaries serialized by the shared GenAI utility instead of custom code.function.parameters entries, preserving normalization of Pydantic models and other objects.

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

Lines changed: 49 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
import functools
55
import inspect
66
import json
7+
import logging
78
from collections.abc import Callable
9+
from copy import deepcopy
10+
from dataclasses import asdict, is_dataclass
811
from typing import Any
912

1013
from google.genai.types import (
@@ -14,9 +17,11 @@
1417
)
1518

1619
from opentelemetry.util.genai.handler import TelemetryHandler
17-
from opentelemetry.util.genai.utils import bind_arguments, get_signature
20+
from opentelemetry.util.genai.utils import bind_arguments
21+
from opentelemetry.util.types import AnyValue
1822

1923
ToolFunction = Callable[..., Any]
24+
_logger = logging.getLogger(__name__)
2025

2126

2227
def _is_primitive(value):
@@ -40,94 +45,83 @@ def _to_otel_value(python_value):
4045
return repr(python_value)
4146

4247

43-
# There is no canonical way to serialize a Python object to a span attribute value.
44-
# Span attribute values currently must be one of the primitive types, or a homogeneous list of primitive types.
45-
# In the future the value will be expanded to include None, heterogeneous lists of primitive types, and a Map of these types.
46-
# See https://github.com/open-telemetry/opentelemetry-specification/pull/4485
47-
def _get_function_args(wrapped_function, function_args, function_kwargs):
48-
"""Records the details about a function invocation as span attributes."""
49-
function_arg_attr = {}
48+
def _snapshot_tool_arguments(
49+
tool_function: ToolFunction,
50+
args: tuple[object, ...],
51+
kwargs: dict[str, object],
52+
) -> dict[str, AnyValue] | None:
53+
# ToolInvocation serializes at span end, after the tool may mutate its inputs.
5054
try:
51-
signature = get_signature(wrapped_function)
52-
parameters = list(signature.parameters.values())
53-
has_variadics = any(
54-
parameter.kind
55-
in (
56-
inspect.Parameter.VAR_POSITIONAL,
57-
inspect.Parameter.VAR_KEYWORD,
58-
)
59-
for parameter in parameters
55+
bound = deepcopy(
56+
bind_arguments(tool_function, args, kwargs, apply_defaults=False)
6057
)
61-
except (TypeError, ValueError):
62-
has_variadics = False
63-
parameters = []
64-
65-
if has_variadics:
66-
bound = {
67-
(
68-
parameters[index].name
69-
if index < len(parameters)
70-
else f"args[{index}]"
71-
): value
72-
for index, value in enumerate(function_args)
58+
return {
59+
name: _normalize_tool_argument(value)
60+
for name, value in bound.items()
7361
}
74-
bound.update(function_kwargs)
75-
else:
76-
bound = bind_arguments(
77-
wrapped_function,
78-
function_args,
79-
function_kwargs,
80-
apply_defaults=False,
81-
)
62+
except Exception:
63+
_logger.warning("Failed to snapshot tool arguments", exc_info=True)
64+
return None
8265

83-
for key, value in bound.items():
84-
function_arg_attr[f"code.function.parameters.{key}.type"] = type(
85-
value
86-
).__name__
87-
function_arg_attr[f"code.function.parameters.{key}.value"] = (
88-
_to_otel_value(value)
89-
)
90-
return function_arg_attr
66+
67+
def _normalize_tool_argument(value: object) -> AnyValue:
68+
if value is None or isinstance(value, (str, int, bool, float, bytes)):
69+
return value
70+
if isinstance(value, (list, tuple)):
71+
return [_normalize_tool_argument(item) for item in value]
72+
if isinstance(value, dict):
73+
return {
74+
key: _normalize_tool_argument(item) for key, item in value.items()
75+
}
76+
model_dump = getattr(value, "model_dump", None)
77+
if callable(model_dump):
78+
return _normalize_tool_argument(model_dump())
79+
if is_dataclass(value) and not isinstance(value, type):
80+
return _normalize_tool_argument(asdict(value))
81+
if hasattr(value, "__dict__"):
82+
return _normalize_tool_argument(value.__dict__)
83+
return _to_otel_value(value)
9184

9285

9386
def _wrap_tool_function(
9487
tool_function: ToolFunction,
9588
telemetry_handler: TelemetryHandler,
96-
):
89+
) -> ToolFunction:
9790
if inspect.iscoroutinefunction(tool_function):
9891

9992
@functools.wraps(tool_function)
100-
async def wrapped_function(*args, **kwargs):
101-
# Always json.dumps. First we convert args / result to something that we can serialize, then we serialize.
102-
# The return value of _to_otel_value could be a dict, which currently cannot be a span attribute..
103-
# In the future that could change (see https://github.com/open-telemetry/opentelemetry-specification/pull/4485), and we could possibly stop using json.dumps here.
93+
async def async_wrapped_function(
94+
*args: object, **kwargs: object
95+
) -> Any:
10496
with telemetry_handler.tool(
10597
tool_function.__name__,
10698
) as tool_invocation:
10799
tool_invocation.tool_description = tool_function.__doc__
108100
# Do this before calling the tool in case that crashes.
109101
if tool_invocation.should_capture_content:
110-
tool_invocation.arguments = json.dumps(
111-
_get_function_args(tool_function, args, kwargs)
102+
tool_invocation.arguments = _snapshot_tool_arguments(
103+
tool_function, args, kwargs
112104
)
113105
result = await tool_function(*args, **kwargs)
114106
if tool_invocation.should_capture_content:
115107
tool_invocation.tool_result = json.dumps(
116108
_to_otel_value(result)
117109
)
118110
return result
111+
112+
return async_wrapped_function
119113
else:
120114

121115
@functools.wraps(tool_function)
122-
def wrapped_function(*args, **kwargs):
116+
def wrapped_function(*args: object, **kwargs: object) -> Any:
123117
with telemetry_handler.tool(
124118
tool_function.__name__,
125119
) as tool_invocation:
126120
tool_invocation.tool_description = tool_function.__doc__
127121
# Do this before calling the tool in case that crashes.
128122
if tool_invocation.should_capture_content:
129-
tool_invocation.arguments = json.dumps(
130-
_get_function_args(tool_function, args, kwargs)
123+
tool_invocation.arguments = _snapshot_tool_arguments(
124+
tool_function, args, kwargs
131125
)
132126
result = tool_function(*args, **kwargs)
133127
if tool_invocation.should_capture_content:

0 commit comments

Comments
 (0)