Repository: scoutapp/scout_apm_python
Pull request #638 (2021-03-26 15:23:46)

Instrument asyncio for python 3.6+
Passes the tracked request via an attribute on the current task for LocalContext to use.

Only instruments basic coroutines and tasks.

Closes scoutapp/scout_apm_python#469


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2bd468..0e7f7d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,12 +3,20 @@
 ## Pending
 
 ### Added
+
 - Tested with Django 3.2. Only define ``default_app_config`` when using
   a version of Django earlier than 3.2.
+- Support instrumentation and transaction decorators for asynchronous
+  functions via ``@instrument.async_``, ``@WebTransaction.async_`` and
+  ``@BackgroundTransaction.async_``.
+  ([PR #633](https://github.com/scoutapp/scout_apm_python/issues/633))
 
 ### Fixed
+
 - Exclude python library paths from backtraces.
   ([PR #514](https://github.com/scoutapp/scout_apm_python/issues/514))
+- Preserve tracked request across asyncio tasks.
+  ([PR #469](https://github.com/scoutapp/scout_apm_python/issues/469))
 
 ## [2.18.0] 2021-02-09
 
diff --git a/src/scout_apm/api/__init__.py b/src/scout_apm/api/__init__.py
index 5294c1e..2fc44b1 100644
--- a/src/scout_apm/api/__init__.py
+++ b/src/scout_apm/api/__init__.py
@@ -6,6 +6,15 @@ from scout_apm.compat import ContextDecorator, text
 from scout_apm.core.config import ScoutConfig
 from scout_apm.core.tracked_request import TrackedRequest
 
+# The async_ module can only be shipped on Python 3.6+
+try:
+    from scout_apm.async_.api import AsyncDecoratorMixin
+except ImportError:
+
+    class AsyncDecoratorMixin(object):
+        pass
+
+
 __all__ = [
     "BackgroundTransaction",
     "Config",
@@ -41,7 +50,7 @@ def ignore_transaction():
     TrackedRequest.instance().tag("ignore_transaction", True)
 
 
-class instrument(ContextDecorator):
+class instrument(AsyncDecoratorMixin, ContextDecorator):
     def __init__(self, operation, kind="Custom", tags=None):
         self.operation = text(kind) + "/" + text(operation)
         if tags is None:
@@ -66,7 +75,7 @@ class instrument(ContextDecorator):
             self.span.tag(key, value)
 
 
-class Transaction(ContextDecorator):
+class Transaction(AsyncDecoratorMixin, ContextDecorator):
     """
     This Class is not meant to be used directly.
     Use one of the subclasses
diff --git a/src/scout_apm/async_/api.py b/src/scout_apm/async_/api.py
new file mode 100644
index 0000000..b6bd623
--- /dev/null
+++ b/src/scout_apm/async_/api.py
@@ -0,0 +1,42 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+from functools import wraps
+
+
+class AsyncDecoratorMixin(object):
+    """Provide the ability to decorate both sync and async functions."""
+
+    is_async = False
+
+    @classmethod
+    def async_(cls, operation, tags=None, **kwargs):
+        """
+        Instrument an async function via a decorator.
+
+        This will return an awaitable which must be awaited.
+        Using this on a synchronous function will raise a
+        RuntimeError.
+
+        ``
+           @instrument.async_("Foo")
+           async def foo():
+              ...
+        ``
+        """
+        instance = cls(operation, tags=tags, **kwargs)
+        instance.is_async = True
+        return instance
+
+    def __call__(self, func):
+        if self.is_async:
+            # Until https://bugs.python.org/issue37398 has a resolution,
+            # manually wrap the async function
+            @wraps(func)
+            async def decorated(*args, **kwds):
+                with self._recreate_cm():
+                    return await func(*args, **kwds)
+
+            return decorated
+        else:
+            return super().__call__(func)
diff --git a/src/scout_apm/async_/starlette.py b/src/scout_apm/async_/starlette.py
index 6a3cdf4..8189c7e 100644
--- a/src/scout_apm/async_/starlette.py
+++ b/src/scout_apm/async_/starlette.py
@@ -22,6 +22,10 @@ class ScoutMiddleware:
             return await self.app(scope, receive, send)
 
         tracked_request = TrackedRequest.instance()
+        # Assume the request is real until we determine it's not. This is useful
+        # when the asyncio instrumentation is determining if a new Task should
+        # reuse the existing tracked request.
+        tracked_request.is_real_request = True
         # Can't name controller until post-routing - see final clause
         controller_span = tracked_request.start_span(operation="Controller/Unknown")
 
@@ -37,7 +41,9 @@ class ScoutMiddleware:
                     endpoint.__module__,
                     endpoint.__qualname__,
                 )
-                tracked_request.is_real_request = True
+            else:
+                # Mark the request as not real
+                tracked_request.is_real_request = False
 
             # From AuthenticationMiddleware - bypass request.user because it
             # throws AssertionError if 'user' is not in Scope, and we need a
diff --git a/src/scout_apm/compat.py b/src/scout_apm/compat.py
index 1edef4c..6d96b82 100644
--- a/src/scout_apm/compat.py
+++ b/src/scout_apm/compat.py
@@ -34,13 +34,15 @@ else:
 if sys.version_info >= (3, 2):
     from contextlib import ContextDecorator
 else:
-    import functools
 
     class ContextDecorator(object):
+        def _recreate_cm(self):
+            return self
+
         def __call__(self, f):
-            @functools.wraps(f)
+            @wraps(f)
             def decorated(*args, **kwds):
-                with self:
+                with self._recreate_cm():
                     return f(*args, **kwds)
 
             return decorated
diff --git a/src/scout_apm/core/context.py b/src/scout_apm/core/context.py
index 01e96b6..72d44af 100644
--- a/src/scout_apm/core/context.py
+++ b/src/scout_apm/core/context.py
@@ -18,21 +18,24 @@ try:
 except ImportError:
     asyncio = None
 
+SCOUT_REQUEST_ATTR = "__scout_trackedrequest"
+
 
 def get_current_asyncio_task():
     """
     Cross-version implementation of asyncio.current_task()
     Returns None if there is no task.
     """
-    try:
-        if hasattr(asyncio, "current_task"):
-            # Python 3.7 and up
-            return asyncio.current_task()
-        else:
-            # Python 3.6
-            return asyncio.Task.current_task()
-    except RuntimeError:
-        return None
+    if asyncio:
+        try:
+            if hasattr(asyncio, "current_task"):
+                # Python 3.7 and up
+                return asyncio.current_task()
+            else:
+                # Python 3.6
+                return asyncio.Task.current_task()
+        except RuntimeError:
+            return None
 
 
 class SimplifiedAsgirefLocal:
@@ -111,8 +114,11 @@ class LocalContext(object):
             self._local = ThreadLocal()
 
     def get_tracked_request(self):
+        task = get_current_asyncio_task()
         if not hasattr(self._local, "tracked_request"):
-            self._local.tracked_request = TrackedRequest()
+            self._local.tracked_request = (
+                task and getattr(task, SCOUT_REQUEST_ATTR, None) or TrackedRequest()
+            )
         return self._local.tracked_request
 
     def clear_tracked_request(self, instance):
diff --git a/src/scout_apm/instruments/__init__.py b/src/scout_apm/instruments/__init__.py
index 0b871a3..5d0c7c9 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 = ["elasticsearch", "jinja2", "pymongo", "redis", "urllib3"]
+instrument_names = ["asyncio", "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
new file mode 100644
index 0000000..48541c4
--- /dev/null
+++ b/src/scout_apm/instruments/asyncio.py
@@ -0,0 +1,65 @@
+# 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


Test patch:
diff --git a/tests/integration/instruments/test_asyncio_py36plus.py b/tests/integration/instruments/test_asyncio_py36plus.py
new file mode 100644
index 0000000..5a19483
--- /dev/null
+++ b/tests/integration/instruments/test_asyncio_py36plus.py
@@ -0,0 +1,258 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import asyncio
+import logging
+
+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):
+    """Provide interface to create_task regardless of python version."""
+    if hasattr(asyncio, "create_task"):  # py37+
+        return asyncio.create_task(coro)
+    return asyncio.get_event_loop().create_task(coro)
+
+
+async def coro(value=None, wait=None, loop=None):
+    """Helper function for testing coroutines.
+
+    :value: This will be inserted into the ``tags`` of ``instrument``.
+    :wait: A float of seconds to be waited before calling ``instrument``.
+    :loop: EventLoop to be used if any.
+    :returns: nothing.
+    """
+
+    if wait:
+        await asyncio.sleep(wait, loop=loop)
+    tags = {"value": value} if value else None
+    with instrument("coro", tags=tags):
+        await asyncio.sleep(0.1, loop=loop)
+
+
+def get_future():
+    """Helper function to return a future for the current event loop."""
+    if hasattr(asyncio, "get_running_loop"):  # py37+
+        return asyncio.get_running_loop().create_future()
+    return asyncio.get_event_loop().create_future()
+
+
+async def set_future(fut, wait):
+    """Helper function to set a future after a set delay.
+
+    :fut: The future to be set.
+    :wait: A float of seconds to be waited.
+    :returns: nothing.
+    """
+    await asyncio.sleep(wait)
+    with instrument("set_future"):
+        fut.set_result("complete")
+
+
+async def resolving_future(wait):
+    """Get a future that will resolve eventually."""
+    fut = get_future()
+    create_task(set_future(fut, wait=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()
+    with BackgroundTransaction("test"):
+        await coro()
+
+    assert len(tracked_request.complete_spans) == 2
+    span = tracked_request.complete_spans[0]
+    assert span.operation == "Custom/coro"
+    assert tracked_request.complete_spans[1].operation == "Job/test"
+
+
+@async_test
+async def test_coroutine_timeout(tracked_request):
+    ensure_installed()
+    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"
+
+
+@async_test
+async def test_task_awaited(tracked_request):
+    ensure_installed()
+    with BackgroundTransaction("test"):
+        await create_task(coro())
+
+    assert len(tracked_request.complete_spans) == 2
+    span = tracked_request.complete_spans[0]
+    assert span.operation == "Custom/coro"
+    assert tracked_request.complete_spans[1].operation == "Job/test"
+
+
+@async_test
+async def test_nested_tasks(tracked_request):
+    @instrument.async_("orchestrator")
+    async def orchestrator():
+        await coro("1")
+        await asyncio.gather(
+            # Force this coroutine to finish later for idempotency
+            coro("2a", wait=0.5),
+            coro("2b"),
+        )
+
+    ensure_installed()
+    with BackgroundTransaction("test"):
+        await orchestrator()
+
+    spans = tracked_request.complete_spans
+    assert len(spans) == 5
+    assert [span.operation for span in spans] == [
+        "Custom/coro",
+        "Custom/coro",
+        "Custom/coro",
+        "Custom/orchestrator",
+        "Job/test",
+    ]
+    # Verify the order of the coroutines
+    assert [span.tags.get("value") for span in spans] == [
+        "1",
+        "2b",
+        "2a",
+        None,
+        None,
+    ]
+
+
+@async_test
+async def test_task_not_awaited(tracked_request):
+    ensure_installed()
+    with BackgroundTransaction("test"):
+        await create_task(coro("short"))
+        long = create_task(coro("long", wait=0.5))
+
+    assert len(tracked_request.complete_spans) == 2
+    span = tracked_request.complete_spans[0]
+    assert span.operation == "Custom/coro"
+    assert span.tags["value"] == "short"
+    assert tracked_request.complete_spans[1].operation == "Job/test"
+
+    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"
+
+
+@async_test
+async def test_task_cancelled(tracked_request):
+    ensure_installed()
+    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"
+
+
+@async_test
+async def test_future_awaited(tracked_request):
+    ensure_installed()
+    with BackgroundTransaction("test"):
+        fut = get_future()
+        create_task(set_future(fut, wait=0.5))
+        await fut
+        assert len(tracked_request.complete_spans) == 1
+        assert tracked_request.complete_spans[0].operation == "Custom/set_future"
+
+    assert len(tracked_request.complete_spans) == 2
+    assert tracked_request.complete_spans[1].operation == "Job/test"
+
+
+@async_test
+async def test_future_gathered(tracked_request):
+    ensure_installed()
+    with BackgroundTransaction("test"):
+        await asyncio.gather(
+            resolving_future(0.5),
+            coro(wait=0.5),
+        )
+        assert len(tracked_request.complete_spans) == 2
+        # gather can run the coroutines/futures in any order.
+        operations = {span.operation for span in tracked_request.complete_spans}
+        assert operations == {"Custom/set_future", "Custom/coro"}
+
+    assert len(tracked_request.complete_spans) == 3
+    assert tracked_request.complete_spans[2].operation == "Job/test"
+
+
+@async_test
+async def test_future_not_gathered(tracked_request):
+    ensure_installed()
+    with BackgroundTransaction("test"):
+        gather_future = asyncio.gather(
+            resolving_future(0.5),
+            coro(wait=0.5),
+        )
+
+    assert len(tracked_request.complete_spans) == 1
+    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"}
+
+
+@async_test
+async def test_future_cancelled(tracked_request):
+    ensure_installed()
+    with BackgroundTransaction("test"):
+        gather_future = asyncio.gather(
+            resolving_future(0.5),
+            coro(wait=0.5),
+        )
+        gather_future.cancel()
+
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Job/test"
diff --git a/tests/integration/test_api_py36plus.py b/tests/integration/test_api_py36plus.py
new file mode 100644
index 0000000..8464bf2
--- /dev/null
+++ b/tests/integration/test_api_py36plus.py
@@ -0,0 +1,248 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest
+
+from scout_apm.api import BackgroundTransaction, WebTransaction, instrument
+from tests.tools import async_test
+
+
+@async_test
+async def test_instrument_decorator_async(tracked_request):
+    @instrument.async_("Foo")
+    async def foo():
+        pass
+
+    @instrument.async_("Bar")
+    async def example():
+        await foo()
+
+    await example()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 2
+    assert tracked_request.complete_spans[0].operation == "Custom/Foo"
+    assert tracked_request.complete_spans[1].operation == "Custom/Bar"
+
+
+def test_instrument_decorator_async_for_sync_function(tracked_request):
+    @instrument.async_("Bar")
+    def example():
+        pass
+
+    with pytest.warns(RuntimeWarning):
+        example()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 0
+
+
+@async_test
+async def test_instrument_decorator_async_misconfigured(tracked_request):
+    """Test case where .async_ isn't used from parent instrument"""
+
+    @instrument.async_("Foo")
+    async def foo():
+        pass
+
+    @instrument("Bar")
+    async def example():
+        await foo()
+
+    await example()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Custom/Bar"
+
+
+@async_test
+async def test_instrument_decorator_async_classmethod(tracked_request):
+    class Example(object):
+        @classmethod
+        @instrument.async_("Test Decorator")
+        async def method(cls):
+            pass
+
+    await Example.method()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Custom/Test Decorator"
+
+
+@async_test
+async def test_instrument_decorator_async_staticmethod(tracked_request):
+    class Example(object):
+        @staticmethod
+        @instrument.async_("Test Decorator")
+        async def method():
+            pass
+
+    await Example.method()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Custom/Test Decorator"
+
+
+@async_test
+async def test_instrument_decorator_async_return_awaitable(tracked_request):
+    @instrument.async_("Foo")
+    async def foo():
+        pass
+
+    @instrument.async_("Bar")
+    def return_awaitable():
+        return foo()
+
+    await return_awaitable()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 2
+    assert tracked_request.complete_spans[0].operation == "Custom/Foo"
+    assert tracked_request.complete_spans[1].operation == "Custom/Bar"
+
+
+@async_test
+async def test_instrument_decorator_async_return_awaitable_misconfigured(
+    tracked_request,
+):
+    """Test case where .async_ isn't used from parent instrument"""
+
+    @instrument.async_("Foo")
+    async def foo():
+        pass
+
+    @instrument("Bar")
+    def return_awaitable():
+        return foo()
+
+    await return_awaitable()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Custom/Bar"
+
+
+@async_test
+async def test_instrument_context_manager_async_await_later(tracked_request):
+    """
+    Test proving that if an awaitable goes unawaited in a context manager,
+    the spans are lost.
+    """
+
+    @instrument.async_("Outer")
+    async def foo():
+        with instrument("Inner"):
+            pass
+
+    async def example():
+        await foo()
+
+    with instrument("Test Decorator"):
+        awaitable = example()
+
+    await awaitable
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Custom/Test Decorator"
+
+
+@async_test
+async def test_web_transaction_decorator_async(tracked_request):
+    @instrument.async_("Foo")
+    async def foo():
+        pass
+
+    @WebTransaction.async_("Bar")
+    async def my_transaction():
+        await foo()
+
+    await my_transaction()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 2
+    assert tracked_request.complete_spans[0].operation == "Custom/Foo"
+    assert tracked_request.complete_spans[1].operation == "Controller/Bar"
+
+
+@async_test
+async def test_web_transaction_decorator_async_misconfigured(tracked_request):
+    """Test case where .async_ isn't used from WebTransaction"""
+
+    @instrument.async_("Foo")
+    async def foo():
+        pass
+
+    @WebTransaction("Bar")
+    async def my_transaction():
+        await foo()
+
+    await my_transaction()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Controller/Bar"
+
+
+def test_web_transaction_decorator_async_for_sync_function(tracked_request):
+    @WebTransaction.async_("Bar")
+    def example():
+        pass
+
+    with pytest.warns(RuntimeWarning):
+        example()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 0
+
+
+@async_test
+async def test_background_transaction_decorator_async(tracked_request):
+    @instrument.async_("Foo")
+    async def foo():
+        pass
+
+    @BackgroundTransaction.async_("Bar")
+    async def my_transaction():
+        await foo()
+
+    await my_transaction()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 2
+    assert tracked_request.complete_spans[0].operation == "Custom/Foo"
+    assert tracked_request.complete_spans[1].operation == "Job/Bar"
+
+
+@async_test
+async def test_background_transaction_decorator_async_misconfigured(tracked_request):
+    """Test case where .async_ isn't used from BackgroundTransaction"""
+
+    @instrument.async_("Foo")
+    async def foo():
+        pass
+
+    @BackgroundTransaction("Bar")
+    async def my_transaction():
+        await foo()
+
+    await my_transaction()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Job/Bar"
+
+
+def test_background_transaction_decorator_async_for_sync_function(tracked_request):
+    @BackgroundTransaction.async_("Bar")
+    def example():
+        pass
+
+    with pytest.warns(RuntimeWarning):
+        example()
+
+    assert len(tracked_request.active_spans) == 0
+    assert len(tracked_request.complete_spans) == 0