Skip to content

Commit 32d0df1

Browse files
committed
Add support for instrumenting async functions via decorator.
This is only for python versions 3.6 and up. Fixes #633
1 parent 859cbcd commit 32d0df1

6 files changed

Lines changed: 314 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
- Tested with Django 3.2. Only define ``default_app_config`` when using
88
a version of Django earlier than 3.2.
9+
- Support instrumentation and transaction decorators for asynchronous
10+
functions via ``@instrument.async_``, ``@WebTransaction.async_`` and
11+
``@BackgroundTransaction.async_``.
12+
([PR #633](https://github.com/scoutapp/scout_apm_python/issues/633))
913

1014
### Fixed
1115

src/scout_apm/api/__init__.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66
from scout_apm.core.config import ScoutConfig
77
from scout_apm.core.tracked_request import TrackedRequest
88

9+
# The async_ module can only be shipped on Python 3.6+
10+
try:
11+
from scout_apm.async_.api import AsyncDecoratorMixin
12+
except ImportError:
13+
14+
class AsyncDecoratorMixin(object):
15+
pass
16+
17+
918
__all__ = [
1019
"BackgroundTransaction",
1120
"Config",
@@ -41,7 +50,7 @@ def ignore_transaction():
4150
TrackedRequest.instance().tag("ignore_transaction", True)
4251

4352

44-
class instrument(ContextDecorator):
53+
class instrument(AsyncDecoratorMixin, ContextDecorator):
4554
def __init__(self, operation, kind="Custom", tags=None):
4655
self.operation = text(kind) + "/" + text(operation)
4756
if tags is None:
@@ -66,7 +75,7 @@ def tag(self, key, value):
6675
self.span.tag(key, value)
6776

6877

69-
class Transaction(ContextDecorator):
78+
class Transaction(AsyncDecoratorMixin, ContextDecorator):
7079
"""
7180
This Class is not meant to be used directly.
7281
Use one of the subclasses

src/scout_apm/async_/api.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# coding=utf-8
2+
from __future__ import absolute_import, division, print_function, unicode_literals
3+
4+
from functools import wraps
5+
6+
7+
class AsyncDecoratorMixin(object):
8+
"""Provide the ability to decorate both sync and async functions."""
9+
10+
is_async = False
11+
12+
@classmethod
13+
def async_(cls, operation, tags=None, **kwargs):
14+
"""
15+
Instrument an async function via a decorator.
16+
17+
This will return an awaitable which must be awaited.
18+
Using this on a synchronous function will raise a
19+
RuntimeError.
20+
21+
``
22+
@instrument.async_("Foo")
23+
async def foo():
24+
...
25+
``
26+
"""
27+
instance = cls(operation, tags=tags, **kwargs)
28+
instance.is_async = True
29+
return instance
30+
31+
def __call__(self, func):
32+
if self.is_async:
33+
# Until https://bugs.python.org/issue37398 has a resolution,
34+
# manually wrap the async function
35+
@wraps(func)
36+
async def decorated(*args, **kwds):
37+
with self._recreate_cm():
38+
return await func(*args, **kwds)
39+
40+
return decorated
41+
else:
42+
return super().__call__(func)

src/scout_apm/compat.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,15 @@ def iteritems(dictionary):
3434
if sys.version_info >= (3, 2):
3535
from contextlib import ContextDecorator
3636
else:
37-
import functools
3837

3938
class ContextDecorator(object):
39+
def _recreate_cm(self):
40+
return self
41+
4042
def __call__(self, f):
41-
@functools.wraps(f)
43+
@wraps(f)
4244
def decorated(*args, **kwds):
43-
with self:
45+
with self._recreate_cm():
4446
return f(*args, **kwds)
4547

4648
return decorated

tests/integration/instruments/test_asyncio_py36plus.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ async def test_task_awaited(tracked_request):
130130

131131
@async_test
132132
async def test_nested_tasks(tracked_request):
133+
@instrument.async_("orchestrator")
133134
async def orchestrator():
134135
await coro("1")
135136
await asyncio.gather(
@@ -143,11 +144,12 @@ async def orchestrator():
143144
await orchestrator()
144145

145146
spans = tracked_request.complete_spans
146-
assert len(spans) == 4
147+
assert len(spans) == 5
147148
assert [span.operation for span in spans] == [
148149
"Custom/coro",
149150
"Custom/coro",
150151
"Custom/coro",
152+
"Custom/orchestrator",
151153
"Job/test",
152154
]
153155
# Verify the order of the coroutines
@@ -156,6 +158,7 @@ async def orchestrator():
156158
"2b",
157159
"2a",
158160
None,
161+
None,
159162
]
160163

161164

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# coding=utf-8
2+
from __future__ import absolute_import, division, print_function, unicode_literals
3+
4+
import pytest
5+
6+
from scout_apm.api import BackgroundTransaction, WebTransaction, instrument
7+
from tests.tools import async_test
8+
9+
10+
@async_test
11+
async def test_instrument_decorator_async(tracked_request):
12+
@instrument.async_("Foo")
13+
async def foo():
14+
pass
15+
16+
@instrument.async_("Bar")
17+
async def example():
18+
await foo()
19+
20+
await example()
21+
22+
assert len(tracked_request.active_spans) == 0
23+
assert len(tracked_request.complete_spans) == 2
24+
assert tracked_request.complete_spans[0].operation == "Custom/Foo"
25+
assert tracked_request.complete_spans[1].operation == "Custom/Bar"
26+
27+
28+
def test_instrument_decorator_async_for_sync_function(tracked_request):
29+
@instrument.async_("Bar")
30+
def example():
31+
pass
32+
33+
with pytest.warns(RuntimeWarning):
34+
example()
35+
36+
assert len(tracked_request.active_spans) == 0
37+
assert len(tracked_request.complete_spans) == 0
38+
39+
40+
@async_test
41+
async def test_instrument_decorator_async_misconfigured(tracked_request):
42+
"""Test case where .async_ isn't used from parent instrument"""
43+
44+
@instrument.async_("Foo")
45+
async def foo():
46+
pass
47+
48+
@instrument("Bar")
49+
async def example():
50+
await foo()
51+
52+
await example()
53+
54+
assert len(tracked_request.active_spans) == 0
55+
assert len(tracked_request.complete_spans) == 1
56+
assert tracked_request.complete_spans[0].operation == "Custom/Bar"
57+
58+
59+
@async_test
60+
async def test_instrument_decorator_async_classmethod(tracked_request):
61+
class Example(object):
62+
@classmethod
63+
@instrument.async_("Test Decorator")
64+
async def method(cls):
65+
pass
66+
67+
await Example.method()
68+
69+
assert len(tracked_request.active_spans) == 0
70+
assert len(tracked_request.complete_spans) == 1
71+
assert tracked_request.complete_spans[0].operation == "Custom/Test Decorator"
72+
73+
74+
@async_test
75+
async def test_instrument_decorator_async_staticmethod(tracked_request):
76+
class Example(object):
77+
@staticmethod
78+
@instrument.async_("Test Decorator")
79+
async def method():
80+
pass
81+
82+
await Example.method()
83+
84+
assert len(tracked_request.active_spans) == 0
85+
assert len(tracked_request.complete_spans) == 1
86+
assert tracked_request.complete_spans[0].operation == "Custom/Test Decorator"
87+
88+
89+
@async_test
90+
async def test_instrument_decorator_async_return_awaitable(tracked_request):
91+
@instrument.async_("Foo")
92+
async def foo():
93+
pass
94+
95+
@instrument.async_("Bar")
96+
def return_awaitable():
97+
return foo()
98+
99+
await return_awaitable()
100+
101+
assert len(tracked_request.active_spans) == 0
102+
assert len(tracked_request.complete_spans) == 2
103+
assert tracked_request.complete_spans[0].operation == "Custom/Foo"
104+
assert tracked_request.complete_spans[1].operation == "Custom/Bar"
105+
106+
107+
@async_test
108+
async def test_instrument_decorator_async_return_awaitable_misconfigured(
109+
tracked_request,
110+
):
111+
"""Test case where .async_ isn't used from parent instrument"""
112+
113+
@instrument.async_("Foo")
114+
async def foo():
115+
pass
116+
117+
@instrument("Bar")
118+
def return_awaitable():
119+
return foo()
120+
121+
await return_awaitable()
122+
123+
assert len(tracked_request.active_spans) == 0
124+
assert len(tracked_request.complete_spans) == 1
125+
assert tracked_request.complete_spans[0].operation == "Custom/Bar"
126+
127+
128+
@async_test
129+
async def test_instrument_context_manager_async_await_later(tracked_request):
130+
"""
131+
Test proving that if an awaitable goes unawaited in a context manager,
132+
the spans are lost.
133+
"""
134+
135+
@instrument.async_("Outer")
136+
async def foo():
137+
with instrument("Inner"):
138+
pass
139+
140+
async def example():
141+
await foo()
142+
143+
with instrument("Test Decorator"):
144+
awaitable = example()
145+
146+
await awaitable
147+
148+
assert len(tracked_request.active_spans) == 0
149+
assert len(tracked_request.complete_spans) == 1
150+
assert tracked_request.complete_spans[0].operation == "Custom/Test Decorator"
151+
152+
153+
@async_test
154+
async def test_web_transaction_decorator_async(tracked_request):
155+
@instrument.async_("Foo")
156+
async def foo():
157+
pass
158+
159+
@WebTransaction.async_("Bar")
160+
async def my_transaction():
161+
await foo()
162+
163+
await my_transaction()
164+
165+
assert len(tracked_request.active_spans) == 0
166+
assert len(tracked_request.complete_spans) == 2
167+
assert tracked_request.complete_spans[0].operation == "Custom/Foo"
168+
assert tracked_request.complete_spans[1].operation == "Controller/Bar"
169+
170+
171+
@async_test
172+
async def test_web_transaction_decorator_async_misconfigured(tracked_request):
173+
"""Test case where .async_ isn't used from WebTransaction"""
174+
175+
@instrument.async_("Foo")
176+
async def foo():
177+
pass
178+
179+
@WebTransaction("Bar")
180+
async def my_transaction():
181+
await foo()
182+
183+
await my_transaction()
184+
185+
assert len(tracked_request.active_spans) == 0
186+
assert len(tracked_request.complete_spans) == 1
187+
assert tracked_request.complete_spans[0].operation == "Controller/Bar"
188+
189+
190+
def test_web_transaction_decorator_async_for_sync_function(tracked_request):
191+
@WebTransaction.async_("Bar")
192+
def example():
193+
pass
194+
195+
with pytest.warns(RuntimeWarning):
196+
example()
197+
198+
assert len(tracked_request.active_spans) == 0
199+
assert len(tracked_request.complete_spans) == 0
200+
201+
202+
@async_test
203+
async def test_background_transaction_decorator_async(tracked_request):
204+
@instrument.async_("Foo")
205+
async def foo():
206+
pass
207+
208+
@BackgroundTransaction.async_("Bar")
209+
async def my_transaction():
210+
await foo()
211+
212+
await my_transaction()
213+
214+
assert len(tracked_request.active_spans) == 0
215+
assert len(tracked_request.complete_spans) == 2
216+
assert tracked_request.complete_spans[0].operation == "Custom/Foo"
217+
assert tracked_request.complete_spans[1].operation == "Job/Bar"
218+
219+
220+
@async_test
221+
async def test_background_transaction_decorator_async_misconfigured(tracked_request):
222+
"""Test case where .async_ isn't used from BackgroundTransaction"""
223+
224+
@instrument.async_("Foo")
225+
async def foo():
226+
pass
227+
228+
@BackgroundTransaction("Bar")
229+
async def my_transaction():
230+
await foo()
231+
232+
await my_transaction()
233+
234+
assert len(tracked_request.active_spans) == 0
235+
assert len(tracked_request.complete_spans) == 1
236+
assert tracked_request.complete_spans[0].operation == "Job/Bar"
237+
238+
239+
def test_background_transaction_decorator_async_for_sync_function(tracked_request):
240+
@BackgroundTransaction.async_("Bar")
241+
def example():
242+
pass
243+
244+
with pytest.warns(RuntimeWarning):
245+
example()
246+
247+
assert len(tracked_request.active_spans) == 0
248+
assert len(tracked_request.complete_spans) == 0

0 commit comments

Comments
 (0)