Skip to content

Commit 771d134

Browse files
authored
Merge branch 'main' into anshul-fix-1781-alt
2 parents f5061ff + 39bd7fa commit 771d134

File tree

5 files changed

+151
-8
lines changed

5 files changed

+151
-8
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
([#2082](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2082))
2222
- `opentelemetry-instrumentation-redis` Add additional attributes for methods create_index and search, rename those spans
2323
([#2635](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2635))
24+
- `opentelemetry-instrumentation` Add support for string based dotted module paths in unwrap
25+
([#2919](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2919))
2426

2527
### Fixed
2628

@@ -80,6 +82,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8082
([#2753](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2753))
8183
- `opentelemetry-instrumentation-grpc` Fix grpc supported version
8284
([#2845](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2845))
85+
- `opentelemetry-instrumentation-asyncio` fix `AttributeError` in
86+
`AsyncioInstrumentor.trace_to_thread` when `func` is a `functools.partial` instance
87+
([#2911](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2911))
8388

8489
## Version 1.26.0/0.47b0 (2024-07-23)
8590

instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/__init__.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def func():
7878
"""
7979

8080
import asyncio
81+
import functools
8182
import sys
8283
from asyncio import futures
8384
from timeit import default_timer
@@ -231,14 +232,15 @@ def wrap_taskgroup_create_task(method, instance, args, kwargs) -> None:
231232
def trace_to_thread(self, func: callable):
232233
"""Trace a function."""
233234
start = default_timer()
235+
func_name = getattr(func, "__name__", None)
236+
if func_name is None and isinstance(func, functools.partial):
237+
func_name = func.func.__name__
234238
span = (
235-
self._tracer.start_span(
236-
f"{ASYNCIO_PREFIX} to_thread-" + func.__name__
237-
)
238-
if func.__name__ in self._to_thread_name_to_trace
239+
self._tracer.start_span(f"{ASYNCIO_PREFIX} to_thread-" + func_name)
240+
if func_name in self._to_thread_name_to_trace
239241
else None
240242
)
241-
attr = {"type": "to_thread", "name": func.__name__}
243+
attr = {"type": "to_thread", "name": func_name}
242244
exception = None
243245
try:
244246
attr["state"] = "finished"

instrumentation/opentelemetry-instrumentation-asyncio/tests/test_asyncio_to_thread.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import asyncio
15+
import functools
1516
import sys
1617
from unittest import skipIf
1718
from unittest.mock import patch
@@ -72,3 +73,37 @@ async def to_thread():
7273
for point in metric.data.data_points:
7374
self.assertEqual(point.attributes["type"], "to_thread")
7475
self.assertEqual(point.attributes["name"], "multiply")
76+
77+
@skipIf(
78+
sys.version_info < (3, 9), "to_thread is only available in Python 3.9+"
79+
)
80+
def test_to_thread_partial_func(self):
81+
def multiply(x, y):
82+
return x * y
83+
84+
double = functools.partial(multiply, 2)
85+
86+
async def to_thread():
87+
result = await asyncio.to_thread(double, 3)
88+
assert result == 6
89+
90+
with self._tracer.start_as_current_span("root"):
91+
asyncio.run(to_thread())
92+
spans = self.memory_exporter.get_finished_spans()
93+
94+
self.assertEqual(len(spans), 2)
95+
assert spans[0].name == "asyncio to_thread-multiply"
96+
for metric in (
97+
self.memory_metrics_reader.get_metrics_data()
98+
.resource_metrics[0]
99+
.scope_metrics[0]
100+
.metrics
101+
):
102+
if metric.name == "asyncio.process.duration":
103+
for point in metric.data.data_points:
104+
self.assertEqual(point.attributes["type"], "to_thread")
105+
self.assertEqual(point.attributes["name"], "multiply")
106+
if metric.name == "asyncio.process.created":
107+
for point in metric.data.data_points:
108+
self.assertEqual(point.attributes["type"], "to_thread")
109+
self.assertEqual(point.attributes["name"], "multiply")

opentelemetry-instrumentation/src/opentelemetry/instrumentation/utils.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414

1515
import urllib.parse
1616
from contextlib import contextmanager
17+
from importlib import import_module
1718
from re import escape, sub
18-
from typing import Dict, Iterable, Sequence
19+
from typing import Dict, Iterable, Sequence, Union
1920

2021
from wrapt import ObjectProxy
2122

@@ -80,13 +81,30 @@ def http_status_to_status_code(
8081
return StatusCode.ERROR
8182

8283

83-
def unwrap(obj, attr: str):
84+
def unwrap(obj: Union[object, str], attr: str):
8485
"""Given a function that was wrapped by wrapt.wrap_function_wrapper, unwrap it
8586
87+
The object containing the function to unwrap may be passed as dotted module path string.
88+
8689
Args:
87-
obj: Object that holds a reference to the wrapped function
90+
obj: Object that holds a reference to the wrapped function or dotted import path as string
8891
attr (str): Name of the wrapped function
8992
"""
93+
if isinstance(obj, str):
94+
try:
95+
module_path, class_name = obj.rsplit(".", 1)
96+
except ValueError as exc:
97+
raise ImportError(
98+
f"Cannot parse '{obj}' as dotted import path"
99+
) from exc
100+
module = import_module(module_path)
101+
try:
102+
obj = getattr(module, class_name)
103+
except AttributeError as exc:
104+
raise ImportError(
105+
f"Cannot import '{class_name}' from '{module}'"
106+
) from exc
107+
90108
func = getattr(obj, attr, None)
91109
if func and isinstance(func, ObjectProxy) and hasattr(func, "__wrapped__"):
92110
setattr(obj, attr, func.__wrapped__)

opentelemetry-instrumentation/tests/test_utils.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import unittest
1616
from http import HTTPStatus
1717

18+
from wrapt import ObjectProxy, wrap_function_wrapper
19+
1820
from opentelemetry.context import (
1921
_SUPPRESS_HTTP_INSTRUMENTATION_KEY,
2022
_SUPPRESS_INSTRUMENTATION_KEY,
@@ -29,10 +31,19 @@
2931
is_instrumentation_enabled,
3032
suppress_http_instrumentation,
3133
suppress_instrumentation,
34+
unwrap,
3235
)
3336
from opentelemetry.trace import StatusCode
3437

3538

39+
class WrappedClass:
40+
def method(self):
41+
pass
42+
43+
def wrapper_method(self):
44+
pass
45+
46+
3647
class TestUtils(unittest.TestCase):
3748
# See https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#status
3849
def test_http_status_to_status_code(self):
@@ -240,3 +251,75 @@ def test_suppress_http_instrumentation_key(self):
240251
self.assertTrue(get_value(_SUPPRESS_HTTP_INSTRUMENTATION_KEY))
241252

242253
self.assertIsNone(get_value(_SUPPRESS_HTTP_INSTRUMENTATION_KEY))
254+
255+
256+
class UnwrapTestCase(unittest.TestCase):
257+
@staticmethod
258+
def _wrap_method():
259+
return wrap_function_wrapper(
260+
WrappedClass, "method", WrappedClass.wrapper_method
261+
)
262+
263+
def test_can_unwrap_object_attribute(self):
264+
self._wrap_method()
265+
instance = WrappedClass()
266+
self.assertTrue(isinstance(instance.method, ObjectProxy))
267+
268+
unwrap(WrappedClass, "method")
269+
self.assertFalse(isinstance(instance.method, ObjectProxy))
270+
271+
def test_can_unwrap_object_attribute_as_string(self):
272+
self._wrap_method()
273+
instance = WrappedClass()
274+
self.assertTrue(isinstance(instance.method, ObjectProxy))
275+
276+
unwrap("tests.test_utils.WrappedClass", "method")
277+
self.assertFalse(isinstance(instance.method, ObjectProxy))
278+
279+
def test_raises_import_error_if_path_not_well_formed(self):
280+
self._wrap_method()
281+
instance = WrappedClass()
282+
self.assertTrue(isinstance(instance.method, ObjectProxy))
283+
284+
with self.assertRaisesRegex(
285+
ImportError, "Cannot parse '' as dotted import path"
286+
):
287+
unwrap("", "method")
288+
289+
unwrap(WrappedClass, "method")
290+
self.assertFalse(isinstance(instance.method, ObjectProxy))
291+
292+
def test_raises_import_error_if_cannot_find_module(self):
293+
self._wrap_method()
294+
instance = WrappedClass()
295+
self.assertTrue(isinstance(instance.method, ObjectProxy))
296+
297+
with self.assertRaisesRegex(ImportError, "No module named 'does'"):
298+
unwrap("does.not.exist.WrappedClass", "method")
299+
300+
unwrap(WrappedClass, "method")
301+
self.assertFalse(isinstance(instance.method, ObjectProxy))
302+
303+
def test_raises_import_error_if_cannot_find_object(self):
304+
self._wrap_method()
305+
instance = WrappedClass()
306+
self.assertTrue(isinstance(instance.method, ObjectProxy))
307+
308+
with self.assertRaisesRegex(
309+
ImportError, "Cannot import 'NotWrappedClass' from"
310+
):
311+
unwrap("tests.test_utils.NotWrappedClass", "method")
312+
313+
unwrap(WrappedClass, "method")
314+
self.assertFalse(isinstance(instance.method, ObjectProxy))
315+
316+
# pylint: disable=no-self-use
317+
def test_does_nothing_if_cannot_find_attribute(self):
318+
instance = WrappedClass()
319+
unwrap(instance, "method_not_found")
320+
321+
def test_does_nothing_if_attribute_is_not_from_wrapt(self):
322+
instance = WrappedClass()
323+
self.assertFalse(isinstance(instance.method, ObjectProxy))
324+
unwrap(WrappedClass, "method")
325+
self.assertFalse(isinstance(instance.method, ObjectProxy))

0 commit comments

Comments
 (0)