Repository: scoutapp/scout_apm_python
Pull request #665 (2021-07-20 21:09:12)

Convert objtrace to use PEP 489 multi-phase initialization.
This does not convert the thread specific counters into module
state structs.

References #661


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0ce4c41..47a2526 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -13,7 +13,7 @@ repos:
   - id: end-of-file-fixer
   - id: trailing-whitespace
 - repo: https://github.com/psf/black
-  rev: 21.7b0
+  rev: 21.8b0
   hooks:
   - id: black
     language_version: python3
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7b7cc7..607adb3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,11 +2,31 @@
 
 ## Pending
 
+### Added
+
+### Fixed
+
+## [2.22.1] 2021-08-19
+
+### Added
+- Prevent a TrackedRequest from being sent more than once. Additional
+  logging was added to identify cases when this is occurring.
+
+### Fixed
+- Define TrackedRequest ContextVar at module level and ignore tokens.
+  ([Issue #675](https://github.com/scoutapp/scout_apm_python/issues/675))
+
+
+## [2.22.0] 2021-08-12
+
 ### Added
 - Added `terms_enum` instrumentation for ElasticSearch 7.14.0.
   ([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.
+- Support instrumenting Jinja v3 ``render_async`` function.
+  ([Issue #646](https://github.com/scoutapp/scout_apm_python/issues/646))
 
 ## [2.21.1] 2021-07-27
 
diff --git a/setup.py b/setup.py
index 5658337..0a8b25d 100644
--- a/setup.py
+++ b/setup.py
@@ -36,7 +36,7 @@ else:
 
 setup(
     name="scout_apm",
-    version="2.21.1",
+    version="2.22.1",
     description="Scout Application Performance Monitoring Agent",
     long_description=long_description,
     long_description_content_type="text/markdown",
@@ -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/_objtrace.c b/src/scout_apm/core/_objtrace.c
index 9177fd0..c2bccb4 100644
--- a/src/scout_apm/core/_objtrace.c
+++ b/src/scout_apm/core/_objtrace.c
@@ -4,8 +4,24 @@
 
 #define VERSION "1.0.0"
 
+#if PY_VERSION_HEX >= 0x03050000
+// Config state
+typedef struct {
+    int enabled;
+    int installed;
+} _objtrace_config_state;
+
+static inline _objtrace_config_state*
+get_objtrace_config_state(PyObject *module)
+{
+    void *state = PyModule_GetState(module);
+    assert(state != NULL);
+    return (_objtrace_config_state *)state;
+}
+#else
 static int enabled = 0;
 static int installed = 0;
+#endif
 
 #if PY_VERSION_HEX >= 0x03050000
 #  define FAIL_CALLOC
@@ -91,22 +107,40 @@ static void remove_hooks(void)
 static PyObject*
 objtrace_enable(PyObject *module)
 {
+#if PY_VERSION_HEX >= 0x03050000
+    _objtrace_config_state *state = get_objtrace_config_state(module);
+    state->enabled = 1;
+    if (!state->installed) {
+        state->installed = 1;
+        setup_hooks();
+    }
+#else
     enabled = 1;
     if (!installed) {
         installed = 1;
         setup_hooks();
     }
+#endif
     Py_RETURN_NONE;
 }
 
 static PyObject*
 objtrace_disable(PyObject *module)
 {
+#if PY_VERSION_HEX >= 0x03050000
+    _objtrace_config_state *state = get_objtrace_config_state(module);
+    if (state->installed) {
+        state->installed = 0;
+        remove_hooks();
+    }
+    state->enabled = 0;
+#else
     if (installed) {
         installed = 0;
         remove_hooks();
     }
     enabled = 0;
+#endif
     Py_RETURN_NONE;
 }
 
@@ -146,13 +180,39 @@ static PyMethodDef module_methods[] = {
     {NULL, NULL}  /* sentinel */
 };
 
-static struct PyModuleDef module_def = {
+#if PY_VERSION_HEX >= 0x03050000
+static int
+objtrace_mod_exec(PyObject* module)
+{
+    PyObject *version;
+    version = PyUnicode_FromString(VERSION);
+    if (version == NULL)
+        return -1;
+
+    return PyModule_AddObject(module, "__version__", version);
+}
+
+static PyModuleDef_Slot _objtrace_slots[] = {
+    {Py_mod_exec, objtrace_mod_exec},
+    {0, NULL}
+};
+#endif
+
+static struct PyModuleDef _objtrace_module = {
     PyModuleDef_HEAD_INIT,
     "scout_apm.core._objtrace",
     module_doc,
-    0, /* non-negative size to be able to unload the module */
+#if PY_VERSION_HEX >= 0x03050000
+    sizeof(_objtrace_config_state), /* non-negative size to be able to unload the module */
+#else
+    0,
+#endif
     module_methods,
+#if PY_VERSION_HEX >= 0x03050000
+    _objtrace_slots,
+#else
     NULL,
+#endif
     NULL,
     NULL,
     NULL
@@ -161,9 +221,12 @@ static struct PyModuleDef module_def = {
 PyMODINIT_FUNC
 PyInit__objtrace(void)
 {
+#if PY_VERSION_HEX >= 0x03050000
+    return PyModuleDef_Init(&_objtrace_module);
+#else
     PyObject *m, *version;
 
-    m = PyModule_Create(&module_def);
+    m = PyModule_Create(&_objtrace_module);
     if (m == NULL)
         return NULL;
 
@@ -173,4 +236,5 @@ PyInit__objtrace(void)
 
     PyModule_AddObject(m, "__version__", version);
     return m;
+#endif
 }
diff --git a/src/scout_apm/core/context.py b/src/scout_apm/core/context.py
index 72d44af..1c34443 100644
--- a/src/scout_apm/core/context.py
+++ b/src/scout_apm/core/context.py
@@ -18,6 +18,14 @@ try:
 except ImportError:
     asyncio = None
 
+try:
+    from contextvars import ContextVar
+
+    scout_context_var = ContextVar("__scout_trackedrequest")
+except ImportError:
+    scout_context_var = None
+
+
 SCOUT_REQUEST_ATTR = "__scout_trackedrequest"
 
 
@@ -112,18 +120,22 @@ class LocalContext(object):
             self._local = SimplifiedAsgirefLocal()
         else:
             self._local = ThreadLocal()
+        self.use_context_var = scout_context_var is not None
 
     def get_tracked_request(self):
-        task = get_current_asyncio_task()
+        if scout_context_var:
+            if not scout_context_var.get(None):
+                scout_context_var.set(TrackedRequest())
+            return scout_context_var.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 scout_context_var and scout_context_var.get(None) is instance:
+            scout_context_var.set(None)
 
 
 context = LocalContext()
diff --git a/src/scout_apm/core/tracked_request.py b/src/scout_apm/core/tracked_request.py
index 3e6a403..cd6c6fe 100644
--- a/src/scout_apm/core/tracked_request.py
+++ b/src/scout_apm/core/tracked_request.py
@@ -34,6 +34,7 @@ class TrackedRequest(object):
         "_memory_start",
         "n_plus_one_tracker",
         "hit_max",
+        "sent",
     )
 
     # Stop adding new spans at this point, to avoid exhausting memory
@@ -56,6 +57,7 @@ class TrackedRequest(object):
         self._memory_start = get_rss_in_mb()
         self.n_plus_one_tracker = NPlusOneTracker()
         self.hit_max = False
+        self.sent = False
         logger.debug("Starting request: %s", self.request_id)
 
     def __repr__(self):
@@ -139,12 +141,17 @@ class TrackedRequest(object):
 
     # Request is done, release any info we have about it.
     def finish(self):
+        from scout_apm.core.context import context
+
         logger.debug("Stopping request: %s", self.request_id)
         if self.end_time is None:
             self.end_time = dt.datetime.utcnow()
+
         if self.is_real_request:
             self.tag("mem_delta", self._get_mem_delta())
-            if not self.is_ignored():
+            if not self.is_ignored() and not self.sent:
+                self.sent = True
+                logger.debug("Sending request: %s", self.request_id)
                 batch_command = BatchCommand.from_tracked_request(self)
                 CoreAgentSocketThread.send(batch_command)
             SamplersThread.ensure_started()
@@ -160,12 +167,10 @@ class TrackedRequest(object):
                 ("tags", len(self.tags)),
                 ("hit_max", self.hit_max),
                 ("is_real_request", self.is_real_request),
+                ("sent", self.sent),
             ]
         )
         logger.debug("Request %s %s", self.request_id, details)
-
-        from scout_apm.core.context import context
-
         context.clear_tracked_request(self)
 
     def _get_mem_delta(self):
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/src/scout_apm/instruments/jinja2.py b/src/scout_apm/instruments/jinja2.py
index 31970d5..042f2d6 100644
--- a/src/scout_apm/instruments/jinja2.py
+++ b/src/scout_apm/instruments/jinja2.py
@@ -34,7 +34,6 @@ have_patched_template_render_async = False
 
 
 def ensure_installed():
-    global have_patched_environment_init
     global have_patched_template_render
 
     logger.debug("Instrumenting Jinja2.")
@@ -43,9 +42,31 @@ def ensure_installed():
         logger.debug("Couldn't import jinja2.Template - probably not installed.")
         return
 
-    if not have_patched_environment_init:
+    instrument_render_async()
+
+    if not have_patched_template_render:
+        try:
+            Template.render = wrapped_render(Template.render)
+        except Exception as exc:
+            logger.warning(
+                "Failed to instrument jinja2.Template.render: %r", exc, exc_info=exc
+            )
+        else:
+            have_patched_template_render = True
+
+
+def instrument_render_async():
+    global have_patched_environment_init
+    global have_patched_template_render_async
+
+    if wrapped_render_async is None:
+        return
+
+    if not have_patched_environment_init and not hasattr(Template, "render_async"):
         try:
-            Environment.__init__ = wrapped_environment_init(Environment.__init__)
+            Environment.__init__ = wrapped_environment_init_jinja_v2(
+                Environment.__init__
+            )
         except Exception as exc:
             logger.warning(
                 "Failed to instrument jinja2.Environment.__init__: %r",
@@ -54,16 +75,17 @@ def ensure_installed():
             )
         else:
             have_patched_environment_init = True
-
-    if not have_patched_template_render:
+    elif hasattr(Template, "render_async") and not have_patched_template_render_async:
         try:
-            Template.render = wrapped_render(Template.render)
+            Template.render_async = wrapped_render_async(Template.render_async)
         except Exception as exc:
             logger.warning(
-                "Failed to instrument jinja2.Template.render: %r", exc, exc_info=exc
+                "Failed to instrument jinja2.Template.render_async: %r",
+                exc,
+                exc_info=exc,
             )
         else:
-            have_patched_template_render = True
+            have_patched_template_render_async = True
 
 
 @wrapt.decorator
@@ -75,12 +97,14 @@ def wrapped_render(wrapped, instance, args, kwargs):
 
 
 @wrapt.decorator
-def wrapped_environment_init(wrapped, instance, args, kwargs):
+def wrapped_environment_init_jinja_v2(wrapped, instance, args, kwargs):
     """
     Delayed wrapping of render_async(), since Template won't have this method
     until after jinja2.asyncsupport is imported, which since Jinja2 2.11.0 is
     done conditionally in Environment.__init__:
     https://github.com/pallets/jinja/issues/765
+
+    This is no longer needed since Jinja2 v3.0.0
     """
     global have_patched_template_render_async
     result = wrapped(*args, **kwargs)
diff --git a/tox.ini b/tox.ini
index e799e7a..ae6a53e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -51,7 +51,7 @@ deps =
     hug>=2.5.1 ; python_version >= "3.5"
     httpretty<1 ; python_version < "3.5"
     httpretty!=1.1.1 ; python_version >= "3.5"
-    jinja2<3.0.0
+    jinja2
     ; nameko tests temporarily disabled
     ; Due to bad pinning of kombu only a past nameko version can be installed
     ; alongside the latest celery.
@@ -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/compat.py b/tests/compat.py
index a0598d3..d3139d2 100644
--- a/tests/compat.py
+++ b/tests/compat.py
@@ -73,4 +73,17 @@ else:
             return f.read()
 
 
-__all__ = ["gzip_decompress", "mock", "nullcontext", "suppress", "TemporaryDirectory"]
+try:
+    from contextvars import copy_context
+except ImportError:
+    copy_context = None
+
+
+__all__ = [
+    "gzip_decompress",
+    "mock",
+    "nullcontext",
+    "suppress",
+    "TemporaryDirectory",
+    "copy_context",
+]
diff --git a/tests/conftest.py b/tests/conftest.py
index b350e3e..216352a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,6 +15,7 @@ from scout_apm.core import error_service as scout_apm_core_error_service
 from scout_apm.core.agent import socket as scout_apm_core_socket
 from scout_apm.core.agent.manager import CoreAgentManager
 from scout_apm.core.config import SCOUT_PYTHON_VALUES, scout_config
+from scout_apm.core.context import scout_context_var
 from scout_apm.core.error_service import ErrorServiceThread
 from scout_apm.core.tracked_request import TrackedRequest
 from tests.compat import TemporaryDirectory
@@ -122,24 +123,23 @@ def isolate_global_state():
         if SCOUT_PYTHON_VALUES:
             raise ConfigLeak("Python config changes: %r" % SCOUT_PYTHON_VALUES)
 
+        request = None
         try:
             request = TrackedRequest._thread_lookup.instance
         except AttributeError:
             pass
-        else:
-            if request is not None:
-                raise TrackedRequestLeak(
-                    "Unfinished request: "
-                    "active spans = %r, complete spans = %r, tags = %r"
-                    % (
-                        [(span.operation, span.tags) for span in request.active_spans],
-                        [
-                            (span.operation, span.tags)
-                            for span in request.complete_spans
-                        ],
-                        request.tags,
-                    )
+        if request is None and scout_context_var:
+            request = scout_context_var.get(None)
+        if request is not None:
+            raise TrackedRequestLeak(
+                "Unfinished request: "
+                "active spans = %r, complete spans = %r, tags = %r"
+                % (
+                    [(span.operation, span.tags) for span in request.active_spans],
+                    [(span.operation, span.tags) for span in request.complete_spans],
+                    request.tags,
                 )
+            )
 
 
 @pytest.fixture(autouse=True, scope="session")
diff --git a/tests/integration/instruments/test_asyncio_py36plus.py b/tests/integration/instruments/test_asyncio_py36plus.py
index a2ee15e..7af993c 100644
--- a/tests/integration/instruments/test_asyncio_py36plus.py
+++ b/tests/integration/instruments/test_asyncio_py36plus.py
@@ -1,15 +1,23 @@
 # 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 sys
 
 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
+from scout_apm.core.tracked_request import TrackedRequest
 
 
 def create_task(coro):
@@ -27,7 +35,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 +68,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 +77,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 +102,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 +117,6 @@ async def test_nested_tasks(tracked_request):
             coro("2b"),
         )
 
-    ensure_installed()
     with BackgroundTransaction("test"):
         await orchestrator()
 
@@ -160,15 +137,17 @@ 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))
 
+    assert len(tracked_requests) == 1
     assert len(tracked_request.complete_spans) == 2
     span = tracked_request.complete_spans[0]
     assert span.operation == "Custom/coro"
@@ -177,26 +156,40 @@ 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 tracked_request.complete_spans[0].tags["value"] == "short"
+    assert tracked_request.complete_spans[1].operation == "Job/test"
 
+    assert tracked_requests[0].request_id == tracked_request.request_id
 
-@async_test
-async def test_task_cancelled(tracked_request):
-    ensure_installed()
+    if sys.version_info[:2] < (3, 7):
+        # Python 3.6 utilizes the contextvars backport which functions
+        # slightly differently than the contextvars stdlib implementation
+        # in 3.7+
+        assert len(tracked_request.complete_spans) == 2
+        assert len(tracked_requests) == 1
+    else:
+        assert len(tracked_request.complete_spans) == 3
+        assert tracked_request.complete_spans[2].tags["value"] == "long"
+        # When we await long, it will call finish again which adds it
+        # to tracked_requests
+        assert len(tracked_requests) == 2
+        assert tracked_requests[0] is tracked_requests[1]
+
+
+@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 +199,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,30 +217,41 @@ 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),
             coro(wait=0.5),
+            coro(wait=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"}
 
+    if sys.version_info[:2] < (3, 7):
+        # Python 3.6 utilizes the contextvars backport which functions
+        # slightly differently than the contextvars stdlib implementation
+        # in 3.7+
+        assert len(tracked_request.complete_spans) == 1
+        assert len(tracked_requests) == 1
+    else:
+        assert len(tracked_request.complete_spans) == 5
+        # When the last awaitable completes, it will call finish again which
+        # adds it to tracked_requests
+        assert len(tracked_requests) == 2
+        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 +259,137 @@ 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_with_context_copied_and_active_span(
+    tracked_requests, tracked_request
+):
+    # Start an outer span to prevent the tracked_request from finishing.
+    tracked_request.start_span("outer")
+
+    def inner_func():
+        with BackgroundTransaction("test"):
+            pass
+
+    # Run inner_func
+    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)
+    # Verify finish wasn't called
+    assert len(tracked_requests) == 0
+    assert len(tracked_request.complete_spans) == 1
+
+    tracked_request.stop_span()
+
+    assert tracked_requests[0].request_id == tracked_request.request_id
+    assert len(tracked_requests[0].complete_spans) == 2
+
+
+@pytest.mark.asyncio
+async def test_run_in_executor_with_context_copied_escalates_ignored(tracked_request):
+    # Start an outer span to prevent the tracked_request from finishing.
+    tracked_request.start_span("outer")
+
+    def inner_func():
+        with BackgroundTransaction("test"):
+            TrackedRequest.instance().tag("ignore_transaction", True)
+
+    # Run inner_func
+    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)
+    tracked_request.stop_span()
+    assert tracked_request.is_ignored()
+
+
+@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
+
+
+@pytest.mark.asyncio
+async def test_run_in_executor_without_context_copied_and_active_span(
+    tracked_requests, tracked_request
+):
+    # Start an outer span to prevent the tracked_request from finishing.
+    tracked_request.start_span("outer")
+
+    def inner_func():
+        with BackgroundTransaction("test"):
+            pass
+
+    # Run inner_func
+    loop = asyncio.get_event_loop()
+    func = functools.partial(inner_func)
+    await loop.run_in_executor(None, func)
+    # Verify finish wasn't called
+    assert len(tracked_requests) == 1
+    assert len(tracked_requests[0].complete_spans) == 1
+    assert len(tracked_request.complete_spans) == 0
+
+    tracked_request.stop_span()
+
+    assert tracked_requests[0].request_id != tracked_request.request_id
+    assert len(tracked_request.complete_spans) == 1
+
+
+@pytest.mark.asyncio
+async def test_run_in_executor_without_context_copied_escalates_ignored(
+    tracked_request,
+):
+    # Start an outer span to prevent the tracked_request from finishing.
+    tracked_request.start_span("outer")
+
+    def inner_func():
+        with BackgroundTransaction("test"):
+            TrackedRequest.instance().tag("ignore_transaction", True)
+
+    # Run inner_func
+    loop = asyncio.get_event_loop()
+    func = functools.partial(inner_func)
+    await loop.run_in_executor(None, func)
+
+    tracked_request.stop_span()
+
+    assert not tracked_request.is_ignored()
diff --git a/tests/integration/instruments/test_jinja2_py36plus.py b/tests/integration/instruments/test_jinja2_py36plus.py
index abb5e65..da411f0 100644
--- a/tests/integration/instruments/test_jinja2_py36plus.py
+++ b/tests/integration/instruments/test_jinja2_py36plus.py
@@ -4,14 +4,14 @@ 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):
+def test_ensure_installed_render_async_v2(caplog):
     mock_template = mock.patch("scout_apm.instruments.jinja2.Template")
     with mock_not_attempted(), mock_template as mocked_template:
         del mocked_template.render_async
@@ -29,7 +29,30 @@ def test_ensure_installed_fail_no_render_async_attribute(caplog):
     ]
 
 
-@async_test
+def test_ensure_installed_fail_wrap_render_async(caplog):
+    mock_wrap_render_async = mock.patch(
+        "scout_apm.instruments.jinja2.wrapped_render_async"
+    )
+    with mock_not_attempted(), mock_wrap_render_async as mocked_wrap_render_async:
+        mocked_wrap_render_async.side_effect = ValueError("Boom!")
+        ensure_installed()
+
+    assert len(caplog.record_tuples) == 2
+    assert caplog.record_tuples[0] == (
+        "scout_apm.instruments.jinja2",
+        logging.DEBUG,
+        "Instrumenting Jinja2.",
+    )
+
+    logger, level, message = caplog.record_tuples[1]
+    assert logger == "scout_apm.instruments.jinja2"
+    assert level == logging.WARNING
+    assert message.startswith(
+        "Failed to instrument jinja2.Template.render_async: ValueError"
+    )
+
+
+@pytest.mark.asyncio
 async def test_async_render(tracked_request):
     ensure_installed()
     template = jinja2.Template("Hello {{ name }}!", enable_async=True)
@@ -43,7 +66,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..817d80f 100644
--- a/tests/tools.py
+++ b/tests/tools.py
@@ -1,10 +1,14 @@
 # coding=utf-8
 from __future__ import absolute_import, division, print_function, unicode_literals
 
-import functools
 import sys
 from contextlib import contextmanager
 
+try:
+    from contextvars import ContextVar
+except ImportError:
+    ContextVar = None
+
 import pytest
 
 from scout_apm.core import objtrace
@@ -25,6 +29,10 @@ skip_if_objtrace_is_extension = pytest.mark.skipif(
     objtrace.is_extension, reason="Requires no objtrace C extension"
 )
 
+skip_if_missing_context_vars = pytest.mark.skipif(
+    ContextVar is None, reason="Requires ContextVars"
+)
+
 
 @contextmanager
 def delete_attributes(obj, *attributes):
@@ -72,27 +80,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 = {}
diff --git a/tests/unit/core/test_tracked_request.py b/tests/unit/core/test_tracked_request.py
index 31d8f12..3742faf 100644
--- a/tests/unit/core/test_tracked_request.py
+++ b/tests/unit/core/test_tracked_request.py
@@ -7,8 +7,12 @@ import sys
 
 from scout_apm.core import objtrace
 from scout_apm.core.tracked_request import TrackedRequest
-from tests.compat import mock
-from tests.tools import skip_if_objtrace_is_extension, skip_if_objtrace_not_extension
+from tests.compat import copy_context, mock
+from tests.tools import (
+    skip_if_missing_context_vars,
+    skip_if_objtrace_is_extension,
+    skip_if_objtrace_not_extension,
+)
 
 
 def test_tracked_request_repr(tracked_request):
@@ -25,6 +29,17 @@ def test_tracked_request_instance_is_a_singleton():
         tracked_request_2.finish()
 
 
+@skip_if_missing_context_vars
+def test_tracked_request_copied_into_new_context(tracked_request):
+    ctx = copy_context()
+
+    def generate_new_tracked_request(existing_tracked_request):
+        new_tracked_request = TrackedRequest.instance()
+        assert existing_tracked_request is new_tracked_request
+
+    ctx.run(generate_new_tracked_request, tracked_request)
+
+
 def test_is_real_request_default_false(tracked_request):
     assert not tracked_request.is_real_request
 
@@ -186,10 +201,9 @@ def test_finish_does_captures_memory_on_real_requests(tracked_request):
 
 
 def test_finish_log_request_info(tracked_request, caplog):
+    tracked_request.is_real_request = True
     tracked_request.start_span(operation="Something")
     tracked_request.stop_span()
-    tracked_request.is_real_request = True
-    tracked_request.finish()
 
     assert (
         "scout_apm.core.tracked_request",
@@ -197,6 +211,12 @@ def test_finish_log_request_info(tracked_request, caplog):
         "Stopping request: {}".format(tracked_request.request_id),
     ) in caplog.record_tuples
 
+    assert (
+        "scout_apm.core.tracked_request",
+        logging.DEBUG,
+        "Sending request: {}".format(tracked_request.request_id),
+    ) in caplog.record_tuples
+
     assert (
         "scout_apm.core.tracked_request",
         logging.DEBUG,
@@ -211,11 +231,29 @@ def test_finish_log_request_info(tracked_request, caplog):
             + "complete_spans=1 "
             + "tags=1 "
             + "hit_max=False "
-            + "is_real_request=True"
+            + "is_real_request=True "
+            + "sent=True"
         ),
     ) in caplog.record_tuples
 
 
