44import functools
55import inspect
66import json
7+ import logging
78from collections .abc import Callable
9+ from copy import deepcopy
10+ from dataclasses import asdict , is_dataclass
811from typing import Any
912
1013from google .genai .types import (
1417)
1518
1619from 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
1923ToolFunction = Callable [..., Any ]
24+ _logger = logging .getLogger (__name__ )
2025
2126
2227def _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
9386def _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