diff --git a/.changelog/4624.added b/.changelog/4624.added new file mode 100644 index 0000000000..f6220a1d3a --- /dev/null +++ b/.changelog/4624.added @@ -0,0 +1 @@ +`opentelemetry-instrumentation-django`: add instrumentation for all Django middlewares that automates the creation of spans diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py index 4c45125d19..a959675010 100644 --- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py @@ -227,6 +227,21 @@ def response_hook(span, request, response): Note: The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change. +Middleware spans +**************** +You can optionally enable per-middleware span creation to trace the execution +of each Django middleware individually: + +.. code:: python + + DjangoInstrumentor().instrument(is_middleware_spans_enabled=True) + +When enabled, each middleware in ``settings.MIDDLEWARE`` produces an INTERNAL span +nested under the HTTP server span. + +Warning: + Middleware added to ``settings.MIDDLEWARE`` after calling ``instrument()`` or before the ``middleware_position`` will not be automatically traced. + SQLCommenter ************ You can optionally enable sqlcommenter which enriches the query with contextual @@ -306,6 +321,10 @@ def response_hook(span, request, response): from opentelemetry.instrumentation.django.environment_variables import ( OTEL_PYTHON_DJANGO_INSTRUMENT, ) +from opentelemetry.instrumentation.django.instrument_middleware import ( + instrument_middleware_classes, + uninstrument_middleware_classes, +) from opentelemetry.instrumentation.django.middleware.otel_middleware import ( _DjangoMiddleware, ) @@ -460,11 +479,20 @@ def _instrument(self, **kwargs): settings_middleware = list(settings_middleware) is_sql_commentor_enabled = kwargs.pop("is_sql_commentor_enabled", None) + is_middleware_spans_enabled = kwargs.pop( + "is_middleware_spans_enabled", None + ) middleware_position = _get_django_otel_middleware_position( len(settings_middleware), kwargs.pop("middleware_position", 0) ) + if is_middleware_spans_enabled: + instrument_middleware_classes( + settings_middleware[middleware_position:], + tracer, + ) + if is_sql_commentor_enabled: settings_middleware.insert( middleware_position, self._sql_commenter_middleware @@ -477,6 +505,8 @@ def _instrument(self, **kwargs): setattr(settings, _django_middleware_setting, settings_middleware) def _uninstrument(self, **kwargs): + uninstrument_middleware_classes() + settings_middleware = getattr( settings, _django_middleware_setting, None ) diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/instrument_middleware.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/instrument_middleware.py new file mode 100644 index 0000000000..88bbed9b96 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/instrument_middleware.py @@ -0,0 +1,217 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import inspect +from functools import wraps +from importlib import import_module +from inspect import iscoroutinefunction +from logging import getLogger +from typing import Any + +from django.utils.deprecation import MiddlewareMixin +from django.utils.module_loading import import_string + +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.trace import SpanKind, Tracer + +try: + from wrapt import wrap_function_wrapper +except ImportError: + pass + +_logger = getLogger(__name__) + +_wrapped_targets: list[tuple[Any, str]] = [] + + +def instrument_middleware_classes( + settings_middleware: list[str], + tracer: Tracer, +) -> None: + """Wrap all middleware in settings_middleware with span creation. + + Categorizes each middleware and applies the appropriate wrapping: + - MiddlewareMixin subclasses: single class-level wrap on MiddlewareMixin.__call__/__acall__ + - Pure new-style classes: per-class wrap on __call__/__acall__ + - Function-based factories: wrap the factory to return a traced callable + + """ + has_mixin_subclass = False + + for mw_path in settings_middleware: + try: + cls_or_func = import_string(mw_path) + except ImportError: + _logger.debug("Could not import middleware %s, skipping", mw_path) + continue + + if not inspect.isclass(cls_or_func): + _wrap_function_middleware(mw_path, tracer) + elif issubclass(cls_or_func, MiddlewareMixin): + has_mixin_subclass = True + else: + _wrap_newstyle_middleware(mw_path, cls_or_func, tracer) + + if has_mixin_subclass: + _wrap_middleware_mixin(tracer) + + +def uninstrument_middleware_classes() -> None: + """Remove all middleware span wrappers applied by instrument_middleware_classes.""" + for obj, attr in _wrapped_targets: + unwrap(obj, attr) + _wrapped_targets.clear() + + +# -- Category 1: MiddlewareMixin-based middleware -- + + +def _wrap_middleware_mixin(tracer: Tracer) -> None: + wrap_function_wrapper( + "django.utils.deprecation", + "MiddlewareMixin.__call__", + _make_mixin_call_wrapper(tracer), + ) + _wrapped_targets.append((MiddlewareMixin, "__call__")) + + if hasattr(MiddlewareMixin, "__acall__"): + wrap_function_wrapper( + "django.utils.deprecation", + "MiddlewareMixin.__acall__", + _make_mixin_acall_wrapper(tracer), + ) + _wrapped_targets.append((MiddlewareMixin, "__acall__")) + + +def _make_mixin_call_wrapper(tracer: Tracer): + def _traced_call(wrapped, instance, args, kwargs): + if getattr(instance, "async_mode", False) or getattr( + instance, "is_async", False + ): + return wrapped(*args, **kwargs) + middleware_name = type(instance).__qualname__ + with tracer.start_as_current_span( + f"django.middleware {middleware_name}", + kind=SpanKind.INTERNAL, + ): + return wrapped(*args, **kwargs) + + return _traced_call + + +def _make_mixin_acall_wrapper(tracer: Tracer): + async def _traced_acall(wrapped, instance, args, kwargs): + middleware_name = type(instance).__qualname__ + with tracer.start_as_current_span( + f"django.middleware {middleware_name}", + kind=SpanKind.INTERNAL, + ): + return await wrapped(*args, **kwargs) + + return _traced_acall + + +# -- Category 2: Pure new-style class middleware -- + + +def _wrap_newstyle_middleware(mw_path: str, cls: type, tracer: Tracer) -> None: + module_path, class_name = mw_path.rsplit(".", 1) + + wrap_function_wrapper( + module_path, + f"{class_name}.__call__", + _make_newstyle_call_wrapper(tracer), + ) + _wrapped_targets.append((cls, "__call__")) + + if hasattr(cls, "__acall__") and "__acall__" in cls.__dict__: + wrap_function_wrapper( + module_path, + f"{class_name}.__acall__", + _make_newstyle_acall_wrapper(tracer), + ) + _wrapped_targets.append((cls, "__acall__")) + + +def _make_newstyle_call_wrapper(tracer: Tracer): + def _traced_call(wrapped, instance, args, kwargs): + middleware_name = type(instance).__qualname__ + + if iscoroutinefunction(wrapped): + + async def _traced(): + with tracer.start_as_current_span( + f"django.middleware {middleware_name}", + kind=SpanKind.INTERNAL, + ): + return await wrapped(*args, **kwargs) + + return _traced() + + with tracer.start_as_current_span( + f"django.middleware {middleware_name}", + kind=SpanKind.INTERNAL, + ): + return wrapped(*args, **kwargs) + + return _traced_call + + +def _make_newstyle_acall_wrapper(tracer: Tracer): + async def _traced_acall(wrapped, instance, args, kwargs): + middleware_name = type(instance).__qualname__ + with tracer.start_as_current_span( + f"django.middleware {middleware_name}", + kind=SpanKind.INTERNAL, + ): + return await wrapped(*args, **kwargs) + + return _traced_acall + + +# -- Category 3: Function-based middleware factory -- + + +def _wrap_function_middleware(mw_path: str, tracer: Tracer) -> None: + module_path, func_name = mw_path.rsplit(".", 1) + module = import_module(module_path) + + wrap_function_wrapper( + module_path, + func_name, + _make_factory_wrapper(tracer), + ) + _wrapped_targets.append((module, func_name)) + + +def _make_factory_wrapper(tracer: Tracer): + def _traced_factory(wrapped, instance, args, kwargs): + inner_callable = wrapped(*args, **kwargs) + middleware_name = wrapped.__qualname__ + + if iscoroutinefunction(inner_callable): + + @wraps(inner_callable) + async def traced_async(request): + with tracer.start_as_current_span( + f"django.middleware {middleware_name}", + kind=SpanKind.INTERNAL, + ): + return await inner_callable(request) + + return traced_async + else: + + @wraps(inner_callable) + def traced_sync(request): + with tracer.start_as_current_span( + f"django.middleware {middleware_name}", + kind=SpanKind.INTERNAL, + ): + return inner_callable(request) + + return traced_sync + + return _traced_factory diff --git a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_spans.py b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_spans.py new file mode 100644 index 0000000000..4327d5b704 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_spans.py @@ -0,0 +1,245 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from sys import modules +from unittest.mock import patch + +from django import VERSION +from django.conf import settings +from django.http import HttpResponse +from django.test.client import Client +from django.test.utils import setup_test_environment, teardown_test_environment +from django.utils.deprecation import MiddlewareMixin + +from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, +) +from opentelemetry.instrumentation.django import DjangoInstrumentor +from opentelemetry.test.wsgitestutil import WsgiTestBase +from opentelemetry.trace import SpanKind + +DJANGO_2_0 = VERSION >= (2, 0) + +if DJANGO_2_0: + from django.urls import re_path +else: + from django.conf.urls import url as re_path + + +def traced_view(request): + return HttpResponse() + + +class MixinMiddleware(MiddlewareMixin): + def process_request(self, request): + pass + + +class NewStyleMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + return self.get_response(request) + + +def function_middleware(get_response): + def middleware(request): + return get_response(request) + + return middleware + + +class ErrorMiddleware(MiddlewareMixin): + def process_request(self, request): + raise RuntimeError("middleware error") + + +urlpatterns = [ + re_path(r"^traced/", traced_view), +] + +_django_instrumentor = DjangoInstrumentor() +_THIS_MODULE = modules[__name__].__name__ + +_TEST_MIDDLEWARE = [ + f"{_THIS_MODULE}.MixinMiddleware", + f"{_THIS_MODULE}.NewStyleMiddleware", + f"{_THIS_MODULE}.function_middleware", +] + + +class TestMiddlewareSpans(WsgiTestBase): + @classmethod + def setUpClass(cls): + if not settings.configured: + settings.configure(ROOT_URLCONF=modules[__name__]) + super().setUpClass() + + def setUp(self): + super().setUp() + setup_test_environment() + middleware = getattr(settings, "MIDDLEWARE", []) + self._original_middleware = list(middleware) + self._original_root_urlconf = getattr(settings, "ROOT_URLCONF", None) + middleware.clear() + middleware.extend(_TEST_MIDDLEWARE) + settings.ROOT_URLCONF = modules[__name__] + self.env_patch = patch.dict( + "os.environ", + {OTEL_SEMCONV_STABILITY_OPT_IN: "default"}, + ) + _OpenTelemetrySemanticConventionStability._initialized = False + self.env_patch.start() + _django_instrumentor.instrument(is_middleware_spans_enabled=True) + + def tearDown(self): + super().tearDown() + self.env_patch.stop() + teardown_test_environment() + _django_instrumentor.uninstrument() + middleware = getattr(settings, "MIDDLEWARE", []) + middleware.clear() + middleware.extend(self._original_middleware) + if self._original_root_urlconf is not None: + settings.ROOT_URLCONF = self._original_root_urlconf + + def _get_spans_by_kind(self): + spans = self.memory_exporter.get_finished_spans() + server_spans = [s for s in spans if s.kind == SpanKind.SERVER] + mw_spans = [s for s in spans if s.kind == SpanKind.INTERNAL] + return server_spans, mw_spans + + def test_all_categories_create_spans(self): + Client().get("/traced/") + server_spans, mw_spans = self._get_spans_by_kind() + + self.assertEqual(len(server_spans), 1) + self.assertEqual(len(mw_spans), 3) + + mw_names = {s.name for s in mw_spans} + self.assertIn("django.middleware MixinMiddleware", mw_names) + self.assertIn("django.middleware NewStyleMiddleware", mw_names) + self.assertIn("django.middleware function_middleware", mw_names) + + def test_mixin_middleware_creates_span(self): + Client().get("/traced/") + _, mw_spans = self._get_spans_by_kind() + + mixin_spans = [ + s + for s in mw_spans + if s.name == "django.middleware MixinMiddleware" + ] + self.assertEqual(len(mixin_spans), 1) + + def test_newstyle_middleware_creates_span(self): + Client().get("/traced/") + _, mw_spans = self._get_spans_by_kind() + + newstyle_spans = [ + s + for s in mw_spans + if s.name == "django.middleware NewStyleMiddleware" + ] + self.assertEqual(len(newstyle_spans), 1) + + def test_function_middleware_creates_span(self): + Client().get("/traced/") + _, mw_spans = self._get_spans_by_kind() + + func_spans = [ + s + for s in mw_spans + if s.name == "django.middleware function_middleware" + ] + self.assertEqual(len(func_spans), 1) + + # Span properties + + def test_span_kind_is_internal(self): + Client().get("/traced/") + _, mw_spans = self._get_spans_by_kind() + + for span in mw_spans: + self.assertEqual(span.kind, SpanKind.INTERNAL) + + def test_spans_share_trace_with_server_span(self): + Client().get("/traced/") + server_spans, mw_spans = self._get_spans_by_kind() + + self.assertEqual(len(server_spans), 1) + server_trace_id = server_spans[0].get_span_context().trace_id + + for mw_span in mw_spans: + self.assertEqual( + mw_span.get_span_context().trace_id, + server_trace_id, + ) + + def test_span_naming_format(self): + Client().get("/traced/") + _, mw_spans = self._get_spans_by_kind() + + for span in mw_spans: + self.assertTrue( + span.name.startswith("django.middleware "), + f"Span name '{span.name}' does not match expected format", + ) + + # Edge cases + + def test_disabled_by_default(self): + _django_instrumentor.uninstrument() + _django_instrumentor.instrument() + + Client().get("/traced/") + _, mw_spans = self._get_spans_by_kind() + self.assertEqual(len(mw_spans), 0) + + def test_uninstrument_removes_spans(self): + _django_instrumentor.uninstrument() + _django_instrumentor.instrument() + + Client().get("/traced/") + _, mw_spans = self._get_spans_by_kind() + self.assertEqual(len(mw_spans), 0) + + def _reinstrument_with_middleware(self, middleware_list, **kwargs): + _django_instrumentor.uninstrument() + middleware = getattr(settings, "MIDDLEWARE", []) + middleware.clear() + middleware.extend(middleware_list) + kwargs.setdefault("is_middleware_spans_enabled", True) + _django_instrumentor.instrument(**kwargs) + + def test_middleware_error_creates_span(self): + self._reinstrument_with_middleware([f"{_THIS_MODULE}.ErrorMiddleware"]) + + try: + Client().get("/traced/") + except RuntimeError: + pass + + spans = self.memory_exporter.get_finished_spans() + mw_spans = [s for s in spans if s.kind == SpanKind.INTERNAL] + self.assertEqual(len(mw_spans), 1) + self.assertEqual(mw_spans[0].name, "django.middleware ErrorMiddleware") + + def test_respects_middleware_position(self): + self._reinstrument_with_middleware( + [ + f"{_THIS_MODULE}.MixinMiddleware", + f"{_THIS_MODULE}.NewStyleMiddleware", + ], + middleware_position=1, + ) + + Client().get("/traced/") + spans = self.memory_exporter.get_finished_spans() + mw_spans = [s for s in spans if s.kind == SpanKind.INTERNAL] + + mw_names = {s.name for s in mw_spans} + self.assertNotIn("django.middleware MixinMiddleware", mw_names) + self.assertIn("django.middleware NewStyleMiddleware", mw_names)