Skip to content

Commit 0c3f633

Browse files
Open Telemetry for A2A-Python-SDK (#6)
1 parent 5dd2ed1 commit 0c3f633

File tree

3 files changed

+835
-546
lines changed

3 files changed

+835
-546
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ dependencies = [
88
"httpx>=0.28.1",
99
"httpx-sse>=0.4.0",
1010
"mypy>=1.15.0",
11+
"opentelemetry-api>=1.33.0",
12+
"opentelemetry-sdk>=1.33.0",
1113
"pydantic>=2.11.3",
1214
"sse-starlette>=2.3.3",
1315
"starlette>=0.46.2",

src/a2a/utils/telemetry.py

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
# type: ignore
2+
"""OpenTelemetry Tracing Utilities for A2A Python SDK.
3+
4+
This module provides decorators to simplify the integration of OpenTelemetry
5+
tracing into Python applications. It offers `trace_function` for instrumenting
6+
individual functions (both synchronous and asynchronous) and `trace_class`
7+
for instrumenting multiple methods within a class.
8+
9+
The tracer is initialized with the module name and version defined by
10+
`INSTRUMENTING_MODULE_NAME` ('a2a-python-sdk') and
11+
`INSTRUMENTING_MODULE_VERSION` ('1.0.0').
12+
13+
Features:
14+
- Automatic span creation for decorated functions/methods.
15+
- Support for both synchronous and asynchronous functions.
16+
- Default span naming based on module and function/class/method name.
17+
- Customizable span names, kinds, and static attributes.
18+
- Dynamic attribute setting via an `attribute_extractor` callback.
19+
- Automatic recording of exceptions and setting of span status.
20+
- Selective method tracing in classes using include/exclude lists.
21+
22+
Usage:
23+
For a single function:
24+
```python
25+
from your_module import trace_function
26+
27+
28+
@trace_function
29+
def my_function():
30+
# ...
31+
pass
32+
33+
34+
@trace_function(span_name='custom.op', kind=SpanKind.CLIENT)
35+
async def my_async_function():
36+
# ...
37+
pass
38+
```
39+
40+
For a class:
41+
```python
42+
from your_module import trace_class
43+
44+
45+
@trace_class(exclude_list=['internal_method'])
46+
class MyService:
47+
def public_api(self, user_id):
48+
# This method will be traced
49+
pass
50+
51+
def internal_method(self):
52+
# This method will not be traced
53+
pass
54+
```
55+
"""
56+
57+
import functools
58+
import inspect
59+
import logging
60+
61+
from opentelemetry import trace
62+
from opentelemetry.trace import SpanKind, StatusCode
63+
64+
65+
INSTRUMENTING_MODULE_NAME = 'a2a-python-sdk'
66+
INSTRUMENTING_MODULE_VERSION = '1.0.0'
67+
68+
logger = logging.getLogger(__name__)
69+
70+
71+
def trace_function(
72+
func=None,
73+
*,
74+
span_name=None,
75+
kind=SpanKind.INTERNAL,
76+
attributes=None,
77+
attribute_extractor=None,
78+
):
79+
"""A decorator to automatically trace a function call with OpenTelemetry.
80+
81+
This decorator can be used to wrap both sync and async functions.
82+
When applied, it creates a new span for each call to the decorated function.
83+
The span will record the execution time, status (OK or ERROR), and any
84+
exceptions that occur.
85+
86+
It can be used in two ways:
87+
1. As a direct decorator: `@trace_function`
88+
2. As a decorator factory to provide arguments:
89+
`@trace_function(span_name="custom.name")`
90+
91+
Args:
92+
func (callable, optional): The function to be decorated. If None,
93+
the decorator returns a partial function, allowing it to be called
94+
with arguments. Defaults to None.
95+
span_name (str, optional): Custom name for the span. If None,
96+
it defaults to ``f'{func.__module__}.{func.__name__}'``.
97+
Defaults to None.
98+
kind (SpanKind, optional): The ``opentelemetry.trace.SpanKind`` for the
99+
created span. Defaults to ``SpanKind.INTERNAL``.
100+
attributes (dict, optional): A dictionary of static attributes to be
101+
set on the span. Keys are attribute names (str) and values are
102+
the corresponding attribute values. Defaults to None.
103+
attribute_extractor (callable, optional): A function that can be used
104+
to dynamically extract and set attributes on the span.
105+
It is called within a ``finally`` block, ensuring it runs even if
106+
the decorated function raises an exception.
107+
The function signature should be:
108+
``attribute_extractor(span, args, kwargs, result, exception)``
109+
where:
110+
- ``span`` : the OpenTelemetry ``Span`` object.
111+
- ``args`` : a tuple of positional arguments passed
112+
- ``kwargs`` : a dictionary of keyword arguments passed
113+
- ``result`` : return value (None if an exception occurred)
114+
- ``exception`` : exception object if raised (None otherwise).
115+
Any exception raised by the ``attribute_extractor`` itself will be
116+
caught and logged. Defaults to None.
117+
118+
Returns:
119+
callable: The wrapped function that includes tracing, or a partial
120+
decorator if ``func`` is None.
121+
"""
122+
if func is None:
123+
return functools.partial(
124+
trace_function,
125+
span_name=span_name,
126+
kind=kind,
127+
attributes=attributes,
128+
attribute_extractor=attribute_extractor,
129+
)
130+
131+
actual_span_name = span_name or f'{func.__module__}.{func.__name__}'
132+
133+
is_async_func = inspect.iscoroutinefunction(func)
134+
135+
logger.debug(
136+
f'Start tracing for {actual_span_name}, is_async_func {is_async_func}'
137+
)
138+
139+
@functools.wraps(func)
140+
async def async_wrapper(*args, **kwargs) -> any:
141+
"""Async Wrapper for the decorator."""
142+
logger.debug('Start async tracer')
143+
tracer = trace.get_tracer(
144+
INSTRUMENTING_MODULE_NAME, INSTRUMENTING_MODULE_VERSION
145+
)
146+
with tracer.start_as_current_span(actual_span_name, kind=kind) as span:
147+
if attributes:
148+
for k, v in attributes.items():
149+
span.set_attribute(k, v)
150+
151+
result = None
152+
exception = None
153+
154+
try:
155+
# Async wrapper, await for the function call to complete.
156+
result = await func(*args, **kwargs)
157+
span.set_status(StatusCode.OK)
158+
return result
159+
160+
except Exception as e:
161+
exception = e
162+
span.record_exception(e)
163+
span.set_status(StatusCode.ERROR, description=str(e))
164+
raise
165+
finally:
166+
if attribute_extractor:
167+
try:
168+
attribute_extractor(
169+
span, args, kwargs, result, exception
170+
)
171+
except Exception as attr_e:
172+
logger.error(
173+
f'attribute_extractor error in span {actual_span_name}: {attr_e}'
174+
)
175+
176+
@functools.wraps(func)
177+
def sync_wrapper(*args, **kwargs):
178+
"""Sync Wrapper for the decorator."""
179+
tracer = trace.get_tracer(INSTRUMENTING_MODULE_NAME)
180+
with tracer.start_as_current_span(actual_span_name, kind=kind) as span:
181+
if attributes:
182+
for k, v in attributes.items():
183+
span.set_attribute(k, v)
184+
185+
result = None
186+
exception = None
187+
188+
try:
189+
# Sync wrapper, execute the function call.
190+
result = func(*args, **kwargs)
191+
span.set_status(StatusCode.OK)
192+
return result
193+
194+
except Exception as e:
195+
exception = e
196+
span.record_exception(e)
197+
span.set_status(StatusCode.ERROR, description=str(e))
198+
raise
199+
finally:
200+
if attribute_extractor:
201+
try:
202+
attribute_extractor(
203+
span, args, kwargs, result, exception
204+
)
205+
except Exception as attr_e:
206+
logger.error(
207+
f'attribute_extractor error in span {actual_span_name}: {attr_e}'
208+
)
209+
210+
return async_wrapper if is_async_func else sync_wrapper
211+
212+
213+
def trace_class(include_list: list[str] = None, exclude_list: list[str] = None):
214+
"""A class decorator to automatically trace specified methods of a class.
215+
216+
This decorator iterates over the methods of a class and applies the
217+
`trace_function` decorator to them, based on the `include_list` and
218+
`exclude_list` criteria. Dunder methods (e.g., `__init__`, `__call__`)
219+
are always excluded.
220+
221+
Args:
222+
include_list (list[str], optional): A list of method names to
223+
explicitly include for tracing. If provided, only methods in this
224+
list (that are not dunder methods) will be traced.
225+
Defaults to None.
226+
exclude_list (list[str], optional): A list of method names to exclude
227+
from tracing. This is only considered if `include_list` is not
228+
provided. Dunder methods are implicitly excluded.
229+
Defaults to an empty list.
230+
231+
Returns:
232+
callable: A decorator function that, when applied to a class,
233+
modifies the class to wrap its specified methods with tracing.
234+
235+
Example:
236+
To trace all methods except 'internal_method':
237+
```python
238+
@trace_class(exclude_list=['internal_method'])
239+
class MyService:
240+
def public_api(self):
241+
pass
242+
243+
def internal_method(self):
244+
pass
245+
```
246+
247+
To trace only 'method_one' and 'method_two':
248+
```python
249+
@trace_class(include_list=['method_one', 'method_two'])
250+
class AnotherService:
251+
def method_one(self):
252+
pass
253+
254+
def method_two(self):
255+
pass
256+
257+
def not_traced_method(self):
258+
pass
259+
```
260+
"""
261+
logger.debug(f'Trace all class {include_list}, {exclude_list}')
262+
exclude_list = exclude_list or []
263+
264+
def decorator(cls):
265+
all_methods = {}
266+
for name, method in inspect.getmembers(cls, inspect.isfunction):
267+
# Skip Dunders
268+
if name.startswith('__') and name.endswith('__'):
269+
continue
270+
271+
# Skip if include list is defined but the method not included.
272+
if include_list and name not in include_list:
273+
continue
274+
# Skip if include list is not defined but the method is in excludes.
275+
if not include_list and name in exclude_list:
276+
continue
277+
278+
all_methods[name] = method
279+
span_name = f'{cls.__module__}.{cls.__name__}.{name}'
280+
# Set the decorator on the method.
281+
setattr(cls, name, trace_function(span_name=span_name)(method))
282+
return cls
283+
284+
return decorator

0 commit comments

Comments
 (0)