Repository: scoutapp/scout_apm_python

Issue:
Test/instrument asynchronous Jinja2 rendering
Jinja2 supports an asynchronous render mode. We should add tests against it and if necessary  extend the existing instrumentation.


Discussion:

Resolving patch:
diff --git a/src/scout_apm/async_/instruments/__init__.py b/src/scout_apm/async_/instruments/__init__.py
new file mode 100644
--- /dev/null
+++ b/src/scout_apm/async_/instruments/__init__.py
@@ -0,0 +1,2 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
diff --git a/src/scout_apm/async_/instruments/jinja2.py b/src/scout_apm/async_/instruments/jinja2.py
new file mode 100644
--- /dev/null
+++ b/src/scout_apm/async_/instruments/jinja2.py
@@ -0,0 +1,17 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import wrapt
+
+from scout_apm.core.tracked_request import TrackedRequest
+
+
+@wrapt.decorator
+async def wrapped_render_async(wrapped, instance, args, kwargs):
+    tracked_request = TrackedRequest.instance()
+    span = tracked_request.start_span(operation="Template/Render")
+    span.tag("name", instance.name)
+    try:
+        return await wrapped(*args, **kwargs)
+    finally:
+        tracked_request.stop_span()
diff --git a/src/scout_apm/instruments/jinja2.py b/src/scout_apm/instruments/jinja2.py
--- a/src/scout_apm/instruments/jinja2.py
+++ b/src/scout_apm/instruments/jinja2.py
@@ -12,20 +12,31 @@
 except ImportError:  # pragma: no cover
     Template = None
 
+# The async_ module can only be shipped on Python 3.6+
+try:
+    from scout_apm.async_.instruments.jinja2 import wrapped_render_async
+except ImportError:
+    wrapped_render_async = None
+
+
 logger = logging.getLogger(__name__)
 
 
 have_patched_template_render = False
+have_patched_template_render_async = False
 
 
 def ensure_installed():
     global have_patched_template_render
+    global have_patched_template_render_async
 
     logger.info("Ensuring Jinja2 instrumentation is installed.")
 
     if Template is None:
         logger.info("Unable to import jinja2.Template")
-    elif not have_patched_template_render:
+        return
+
+    if not have_patched_template_render:
         try:
             Template.render = wrapped_render(Template.render)
         except Exception as exc:
@@ -35,6 +46,18 @@ def ensure_installed():
         else:
             have_patched_template_render = True
 
+    if not have_patched_template_render_async and wrapped_render_async is not None:
+        try:
+            Template.render_async = wrapped_render_async(Template.render_async)
+        except Exception as exc:
+            logger.warning(
+                "Unable to instrument jinja2.Template.render_async: %r",
+                exc,
+                exc_info=exc,
+            )
+        else:
+            have_patched_template_render_async = True
+
 
 @wrapt.decorator
 def wrapped_render(wrapped, instance, args, kwargs):


Test patch:
diff --git a/tests/integration/instruments/test_jinja2.py b/tests/integration/instruments/test_jinja2.py
--- a/tests/integration/instruments/test_jinja2.py
+++ b/tests/integration/instruments/test_jinja2.py
@@ -2,15 +2,24 @@
 from __future__ import absolute_import, division, print_function, unicode_literals
 
 import logging
+from contextlib import contextmanager
 
 import jinja2
 
 from scout_apm.instruments.jinja2 import ensure_installed
 from tests.compat import mock
 
-mock_not_attempted = mock.patch(
-    "scout_apm.instruments.jinja2.have_patched_template_render", new=False
-)
+
+@contextmanager
+def mock_not_attempted():
+    not_render = mock.patch(
+        "scout_apm.instruments.jinja2.have_patched_template_render", new=False
+    )
+    not_render_async = mock.patch(
+        "scout_apm.instruments.jinja2.have_patched_template_render_async", new=False
+    )
+    with not_render, not_render_async:
+        yield
 
 
 def test_ensure_installed_twice(caplog):
@@ -28,7 +37,7 @@ def test_ensure_installed_twice(caplog):
 
 def test_ensure_installed_fail_no_template(caplog):
     mock_no_template = mock.patch("scout_apm.instruments.jinja2.Template", new=None)
-    with mock_not_attempted, mock_no_template:
+    with mock_not_attempted(), mock_no_template:
         ensure_installed()
 
     assert caplog.record_tuples == [
@@ -47,7 +56,7 @@ def test_ensure_installed_fail_no_template(caplog):
 
 def test_ensure_installed_fail_no_render_attribute(caplog):
     mock_template = mock.patch("scout_apm.instruments.jinja2.Template")
-    with mock_not_attempted, mock_template as mocked_template:
+    with mock_not_attempted(), mock_template as mocked_template:
         # Remove render attribute
         del mocked_template.render
 
diff --git a/tests/integration/instruments/test_jinja2_py36plus.py b/tests/integration/instruments/test_jinja2_py36plus.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/instruments/test_jinja2_py36plus.py
@@ -0,0 +1,58 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import logging
+
+import jinja2
+
+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):
+    mock_template = mock.patch("scout_apm.instruments.jinja2.Template")
+    with mock_not_attempted(), mock_template as mocked_template:
+        del mocked_template.render_async
+
+        ensure_installed()
+
+    assert len(caplog.record_tuples) == 2
+    assert caplog.record_tuples[0] == (
+        "scout_apm.instruments.jinja2",
+        logging.INFO,
+        "Ensuring Jinja2 instrumentation is installed.",
+    )
+    logger, level, message = caplog.record_tuples[1]
+    assert logger == "scout_apm.instruments.jinja2"
+    assert level == logging.WARNING
+    assert message.startswith(
+        "Unable to instrument jinja2.Template.render_async: AttributeError"
+    )
+
+
+@async_test
+async def test_async_render(tracked_request):
+    ensure_installed()
+    template = jinja2.Template("Hello {{ name }}!", enable_async=True)
+
+    result = await template.render_async(name="World")
+
+    assert result == "Hello World!"
+    assert len(tracked_request.complete_spans) == 1
+    span = tracked_request.complete_spans[0]
+    assert span.operation == "Template/Render"
+    assert span.tags["name"] is None
+
+
+@async_test
+async def test_async_render_name(tracked_request):
+    ensure_installed()
+    template = jinja2.Template("Hello {{ name }}!", enable_async=True)
+    template.name = "mytemplate.html"
+
+    result = await template.render_async(name="World")
+
+    assert result == "Hello World!"
+    assert tracked_request.complete_spans[0].tags["name"] == "mytemplate.html"