Repository: open-telemetry/opentelemetry-python

Issue:
Add instrumentation for jinja2
Having discussed this with @codeboten, I want to propose that I migrate the Datadog donated jinja2 instrumentation.


Discussion:
Sounds great! 

Resolving patch:
diff --git a/ext/opentelemetry-ext-jinja2/setup.py b/ext/opentelemetry-ext-jinja2/setup.py
new file mode 100644
--- /dev/null
+++ b/ext/opentelemetry-ext-jinja2/setup.py
@@ -0,0 +1,26 @@
+# Copyright The OpenTelemetry Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import os
+
+import setuptools
+
+BASE_DIR = os.path.dirname(__file__)
+VERSION_FILENAME = os.path.join(
+    BASE_DIR, "src", "opentelemetry", "ext", "jinja2", "version.py"
+)
+PACKAGE_INFO = {}
+with open(VERSION_FILENAME) as f:
+    exec(f.read(), PACKAGE_INFO)
+
+setuptools.setup(version=PACKAGE_INFO["__version__"])
diff --git a/ext/opentelemetry-ext-jinja2/src/opentelemetry/ext/jinja2/__init__.py b/ext/opentelemetry-ext-jinja2/src/opentelemetry/ext/jinja2/__init__.py
new file mode 100644
--- /dev/null
+++ b/ext/opentelemetry-ext-jinja2/src/opentelemetry/ext/jinja2/__init__.py
@@ -0,0 +1,147 @@
+# Copyright The OpenTelemetry Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+
+Usage
+-----
+
+The OpenTelemetry ``jinja2`` integration traces templates loading, compilation
+and rendering.
+
+Usage
+-----
+
+.. code-block:: python
+
+    from jinja2 import Environment, FileSystemLoader
+    from opentelemetry.ext.jinja2 import Jinja2Instrumentor
+    from opentelemetry import trace
+    from opentelemetry.trace import TracerProvider
+
+    trace.set_tracer_provider(TracerProvider())
+
+    Jinja2Instrumentor().instrument()
+
+    env = Environment(loader=FileSystemLoader("templates"))
+    template = env.get_template("mytemplate.html")
+
+API
+---
+"""
+# pylint: disable=no-value-for-parameter
+
+import logging
+
+import jinja2
+from wrapt import ObjectProxy
+from wrapt import wrap_function_wrapper as _wrap
+
+from opentelemetry.auto_instrumentation.instrumentor import BaseInstrumentor
+from opentelemetry.ext.jinja2.version import __version__
+from opentelemetry.trace import SpanKind, get_tracer
+from opentelemetry.trace.status import Status, StatusCanonicalCode
+
+logger = logging.getLogger(__name__)
+
+ATTRIBUTE_JINJA2_TEMPLATE_NAME = "jinja2.template_name"
+ATTRIBUTE_JINJA2_TEMPLATE_PATH = "jinja2.template_path"
+DEFAULT_TEMPLATE_NAME = "<memory>"
+
+
+def _with_tracer_wrapper(func):
+    """Helper for providing tracer for wrapper functions.
+    """
+
+    def _with_tracer(tracer):
+        def wrapper(wrapped, instance, args, kwargs):
+            return func(tracer, wrapped, instance, args, kwargs)
+
+        return wrapper
+
+    return _with_tracer
+
+
+@_with_tracer_wrapper
+def _wrap_render(tracer, wrapped, instance, args, kwargs):
+    """Wrap `Template.render()` or `Template.generate()`
+    """
+    template_name = instance.name or DEFAULT_TEMPLATE_NAME
+    attributes = {ATTRIBUTE_JINJA2_TEMPLATE_NAME: template_name}
+    with tracer.start_as_current_span(
+        "jinja2.render", kind=SpanKind.INTERNAL, attributes=attributes
+    ):
+        return wrapped(*args, **kwargs)
+
+
+@_with_tracer_wrapper
+def _wrap_compile(tracer, wrapped, _, args, kwargs):
+    template_name = (
+        args[1] if len(args) > 1 else kwargs.get("name", DEFAULT_TEMPLATE_NAME)
+    )
+    attributes = {ATTRIBUTE_JINJA2_TEMPLATE_NAME: template_name}
+    with tracer.start_as_current_span(
+        "jinja2.compile", kind=SpanKind.INTERNAL, attributes=attributes
+    ):
+        return wrapped(*args, **kwargs)
+
+
+@_with_tracer_wrapper
+def _wrap_load_template(tracer, wrapped, _, args, kwargs):
+    template_name = kwargs.get("name", args[0])
+    attributes = {ATTRIBUTE_JINJA2_TEMPLATE_NAME: template_name}
+    with tracer.start_as_current_span(
+        "jinja2.load", kind=SpanKind.INTERNAL, attributes=attributes
+    ) as span:
+        template = None
+        try:
+            template = wrapped(*args, **kwargs)
+            return template
+        finally:
+            if template:
+                span.set_attribute(
+                    ATTRIBUTE_JINJA2_TEMPLATE_PATH, template.filename
+                )
+
+
+def _unwrap(obj, attr):
+    func = getattr(obj, attr, None)
+    if func and isinstance(func, ObjectProxy) and hasattr(func, "__wrapped__"):
+        setattr(obj, attr, func.__wrapped__)
+
+
+class Jinja2Instrumentor(BaseInstrumentor):
+    """An instrumentor for jinja2
+
+    See `BaseInstrumentor`
+    """
+
+    def _instrument(self, **kwargs):
+        tracer_provider = kwargs.get("tracer_provider")
+        tracer = get_tracer(__name__, __version__, tracer_provider)
+
+        _wrap(jinja2, "environment.Template.render", _wrap_render(tracer))
+        _wrap(jinja2, "environment.Template.generate", _wrap_render(tracer))
+        _wrap(jinja2, "environment.Environment.compile", _wrap_compile(tracer))
+        _wrap(
+            jinja2,
+            "environment.Environment._load_template",
+            _wrap_load_template(tracer),
+        )
+
+    def _uninstrument(self, **kwargs):
+        _unwrap(jinja2.Template, "render")
+        _unwrap(jinja2.Template, "generate")
+        _unwrap(jinja2.Environment, "compile")
+        _unwrap(jinja2.Environment, "_load_template")
diff --git a/ext/opentelemetry-ext-jinja2/src/opentelemetry/ext/jinja2/version.py b/ext/opentelemetry-ext-jinja2/src/opentelemetry/ext/jinja2/version.py
new file mode 100644
--- /dev/null
+++ b/ext/opentelemetry-ext-jinja2/src/opentelemetry/ext/jinja2/version.py
@@ -0,0 +1,15 @@
+# Copyright The OpenTelemetry Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+__version__ = "0.7.dev0"


Test patch:
diff --git a/ext/opentelemetry-ext-jinja2/tests/__init__.py b/ext/opentelemetry-ext-jinja2/tests/__init__.py
new file mode 100644
diff --git a/ext/opentelemetry-ext-jinja2/tests/templates/base.html b/ext/opentelemetry-ext-jinja2/tests/templates/base.html
new file mode 100644
--- /dev/null
+++ b/ext/opentelemetry-ext-jinja2/tests/templates/base.html
@@ -0,0 +1 @@
+Message: {% block content %}{% endblock %}
diff --git a/ext/opentelemetry-ext-jinja2/tests/templates/template.html b/ext/opentelemetry-ext-jinja2/tests/templates/template.html
new file mode 100644
--- /dev/null
+++ b/ext/opentelemetry-ext-jinja2/tests/templates/template.html
@@ -0,0 +1,2 @@
+{% extends 'base.html' %}
+{% block content %}Hello {{name}}!{% endblock %}
diff --git a/ext/opentelemetry-ext-jinja2/tests/test_jinja2.py b/ext/opentelemetry-ext-jinja2/tests/test_jinja2.py
new file mode 100644
--- /dev/null
+++ b/ext/opentelemetry-ext-jinja2/tests/test_jinja2.py
@@ -0,0 +1,194 @@
+# Copyright The OpenTelemetry Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+
+import jinja2
+
+from opentelemetry import trace as trace_api
+from opentelemetry.ext.jinja2 import Jinja2Instrumentor
+from opentelemetry.test.test_base import TestBase
+from opentelemetry.trace import get_tracer
+
+TEST_DIR = os.path.dirname(os.path.realpath(__file__))
+TMPL_DIR = os.path.join(TEST_DIR, "templates")
+
+
+class TestJinja2Instrumentor(TestBase):
+    def setUp(self):
+        super().setUp()
+        Jinja2Instrumentor().instrument()
+        # prevent cache effects when using Template('code...')
+        # pylint: disable=protected-access
+        jinja2.environment._spontaneous_environments.clear()
+        self.tracer = get_tracer(__name__)
+
+    def tearDown(self):
+        super().tearDown()
+        Jinja2Instrumentor().uninstrument()
+
+    def test_render_inline_template_with_root(self):
+        with self.tracer.start_as_current_span("root"):
+            template = jinja2.environment.Template("Hello {{name}}!")
+            self.assertEqual(template.render(name="Jinja"), "Hello Jinja!")
+
+        spans = self.memory_exporter.get_finished_spans()
+        self.assertEqual(len(spans), 3)
+
+        # pylint:disable=unbalanced-tuple-unpacking
+        render, template, root = spans[:3]
+
+        self.assertIs(render.parent, root.get_context())
+        self.assertIs(template.parent, root.get_context())
+        self.assertIsNone(root.parent)
+
+    def test_render_inline_template(self):
+        template = jinja2.environment.Template("Hello {{name}}!")
+        self.assertEqual(template.render(name="Jinja"), "Hello Jinja!")
+
+        spans = self.memory_exporter.get_finished_spans()
+        self.assertEqual(len(spans), 2)
+
+        # pylint:disable=unbalanced-tuple-unpacking
+        template, render = spans
+
+        self.assertEqual(template.name, "jinja2.compile")
+        self.assertIs(template.kind, trace_api.SpanKind.INTERNAL)
+        self.assertEqual(
+            template.attributes, {"jinja2.template_name": "<memory>"},
+        )
+
+        self.assertEqual(render.name, "jinja2.render")
+        self.assertIs(render.kind, trace_api.SpanKind.INTERNAL)
+        self.assertEqual(
+            render.attributes, {"jinja2.template_name": "<memory>"},
+        )
+
+    def test_generate_inline_template_with_root(self):
+        with self.tracer.start_as_current_span("root"):
+            template = jinja2.environment.Template("Hello {{name}}!")
+            self.assertEqual(
+                "".join(template.generate(name="Jinja")), "Hello Jinja!"
+            )
+
+        spans = self.memory_exporter.get_finished_spans()
+        self.assertEqual(len(spans), 3)
+
+        # pylint:disable=unbalanced-tuple-unpacking
+        template, generate, root = spans
+
+        self.assertIs(generate.parent, root.get_context())
+        self.assertIs(template.parent, root.get_context())
+        self.assertIsNone(root.parent)
+
+    def test_generate_inline_template(self):
+        template = jinja2.environment.Template("Hello {{name}}!")
+        self.assertEqual(
+            "".join(template.generate(name="Jinja")), "Hello Jinja!"
+        )
+
+        spans = self.memory_exporter.get_finished_spans()
+        self.assertEqual(len(spans), 2)
+
+        # pylint:disable=unbalanced-tuple-unpacking
+        template, generate = spans[:2]
+
+        self.assertEqual(template.name, "jinja2.compile")
+        self.assertIs(template.kind, trace_api.SpanKind.INTERNAL)
+        self.assertEqual(
+            template.attributes, {"jinja2.template_name": "<memory>"},
+        )
+
+        self.assertEqual(generate.name, "jinja2.render")
+        self.assertIs(generate.kind, trace_api.SpanKind.INTERNAL)
+        self.assertEqual(
+            generate.attributes, {"jinja2.template_name": "<memory>"},
+        )
+
+    def test_file_template_with_root(self):
+        with self.tracer.start_as_current_span("root"):
+            loader = jinja2.loaders.FileSystemLoader(TMPL_DIR)
+            env = jinja2.Environment(loader=loader)
+            template = env.get_template("template.html")
+            self.assertEqual(
+                template.render(name="Jinja"), "Message: Hello Jinja!"
+            )
+
+        spans = self.memory_exporter.get_finished_spans()
+        self.assertEqual(len(spans), 6)
+
+        # pylint:disable=unbalanced-tuple-unpacking
+        compile2, load2, compile1, load1, render, root = spans
+
+        self.assertIs(compile2.parent, load2.get_context())
+        self.assertIs(load2.parent, root.get_context())
+        self.assertIs(compile1.parent, load1.get_context())
+        self.assertIs(load1.parent, render.get_context())
+        self.assertIs(render.parent, root.get_context())
+        self.assertIsNone(root.parent)
+
+    def test_file_template(self):
+        loader = jinja2.loaders.FileSystemLoader(TMPL_DIR)
+        env = jinja2.Environment(loader=loader)
+        template = env.get_template("template.html")
+        self.assertEqual(
+            template.render(name="Jinja"), "Message: Hello Jinja!"
+        )
+
+        spans = self.memory_exporter.get_finished_spans()
+        self.assertEqual(len(spans), 5)
+
+        # pylint:disable=unbalanced-tuple-unpacking
+        compile2, load2, compile1, load1, render = spans
+
+        self.assertEqual(compile2.name, "jinja2.compile")
+        self.assertEqual(load2.name, "jinja2.load")
+        self.assertEqual(compile1.name, "jinja2.compile")
+        self.assertEqual(load1.name, "jinja2.load")
+        self.assertEqual(render.name, "jinja2.render")
+
+        self.assertEqual(
+            compile2.attributes, {"jinja2.template_name": "template.html"},
+        )
+        self.assertEqual(
+            load2.attributes,
+            {
+                "jinja2.template_name": "template.html",
+                "jinja2.template_path": os.path.join(
+                    TMPL_DIR, "template.html"
+                ),
+            },
+        )
+        self.assertEqual(
+            compile1.attributes, {"jinja2.template_name": "base.html"},
+        )
+        self.assertEqual(
+            load1.attributes,
+            {
+                "jinja2.template_name": "base.html",
+                "jinja2.template_path": os.path.join(TMPL_DIR, "base.html"),
+            },
+        )
+        self.assertEqual(
+            render.attributes, {"jinja2.template_name": "template.html"},
+        )
+
+    def test_uninstrumented(self):
+        Jinja2Instrumentor().uninstrument()
+
+        jinja2.environment.Template("Hello {{name}}!")
+        spans = self.memory_exporter.get_finished_spans()
+        self.assertEqual(len(spans), 0)
+
+        Jinja2Instrumentor().instrument()