55capturing inputs and outputs in OpenTelemetry style.
66"""
77
8+ from contextlib import contextmanager
89from enum import Enum
910from functools import wraps
1011import inspect
@@ -23,28 +24,40 @@ class SpanType(str, Enum):
2324 TOOL = "tool"
2425
2526
26- def _prepare_for_otel (value : Any ) -> Any :
27- """
28- Prepare a value for inclusion in OpenTelemetry span attributes.
27+ _SKIP_INPUT_TYPES = (
28+ "Console" , "Progress" , "Live" , "Table" , "Panel" ,
29+ "TracerProvider" , "Tracer" , "Span" ,
30+ )
2931
30- Converts complex types to serializable formats while preserving simple types.
31- """
32- # Simple types can be used directly
32+
33+ def _should_skip_value (value : Any ) -> bool :
34+ type_name = type (value ).__name__
35+ return type_name in _SKIP_INPUT_TYPES
36+
37+
38+ def _prepare_for_otel (value : Any ) -> Any :
3339 if isinstance (value , (str , int , float , bool , type (None ))):
3440 return value
3541
36- # For Pydantic models, convert to dict
42+ if _should_skip_value (value ):
43+ return f"<{ type (value ).__name__ } >"
44+
3745 if hasattr (value , "model_dump" ):
3846 try :
3947 return value .model_dump ()
4048 except Exception :
4149 return str (value )
4250
43- # For dicts, lists, tuples - return as-is (will be serialized later)
4451 if isinstance (value , (dict , list , tuple )):
4552 return value
4653
47- # For other types, convert to string
54+ if isinstance (value , (set , frozenset )):
55+ return list (value )
56+
57+ from pathlib import PurePath
58+ if isinstance (value , PurePath ):
59+ return str (value )
60+
4861 return str (value )
4962
5063
@@ -54,36 +67,11 @@ def observe(span_name: Optional[str] = None, type: SpanType = SpanType.FUNCTION)
5467
5568 Captures function inputs and outputs as span attributes in OTEL style.
5669 Works with both synchronous and asynchronous functions.
57-
58- Args:
59- span_name: Optional name for the span. If not provided, uses the function name.
60- type: The type of span, as a SpanType enum value.
61-
62- Example:
63- ```python
64- @observe(span_name="process_data", type=SpanType.FUNCTION)
65- def process_data(user_id: int, data: dict):
66- return {"result": "success"}
67-
68- @observe() # Uses function name as span name
69- async def async_operation(param: str):
70- return await some_async_call(param)
71- ```
72-
73- The decorator will:
74- - Create a span with the specified name (or function name)
75- - Set the span name and type as attributes
76- - Capture all function arguments as span attributes (skips self/cls for class methods)
77- - Capture the return value as a span attribute
78- - Record exceptions if they occur
79- - Set appropriate span status codes
8070 """
8171
8272 def decorator (func : Callable ) -> Callable :
83- # Determine span name
8473 name = span_name or func .__name__
8574
86- # Check if function is async
8775 is_async = inspect .iscoroutinefunction (func )
8876
8977 if is_async :
@@ -92,56 +80,42 @@ def decorator(func: Callable) -> Callable:
9280 async def async_wrapper (* args , ** kwargs ):
9381 tracer = get_tracer ()
9482
95- # Get function signature for better argument names
9683 sig = inspect .signature (func )
97- bound_args = sig .bind (* args , ** kwargs )
98- bound_args .apply_defaults ()
99-
100- # Check if this is a method (has self or cls as first parameter)
10184 param_names = list (sig .parameters .keys ())
10285 is_method = len (param_names ) > 0 and param_names [0 ] in ("self" , "cls" )
10386 start_idx = 1 if is_method else 0
10487
105- # Start span
106- with tracer .start_as_current_span (name ) as span :
88+ with tracer .start_as_current_span (name ) as otel_span :
10789 try :
108- span .set_attribute ("name" , name )
109- span .set_attribute ("type" , type .value )
90+ otel_span .set_attribute ("name" , name )
91+ otel_span .set_attribute ("type" , type .value )
11092
111- # Capture inputs
11293 inputs = {}
113-
114- # Add positional arguments (skip self/cls if it's a method)
11594 for i , arg in enumerate (args [start_idx :], start = start_idx ):
116- if i < len (param_names ):
117- param_name = param_names [i ]
118- inputs [param_name ] = _prepare_for_otel (arg )
119- else :
120- inputs [f"arg_{ i } " ] = _prepare_for_otel (arg )
95+ if _should_skip_value (arg ):
96+ continue
97+ param_name = param_names [i ] if i < len (param_names ) else f"arg_{ i } "
98+ inputs [param_name ] = _prepare_for_otel (arg )
12199
122- # Add keyword arguments
123100 for key , value in kwargs .items ():
101+ if _should_skip_value (value ):
102+ continue
124103 inputs [key ] = _prepare_for_otel (value )
125104
126- # Set input attributes on span (serialize the entire inputs dict)
127- span .set_attribute ("inputs" , serialize (inputs ))
105+ otel_span .set_attribute ("inputs" , serialize (inputs ))
128106
129- # Execute function
130107 result = await func (* args , ** kwargs )
131108
132- # Capture output
133109 output = _prepare_for_otel (result )
134- span .set_attribute ("outputs" , serialize (output ))
110+ otel_span .set_attribute ("outputs" , serialize (output ))
135111
136- # Set success status
137- span .set_status (Status (StatusCode .OK ))
112+ otel_span .set_status (Status (StatusCode .OK ))
138113
139114 return result
140115
141116 except Exception as e :
142- # Record exception
143- span .record_exception (e )
144- span .set_status (Status (StatusCode .ERROR , str (e )))
117+ otel_span .record_exception (e )
118+ otel_span .set_status (Status (StatusCode .ERROR , str (e )))
145119 raise
146120
147121 return async_wrapper
@@ -151,63 +125,95 @@ async def async_wrapper(*args, **kwargs):
151125 def sync_wrapper (* args , ** kwargs ):
152126 tracer = get_tracer ()
153127
154- # Get function signature for better argument names
155128 sig = inspect .signature (func )
156- bound_args = sig .bind (* args , ** kwargs )
157- bound_args .apply_defaults ()
158-
159- # Check if this is a method (has self or cls as first parameter)
160129 param_names = list (sig .parameters .keys ())
161130 is_method = len (param_names ) > 0 and param_names [0 ] in ("self" , "cls" )
162131 start_idx = 1 if is_method else 0
163132
164- # Start span
165- with tracer .start_as_current_span (name ) as span :
133+ with tracer .start_as_current_span (name ) as otel_span :
166134 try :
167- span .set_attribute ("name" , name )
168- span .set_attribute ("type" , type .value )
135+ otel_span .set_attribute ("name" , name )
136+ otel_span .set_attribute ("type" , type .value )
169137
170- # Capture inputs
171138 inputs = {}
172-
173- # Add positional arguments (skip self/cls if it's a method)
174139 for i , arg in enumerate (args [start_idx :], start = start_idx ):
175- if i < len (param_names ):
176- param_name = param_names [i ]
177- inputs [param_name ] = _prepare_for_otel (arg )
178- else :
179- inputs [f"arg_{ i } " ] = _prepare_for_otel (arg )
140+ if _should_skip_value (arg ):
141+ continue
142+ param_name = param_names [i ] if i < len (param_names ) else f"arg_{ i } "
143+ inputs [param_name ] = _prepare_for_otel (arg )
180144
181- # Add keyword arguments
182145 for key , value in kwargs .items ():
146+ if _should_skip_value (value ):
147+ continue
183148 inputs [key ] = _prepare_for_otel (value )
184149
185- # Set input attributes on span (serialize the entire inputs dict)
186- span .set_attribute ("inputs" , serialize (inputs ))
150+ otel_span .set_attribute ("inputs" , serialize (inputs ))
187151
188- # Execute function
189152 result = func (* args , ** kwargs )
190153
191- # Capture output
192154 output = _prepare_for_otel (result )
193- span .set_attribute ("outputs" , serialize (output ))
155+ otel_span .set_attribute ("outputs" , serialize (output ))
194156
195- # Set success status
196- span .set_status (Status (StatusCode .OK ))
157+ otel_span .set_status (Status (StatusCode .OK ))
197158
198159 return result
199160
200161 except Exception as e :
201- # Record exception
202- span .record_exception (e )
203- span .set_status (Status (StatusCode .ERROR , str (e )))
162+ otel_span .record_exception (e )
163+ otel_span .set_status (Status (StatusCode .ERROR , str (e )))
204164 raise
205165
206166 return sync_wrapper
207167
208168 return decorator
209169
210170
171+ @contextmanager
172+ def span (name : str , span_type : SpanType = SpanType .FUNCTION , attributes : dict [str , Any ] | None = None ):
173+ """Context manager that creates a child span under the current trace.
174+
175+ Use this for explicit span creation in loops or conditional blocks
176+ where a decorator isn't practical.
177+
178+ Example::
179+
180+ for i in range(iterations):
181+ with span("iteration", attributes={"iteration": i, "score": score}):
182+ # ... iteration work ...
183+ set_tag("decision", "keep")
184+ """
185+ tracer = get_tracer ()
186+ with tracer .start_as_current_span (name ) as otel_span :
187+ otel_span .set_attribute ("name" , name )
188+ otel_span .set_attribute ("type" , span_type .value )
189+ if attributes :
190+ for key , value in attributes .items ():
191+ _safe_set_attribute (otel_span , key , value )
192+ try :
193+ yield otel_span
194+ except Exception as e :
195+ otel_span .record_exception (e )
196+ otel_span .set_status (Status (StatusCode .ERROR , str (e )))
197+ raise
198+ else :
199+ otel_span .set_status (Status (StatusCode .OK ))
200+
201+
202+ def _safe_set_attribute (otel_span , key : str , value : Any ) -> None :
203+ """Set a span attribute, coercing the value to an OTel-compatible type."""
204+ if isinstance (value , (str , int , float , bool )):
205+ otel_span .set_attribute (key , value )
206+ elif value is None :
207+ otel_span .set_attribute (key , "" )
208+ elif isinstance (value , (list , tuple )):
209+ if all (isinstance (v , str ) for v in value ):
210+ otel_span .set_attribute (key , list (value ))
211+ else :
212+ otel_span .set_attribute (key , serialize (value ))
213+ else :
214+ otel_span .set_attribute (key , str (value ))
215+
216+
211217def set_workflow_name (workflow_name : str ) -> None :
212218 attach (set_value ("workflow_name" , workflow_name ))
213219
@@ -217,16 +223,11 @@ def set_agent_name(agent_name: str) -> None:
217223
218224
219225def set_conversation_id (conversation_id : str ):
220- """
221- Set the conversation ID for the current context.
222- """
223226 attach (set_value ("conversation_id" , conversation_id ))
224227
225228
226229def conversation (conversation_id : str ):
227- """
228- Decorator that automatically traces a conversation with OpenTelemetry.
229- """
230+ """Decorator that sets a conversation ID in the current context."""
230231
231232 def decorator (fn : Callable ) -> Callable :
232233 if inspect .iscoroutinefunction (fn ):
@@ -250,60 +251,20 @@ def sync_wrapper(*args, **kwargs):
250251
251252
252253def function (name : Optional [str ] = None ):
253- """
254- Decorator that traces a function span.
255-
256- Args:
257- name: Optional span name. Defaults to the decorated function's name.
258-
259- Example:
260- @function(name="process_data")
261- def process_data(user_id: int):
262- ...
263- """
254+ """Decorator that traces a function span."""
264255 return observe (span_name = name , type = SpanType .FUNCTION )
265256
266257
267258def entry_point (name : Optional [str ] = None ):
268- """
269- Decorator that traces an entry point span.
270-
271- Args:
272- name: Optional span name. Defaults to the decorated function's name.
273-
274- Example:
275- @entry_point(name="api_handler")
276- async def handle_request(request):
277- ...
278- """
259+ """Decorator that traces an entry point span."""
279260 return observe (span_name = name , type = SpanType .ENTRY_POINT )
280261
281262
282263def workflow (name : Optional [str ] = None ):
283- """
284- Decorator that traces a workflow span.
285-
286- Args:
287- name: Optional span name. Defaults to the decorated function's name.
288-
289- Example:
290- @workflow(name="onboarding_flow")
291- def run_onboarding(user_id: str):
292- ...
293- """
264+ """Decorator that traces a workflow span."""
294265 return observe (span_name = name , type = SpanType .WORKFLOW )
295266
296267
297268def tool (name : Optional [str ] = None ):
298- """
299- Decorator that traces a tool span.
300-
301- Args:
302- name: Optional span name. Defaults to the decorated function's name.
303-
304- Example:
305- @tool(name="web_search")
306- def search(query: str):
307- ...
308- """
269+ """Decorator that traces a tool span."""
309270 return observe (span_name = name , type = SpanType .TOOL )
0 commit comments