+def test_finish_clears_context():
+    tracked_request_1 = TrackedRequest.instance()
+    tracked_request_1.is_real_request = True
+    tracked_request_2 = TrackedRequest.instance()
+    try:
+        assert tracked_request_2 is tracked_request_1
+    finally:
+        tracked_request_1.finish()
+        tracked_request_2.finish()
+
+    tracked_request_3 = TrackedRequest.instance()
+    try:
+        assert tracked_request_3 is not tracked_request_2
+    finally:
+        tracked_request_3.finish()
+
+
 def test_is_ignored_default_false(tracked_request):
     assert not tracked_request.is_ignored()
 
@@ -223,3 +261,54 @@ def test_is_ignored_default_false(tracked_request):
 def test_is_ignored_mark_true(tracked_request):
     tracked_request.tag("ignore_transaction", True)
     assert tracked_request.is_ignored()
+
+
+def test_request_only_sent_once(tracked_request, caplog):
+    tracked_request.is_real_request = True
+    tracked_request.start_span(operation="Something")
+    tracked_request.stop_span()
+
+    assert tracked_request.sent
+
+    sent_log = (
+        "scout_apm.core.tracked_request",
+        logging.DEBUG,
+        "Sending request: {}".format(tracked_request.request_id),
+    )
+    info_log = (
+        "scout_apm.core.tracked_request",
+        logging.DEBUG,
+        (
+            "Request {} ".format(tracked_request.request_id)
+            + "start_time={} ".format(tracked_request.start_time)
+            + "end_time={} ".format(tracked_request.end_time)
+            + "duration={} ".format(
+                (tracked_request.end_time - tracked_request.start_time).total_seconds()
+            )
+            + "active_spans=0 "
+            + "complete_spans=1 "
+            + "tags=1 "
+            + "hit_max=False "
+            + "is_real_request=True "
+            + "sent=True"
+        ),
+    )
+    assert (
+        len([log_tuple for log_tuple in caplog.record_tuples if log_tuple == sent_log])
+        == 1
+    )
+    assert (
+        len([log_tuple for log_tuple in caplog.record_tuples if log_tuple == info_log])
+        == 1
+    )
+
+    # Call finish again.
+    tracked_request.finish()
+    assert (
+        len([log_tuple for log_tuple in caplog.record_tuples if log_tuple == sent_log])
+        == 1
+    )
+    assert (
+        len([log_tuple for log_tuple in caplog.record_tuples if log_tuple == info_log])
+        == 2
+    )