Repository: scoutapp/scout_apm_python
Pull request #670 (2021-08-03 20:04:18)

Switch to use ContextVars to store TrackedRequest.
This better supports when processes use separate OS threads
to execute code such as run_in_executor

This adds the dependency contextvars when using python 3.6


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7b7cc7..b249935 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
   ([Issue #671](https://github.com/scoutapp/scout_apm_python/issues/671))
 
 ### Fixed
+- Switch to storing TrackedRequest in a ContextVar for Python3.6+ rather than in a thread local.
 
 ## [2.21.1] 2021-07-27
 
diff --git a/setup.py b/setup.py
index 5658337..05673f3 100644
--- a/setup.py
+++ b/setup.py
@@ -62,6 +62,7 @@ setup(
     },
     install_requires=[
         'asgiref ; python_version >= "3.5"',
+        'contextvars ; python_version >= "3.6" and python_version < "3.7"',
         'importlib-metadata ; python_version < "3.8"',
         "psutil>=5,<6",
         'urllib3[secure] < 1.25 ; python_version < "3.5"',
diff --git a/src/scout_apm/core/context.py b/src/scout_apm/core/context.py
index 72d44af..a447e06 100644
--- a/src/scout_apm/core/context.py
+++ b/src/scout_apm/core/context.py
@@ -18,6 +18,12 @@ try:
 except ImportError:
     asyncio = None
 
+try:
+    from contextvars import ContextVar
+except ImportError:
+    ContextVar = None
+
+
 SCOUT_REQUEST_ATTR = "__scout_trackedrequest"
 
 
@@ -112,18 +118,24 @@ class LocalContext(object):
             self._local = SimplifiedAsgirefLocal()
         else:
             self._local = ThreadLocal()
+        self._ctx = ContextVar(SCOUT_REQUEST_ATTR) if ContextVar else None
+        self._token = None
 
     def get_tracked_request(self):
-        task = get_current_asyncio_task()
+        if self._ctx:
+            if not self._ctx.get(None):
+                self._token = self._ctx.set(TrackedRequest())
+            return self._ctx.get()
         if not hasattr(self._local, "tracked_request"):
-            self._local.tracked_request = (
-                task and getattr(task, SCOUT_REQUEST_ATTR, None) or TrackedRequest()
-            )
+            self._local.tracked_request = TrackedRequest()
         return self._local.tracked_request
 
     def clear_tracked_request(self, instance):
         if getattr(self._local, "tracked_request", None) is instance:
             del self._local.tracked_request
+        if self._ctx and self._token is not None and self._ctx.get() is instance:
+            self._ctx = ContextVar(SCOUT_REQUEST_ATTR)
+            self._token = None
 
 
 context = LocalContext()
diff --git a/src/scout_apm/instruments/__init__.py b/src/scout_apm/instruments/__init__.py
index 5d0c7c9..0b871a3 100644
--- a/src/scout_apm/instruments/__init__.py
+++ b/src/scout_apm/instruments/__init__.py
@@ -8,7 +8,7 @@ from scout_apm.core.config import scout_config
 
 logger = logging.getLogger(__name__)
 
-instrument_names = ["asyncio", "elasticsearch", "jinja2", "pymongo", "redis", "urllib3"]
+instrument_names = ["elasticsearch", "jinja2", "pymongo", "redis", "urllib3"]
 
 
 def ensure_all_installed():
diff --git a/src/scout_apm/instruments/asyncio.py b/src/scout_apm/instruments/asyncio.py
deleted file mode 100644
index 48541c4..0000000
--- a/src/scout_apm/instruments/asyncio.py
+++ /dev/null
@@ -1,65 +0,0 @@
-# coding=utf-8
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-import logging
-
-import wrapt
-
-from scout_apm.core.context import (
-    SCOUT_REQUEST_ATTR,
-    TrackedRequest,
-    get_current_asyncio_task,
-)
-
-try:
-    import asyncio
-except ImportError:
-    asyncio = None
-
-logger = logging.getLogger(__name__)
-
-have_patched_asyncio = False
-
-
-def wrapped_create_task(wrapped, instance, args, kwargs):
-    """Wrapper for ``create_task(coro)`` that propagates the current
-    ``TrackedRequest`` to the new ``Task``.
-    """
-    new_task = wrapped(*args, **kwargs)
-    current_task = get_current_asyncio_task()
-
-    tracked_request = (
-        getattr(current_task, SCOUT_REQUEST_ATTR, None) or TrackedRequest.instance()
-    )
-
-    if tracked_request.is_real_request and not tracked_request.is_ignored():
-        # Updates the ``TrackedRequest`` for the given Task.
-        # Used to pass the request amongst different tasks.
-        setattr(new_task, SCOUT_REQUEST_ATTR, tracked_request)
-
-    return new_task
-
-
-def ensure_installed():
-    global have_patched_asyncio
-
-    logger.debug("Instrumenting asyncio.")
-
-    if asyncio is None:
-        logger.debug("Couldn't import asyncio - probably old version of python.")
-    else:
-        if not have_patched_asyncio:
-            try:
-                wrapt.wrap_function_wrapper(
-                    asyncio.BaseEventLoop, "create_task", wrapped_create_task
-                )
-            except Exception as exc:
-                logger.warning(
-                    "Failed to instrument asyncio.create_task: %r",
-                    exc,
-                    exc_info=exc,
-                )
-            else:
-                have_patched_asyncio = True
-
-    return True
diff --git a/tox.ini b/tox.ini
index e799e7a..c3f5225 100644
--- a/tox.ini
+++ b/tox.ini
@@ -61,6 +61,7 @@ deps =
     pymongo
     pyramid
     pytest
+    pytest-asyncio ; python_version >= "3.6"
     pytest-cov
     pyyaml ; python_version != "3.4"
     pyyaml < 5.3 ; python_version == "3.4"


Test patch:
diff --git a/tests/integration/instruments/test_asyncio_py36plus.py b/tests/integration/instruments/test_asyncio_py36plus.py
index a2ee15e..2f3ed0e 100644
--- a/tests/integration/instruments/test_asyncio_py36plus.py
+++ b/tests/integration/instruments/test_asyncio_py36plus.py
@@ -1,15 +1,21 @@
 # coding=utf-8
+"""While asyncio isn't instrumented specifically, it's good to verify
+the functionality is instrumented.
+
+Note, the tracked_request fixture should not be used as the running
+thread may change from when the fixture is started to when the tests
+run. This is only a problem on python 3.6 and results in getting the
+tracked_instance in the test itself.
+"""
 from __future__ import absolute_import, division, print_function, unicode_literals
 
 import asyncio
-import logging
+import contextvars
+import functools
 
 import pytest
 
 from scout_apm.api import BackgroundTransaction, instrument
-from scout_apm.instruments.asyncio import ensure_installed
-from tests.compat import mock
-from tests.tools import async_test
 
 
 def create_task(coro):
@@ -27,7 +33,6 @@ async def coro(value=None, wait=None):
     :loop: EventLoop to be used if any.
     :returns: nothing.
     """
-
     if wait:
         await asyncio.sleep(wait)
     tags = {"value": value} if value else None
@@ -61,41 +66,8 @@ async def resolving_future(wait):
     return fut
 
 
-def test_ensure_installed_twice(caplog):
-    ensure_installed()
-    ensure_installed()
-
-    assert caplog.record_tuples == 2 * [
-        (
-            "scout_apm.instruments.asyncio",
-            logging.DEBUG,
-            "Instrumenting asyncio.",
-        )
-    ]
-
-
-def test_install_fail_no_asyncio(caplog):
-    mock_no_asyncio = mock.patch("scout_apm.instruments.asyncio.asyncio", new=None)
-    with mock_no_asyncio:
-        ensure_installed()
-
-    assert caplog.record_tuples == [
-        (
-            "scout_apm.instruments.asyncio",
-            logging.DEBUG,
-            "Instrumenting asyncio.",
-        ),
-        (
-            "scout_apm.instruments.asyncio",
-            logging.DEBUG,
-            "Couldn't import asyncio - probably old version of python.",
-        ),
-    ]
-
-
-@async_test
-async def test_coroutine(tracked_request):
-    ensure_installed()
+@pytest.mark.asyncio
+async def test_coroutine(tracked_requests, tracked_request):
     with BackgroundTransaction("test"):
         await coro()
 
@@ -103,22 +75,24 @@ async def test_coroutine(tracked_request):
     span = tracked_request.complete_spans[0]
     assert span.operation == "Custom/coro"
     assert tracked_request.complete_spans[1].operation == "Job/test"
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id == tracked_request.request_id
 
 
-@async_test
-async def test_coroutine_timeout(tracked_request):
-    ensure_installed()
+@pytest.mark.asyncio
+async def test_coroutine_timeout(tracked_requests, tracked_request):
     with BackgroundTransaction("test"):
         with pytest.raises(asyncio.TimeoutError):
             await asyncio.wait_for(coro("long", wait=0.5), timeout=0.1)
 
     assert len(tracked_request.complete_spans) == 1
     assert tracked_request.complete_spans[0].operation == "Job/test"
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id == tracked_request.request_id
 
 
-@async_test
-async def test_task_awaited(tracked_request):
-    ensure_installed()
+@pytest.mark.asyncio
+async def test_task_awaited(tracked_requests, tracked_request):
     with BackgroundTransaction("test"):
         await create_task(coro())
 
@@ -126,10 +100,12 @@ async def test_task_awaited(tracked_request):
     span = tracked_request.complete_spans[0]
     assert span.operation == "Custom/coro"
     assert tracked_request.complete_spans[1].operation == "Job/test"
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id == tracked_request.request_id
 
 
-@async_test
-async def test_nested_tasks(tracked_request):
+@pytest.mark.asyncio
+async def test_nested_tasks(tracked_requests, tracked_request):
     @instrument.async_("orchestrator")
     async def orchestrator():
         await coro("1")
@@ -139,7 +115,6 @@ async def test_nested_tasks(tracked_request):
             coro("2b"),
         )
 
-    ensure_installed()
     with BackgroundTransaction("test"):
         await orchestrator()
 
@@ -160,11 +135,12 @@ async def test_nested_tasks(tracked_request):
         None,
         None,
     ]
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id == tracked_request.request_id
 
 
-@async_test
-async def test_task_not_awaited(tracked_request):
-    ensure_installed()
+@pytest.mark.asyncio
+async def test_task_not_awaited(tracked_requests, tracked_request):
     with BackgroundTransaction("test"):
         await create_task(coro("short"))
         long = create_task(coro("long", wait=0.5))
@@ -177,26 +153,28 @@ async def test_task_not_awaited(tracked_request):
 
     assert not long.done()
     await long
-    assert len(tracked_request.complete_spans) == 3
-    span = tracked_request.complete_spans[2]
-    assert span.operation == "Custom/coro"
-    assert span.tags["value"] == "long"
+    assert len(tracked_request.complete_spans) == 2
+    assert tracked_request.complete_spans[0].operation == "Custom/coro"
+    assert tracked_request.complete_spans[0].tags["value"] == "short"
+    assert tracked_request.complete_spans[1].operation == "Job/test"
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id == tracked_request.request_id
 
 
-@async_test
-async def test_task_cancelled(tracked_request):
-    ensure_installed()
+@pytest.mark.asyncio
+async def test_task_cancelled(tracked_requests, tracked_request):
     with BackgroundTransaction("test"):
         long = create_task(coro("long", wait=0.5))
         long.cancel()
 
     assert len(tracked_request.complete_spans) == 1
     assert tracked_request.complete_spans[0].operation == "Job/test"
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id == tracked_request.request_id
 
 
-@async_test
-async def test_future_awaited(tracked_request):
-    ensure_installed()
+@pytest.mark.asyncio
+async def test_future_awaited(tracked_requests, tracked_request):
     with BackgroundTransaction("test"):
         fut = get_future()
         create_task(set_future(fut, wait=0.5))
@@ -206,11 +184,12 @@ async def test_future_awaited(tracked_request):
 
     assert len(tracked_request.complete_spans) == 2
     assert tracked_request.complete_spans[1].operation == "Job/test"
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id == tracked_request.request_id
 
 
-@async_test
-async def test_future_gathered(tracked_request):
-    ensure_installed()
+@pytest.mark.asyncio
+async def test_future_gathered(tracked_requests, tracked_request):
     with BackgroundTransaction("test"):
         await asyncio.gather(
             resolving_future(0.5),
@@ -223,11 +202,12 @@ async def test_future_gathered(tracked_request):
 
     assert len(tracked_request.complete_spans) == 3
     assert tracked_request.complete_spans[2].operation == "Job/test"
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id == tracked_request.request_id
 
 
-@async_test
-async def test_future_not_gathered(tracked_request):
-    ensure_installed()
+@pytest.mark.asyncio
+async def test_future_not_gathered(tracked_requests, tracked_request):
     with BackgroundTransaction("test"):
         gather_future = asyncio.gather(
             resolving_future(0.5),
@@ -238,15 +218,14 @@ async def test_future_not_gathered(tracked_request):
     assert tracked_request.complete_spans[0].operation == "Job/test"
 
     await gather_future
-    assert len(tracked_request.complete_spans) == 3
-    # gather can run the coroutines/futures in any order.
-    operations = {span.operation for span in tracked_request.complete_spans[1:]}
-    assert operations == {"Custom/set_future", "Custom/coro"}
+
+    assert len(tracked_request.complete_spans) == 1
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id == tracked_request.request_id
 
 
-@async_test
-async def test_future_cancelled(tracked_request):
-    ensure_installed()
+@pytest.mark.asyncio
+async def test_future_cancelled(tracked_requests, tracked_request):
     with BackgroundTransaction("test"):
         gather_future = asyncio.gather(
             resolving_future(0.5),
@@ -254,5 +233,42 @@ async def test_future_cancelled(tracked_request):
         )
         gather_future.cancel()
 
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id == tracked_request.request_id
     assert len(tracked_request.complete_spans) == 1
     assert tracked_request.complete_spans[0].operation == "Job/test"
+
+
+@pytest.mark.asyncio
+async def test_run_in_executor_with_context_copied(tracked_requests, tracked_request):
+    def inner_func():
+        with BackgroundTransaction("test"):
+            pass
+
+    loop = asyncio.get_event_loop()
+    child = functools.partial(inner_func)
+    context = contextvars.copy_context()
+    func = context.run
+    args = (child,)
+    await loop.run_in_executor(None, func, *args)
+
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id == tracked_request.request_id
+    assert len(tracked_requests[0].complete_spans) == 1
+
+
+@pytest.mark.asyncio
+async def test_run_in_executor_without_context_copied(
+    tracked_requests, tracked_request
+):
+    def inner_func():
+        with BackgroundTransaction("test"):
+            pass
+
+    loop = asyncio.get_event_loop()
+    func = functools.partial(inner_func)
+    await loop.run_in_executor(None, func)
+
+    assert len(tracked_requests) == 1
+    assert tracked_requests[0].request_id != tracked_request.request_id
+    assert len(tracked_requests[0].complete_spans) == 1
diff --git a/tests/integration/instruments/test_jinja2_py36plus.py b/tests/integration/instruments/test_jinja2_py36plus.py
index abb5e65..478e3bb 100644
--- a/tests/integration/instruments/test_jinja2_py36plus.py
+++ b/tests/integration/instruments/test_jinja2_py36plus.py
@@ -4,11 +4,11 @@ from __future__ import absolute_import, division, print_function, unicode_litera
 import logging
 
 import jinja2
+import pytest
 
 from scout_apm.instruments.jinja2 import ensure_installed
 from tests.compat import mock
 from tests.integration.instruments.test_jinja2 import mock_not_attempted
-from tests.tools import async_test
 
 
 def test_ensure_installed_fail_no_render_async_attribute(caplog):
@@ -29,7 +29,7 @@ def test_ensure_installed_fail_no_render_async_attribute(caplog):
     ]
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_async_render(tracked_request):
     ensure_installed()
     template = jinja2.Template("Hello {{ name }}!", enable_async=True)
@@ -43,7 +43,7 @@ async def test_async_render(tracked_request):
     assert span.tags["name"] is None
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_async_render_name(tracked_request):
     ensure_installed()
     template = jinja2.Template("Hello {{ name }}!", enable_async=True)
diff --git a/tests/integration/test_api_py36plus.py b/tests/integration/test_api_py36plus.py
index 8464bf2..36d9992 100644
--- a/tests/integration/test_api_py36plus.py
+++ b/tests/integration/test_api_py36plus.py
@@ -4,10 +4,9 @@ from __future__ import absolute_import, division, print_function, unicode_litera
 import pytest
 
 from scout_apm.api import BackgroundTransaction, WebTransaction, instrument
-from tests.tools import async_test
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_instrument_decorator_async(tracked_request):
     @instrument.async_("Foo")
     async def foo():
@@ -37,7 +36,7 @@ def test_instrument_decorator_async_for_sync_function(tracked_request):
     assert len(tracked_request.complete_spans) == 0
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_instrument_decorator_async_misconfigured(tracked_request):
     """Test case where .async_ isn't used from parent instrument"""
 
@@ -56,7 +55,7 @@ async def test_instrument_decorator_async_misconfigured(tracked_request):
     assert tracked_request.complete_spans[0].operation == "Custom/Bar"
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_instrument_decorator_async_classmethod(tracked_request):
     class Example(object):
         @classmethod
@@ -71,7 +70,7 @@ async def test_instrument_decorator_async_classmethod(tracked_request):
     assert tracked_request.complete_spans[0].operation == "Custom/Test Decorator"
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_instrument_decorator_async_staticmethod(tracked_request):
     class Example(object):
         @staticmethod
@@ -86,7 +85,7 @@ async def test_instrument_decorator_async_staticmethod(tracked_request):
     assert tracked_request.complete_spans[0].operation == "Custom/Test Decorator"
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_instrument_decorator_async_return_awaitable(tracked_request):
     @instrument.async_("Foo")
     async def foo():
@@ -104,7 +103,7 @@ async def test_instrument_decorator_async_return_awaitable(tracked_request):
     assert tracked_request.complete_spans[1].operation == "Custom/Bar"
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_instrument_decorator_async_return_awaitable_misconfigured(
     tracked_request,
 ):
@@ -125,7 +124,7 @@ async def test_instrument_decorator_async_return_awaitable_misconfigured(
     assert tracked_request.complete_spans[0].operation == "Custom/Bar"
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_instrument_context_manager_async_await_later(tracked_request):
     """
     Test proving that if an awaitable goes unawaited in a context manager,
@@ -150,7 +149,7 @@ async def test_instrument_context_manager_async_await_later(tracked_request):
     assert tracked_request.complete_spans[0].operation == "Custom/Test Decorator"
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_web_transaction_decorator_async(tracked_request):
     @instrument.async_("Foo")
     async def foo():
@@ -168,7 +167,7 @@ async def test_web_transaction_decorator_async(tracked_request):
     assert tracked_request.complete_spans[1].operation == "Controller/Bar"
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_web_transaction_decorator_async_misconfigured(tracked_request):
     """Test case where .async_ isn't used from WebTransaction"""
 
@@ -199,7 +198,7 @@ def test_web_transaction_decorator_async_for_sync_function(tracked_request):
     assert len(tracked_request.complete_spans) == 0
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_background_transaction_decorator_async(tracked_request):
     @instrument.async_("Foo")
     async def foo():
@@ -217,7 +216,7 @@ async def test_background_transaction_decorator_async(tracked_request):
     assert tracked_request.complete_spans[1].operation == "Job/Bar"
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_background_transaction_decorator_async_misconfigured(tracked_request):
     """Test case where .async_ isn't used from BackgroundTransaction"""
 
diff --git a/tests/integration/test_starlette_py36plus.py b/tests/integration/test_starlette_py36plus.py
index d1cb8ed..e0d5e5a 100644
--- a/tests/integration/test_starlette_py36plus.py
+++ b/tests/integration/test_starlette_py36plus.py
@@ -24,7 +24,7 @@ from tests.integration.util import (
     parametrize_queue_time_header_name,
     parametrize_user_ip_headers,
 )
-from tests.tools import asgi_http_scope, async_test
+from tests.tools import asgi_http_scope
 
 
 @contextmanager
@@ -113,7 +113,7 @@ def app_with_scout(*, middleware=None, scout_config=None):
         Config.reset_all()
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_home(tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(app, asgi_http_scope(path="/"))
@@ -137,7 +137,7 @@ async def test_home(tracked_requests):
     )
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_sync_home(tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(app, asgi_http_scope(path="/sync-home/"))
@@ -161,7 +161,7 @@ async def test_sync_home(tracked_requests):
     )
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_home_ignored(tracked_requests):
     with app_with_scout(scout_config={"ignore": ["/"]}) as app:
         communicator = ApplicationCommunicator(app, asgi_http_scope(path="/"))
@@ -177,7 +177,7 @@ async def test_home_ignored(tracked_requests):
     assert tracked_requests == []
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_hello(tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(app, asgi_http_scope(path="/hello/"))
@@ -201,7 +201,7 @@ async def test_hello(tracked_requests):
     )
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_sync_hello(tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(
@@ -227,7 +227,7 @@ async def test_sync_hello(tracked_requests):
     )
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_not_found(tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(app, asgi_http_scope(path="/not-found/"))
@@ -242,7 +242,7 @@ async def test_not_found(tracked_requests):
 
 
 @parametrize_filtered_params
-@async_test
+@pytest.mark.asyncio
 async def test_filtered_params(params, expected_path, tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(
@@ -259,7 +259,7 @@ async def test_filtered_params(params, expected_path, tracked_requests):
 
 
 @parametrize_user_ip_headers
-@async_test
+@pytest.mark.asyncio
 async def test_user_ip(headers, client_address, expected, tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(
@@ -275,7 +275,7 @@ async def test_user_ip(headers, client_address, expected, tracked_requests):
     assert tracked_requests[0].tags["user_ip"] == expected
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_user_ip_collection_disabled(tracked_requests):
     with app_with_scout(scout_config={"collect_remote_ip": False}) as app:
         communicator = ApplicationCommunicator(
@@ -290,7 +290,7 @@ async def test_user_ip_collection_disabled(tracked_requests):
 
 
 @parametrize_queue_time_header_name
-@async_test
+@pytest.mark.asyncio
 async def test_queue_time(header_name, tracked_requests):
     # Not testing floats due to Python 2/3 rounding differences
     queue_start = int(datetime_to_timestamp(dt.datetime.utcnow())) - 2
@@ -311,7 +311,7 @@ async def test_queue_time(header_name, tracked_requests):
     assert isinstance(queue_time_ns, int) and queue_time_ns > 0
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_server_error(tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(app, asgi_http_scope(path="/crash/"))
@@ -332,7 +332,7 @@ async def test_server_error(tracked_requests):
     )
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_return_error(tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(
@@ -350,7 +350,7 @@ async def test_return_error(tracked_requests):
     assert tracked_request.tags["error"] == "true"
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_no_monitor(tracked_requests):
     with app_with_scout(scout_config={"monitor": False}) as app:
         communicator = ApplicationCommunicator(app, asgi_http_scope(path="/"))
@@ -363,7 +363,7 @@ async def test_no_monitor(tracked_requests):
     assert tracked_requests == []
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_unknown_asgi_scope(tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(app, {"type": "lifespan"})
@@ -374,7 +374,7 @@ async def test_unknown_asgi_scope(tracked_requests):
     assert tracked_requests == []
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_background_jobs(tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(
@@ -407,7 +407,7 @@ async def test_background_jobs(tracked_requests):
     )
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_username(tracked_requests):
     class DummyBackend(AuthenticationBackend):
         async def authenticate(self, request):
@@ -428,7 +428,7 @@ async def test_username(tracked_requests):
     assert tracked_request.tags["username"] == "dummy"
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_username_bad_user(tracked_requests):
     class BadUserBackend(AuthenticationBackend):
         async def authenticate(self, request):
@@ -449,7 +449,7 @@ async def test_username_bad_user(tracked_requests):
     assert "username" not in tracked_request.tags
 
 
-@async_test
+@pytest.mark.asyncio
 async def test_instance_app(tracked_requests):
     with app_with_scout() as app:
         communicator = ApplicationCommunicator(
diff --git a/tests/tools.py b/tests/tools.py
index 3b8cc82..607c777 100644
--- a/tests/tools.py
+++ b/tests/tools.py
@@ -1,7 +1,6 @@
 # coding=utf-8
 from __future__ import absolute_import, division, print_function, unicode_literals
 
-import functools
 import sys
 from contextlib import contextmanager
 
@@ -72,27 +71,6 @@ def n_plus_one_thresholds(count=None, duration=None):
         yield
 
 
-def async_test(func):
-    """
-    Wrap async_to_sync with another function because Pytest complains about
-    collecting the resulting callable object as a test because it's not a true
-    function:
-
-    PytestCollectionWarning: cannot collect 'test_foo' because it is not a
-    function.
-    """
-    # inner import because for Python 3.6+ tests only
-    from asgiref.sync import async_to_sync
-
-    sync_func = async_to_sync(func)
-
-    @functools.wraps(func)
-    def wrapper(*args, **kwargs):
-        return sync_func(*args, **kwargs)
-
-    return wrapper
-
-
 def asgi_http_scope(headers=None, **kwargs):
     if headers is None:
         headers = {}