diff --git a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/code_correlation/__init__.py b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/code_correlation/__init__.py index b4d7819b0..73e58d4df 100644 --- a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/code_correlation/__init__.py +++ b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/code_correlation/__init__.py @@ -7,6 +7,7 @@ This module provides functionality for correlating code execution with telemetry data. """ +import inspect from functools import wraps from typing import Any, Callable @@ -21,7 +22,7 @@ CODE_LINE_NUMBER = "code.line.number" -def add_code_attributes_to_span(span, func: Callable[..., Any]) -> None: +def add_code_attributes_to_span(span, func_or_class: Callable[..., Any]) -> None: """ Add code-related attributes to a span based on a Python function. @@ -39,32 +40,23 @@ def add_code_attributes_to_span(span, func: Callable[..., Any]) -> None: return try: - # Get function name - function_name = getattr(func, "__name__", str(func)) - span.set_attribute(CODE_FUNCTION_NAME, function_name) - - # Get function source file from code object - try: - if hasattr(func, "__code__"): - source_file = func.__code__.co_filename - span.set_attribute(CODE_FILE_PATH, source_file) - except (AttributeError, TypeError): - # Handle cases where code object is not available - # (e.g., built-in functions, C extensions) - pass - - # Get function line number from code object + # Check if it's a class first, with proper exception handling try: - if hasattr(func, "__code__"): - line_number = func.__code__.co_firstlineno - span.set_attribute(CODE_LINE_NUMBER, line_number) - except (AttributeError, TypeError): - # Handle cases where code object is not available - pass - + is_class = inspect.isclass(func_or_class) + except Exception: # pylint: disable=broad-exception-caught + # If inspect.isclass fails, we can't safely determine the type, so return early + return + + if is_class: + span.set_attribute(CODE_FUNCTION_NAME, f"{func_or_class.__module__}.{func_or_class.__qualname__}") + span.set_attribute(CODE_FILE_PATH, inspect.getfile(func_or_class)) + else: + code = getattr(func_or_class, "__code__", None) + if code: + span.set_attribute(CODE_FUNCTION_NAME, f"{func_or_class.__module__}.{func_or_class.__qualname__}") + span.set_attribute(CODE_FILE_PATH, code.co_filename) + span.set_attribute(CODE_LINE_NUMBER, code.co_firstlineno) except Exception: # pylint: disable=broad-exception-caught - # Silently handle any unexpected errors to avoid breaking - # the instrumentation flow pass @@ -97,9 +89,8 @@ async def my_async_function(): Returns: The wrapped function with current span code attributes tracing """ - # Detect async functions: check function code object flags or special attributes - # CO_ITERABLE_COROUTINE = 0x80, async functions will have this flag set - is_async = (hasattr(func, "__code__") and func.__code__.co_flags & 0x80) or hasattr(func, "_is_coroutine") + # Detect async functions + is_async = inspect.iscoroutinefunction(func) if is_async: # Async function wrapper diff --git a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/patches/_django_patches.py b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/patches/_django_patches.py new file mode 100644 index 000000000..f4e266a71 --- /dev/null +++ b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/patches/_django_patches.py @@ -0,0 +1,140 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# Modifications Copyright The OpenTelemetry Authors. Licensed under the Apache License 2.0 License. + +from logging import getLogger + +from amazon.opentelemetry.distro.aws_opentelemetry_configurator import get_code_correlation_enabled_status + +_logger = getLogger(__name__) + + +def _apply_django_instrumentation_patches() -> None: + """Django instrumentation patches + + Applies patches to provide code attributes support for Django instrumentation. + This patches the Django instrumentation to automatically add code attributes + to spans by modifying the process_view method of the Django middleware. + Also patches Django's path/re_path functions for URL pattern instrumentation. + """ + if get_code_correlation_enabled_status() is True: + _apply_django_code_attributes_patch() + + +def _apply_django_code_attributes_patch() -> None: # pylint: disable=too-many-statements + """Django instrumentation patch for code attributes + + This patch modifies the Django middleware's process_view method to automatically add + code attributes to the current span when a view function is about to be executed. + + The patch includes: + 1. Support for class-based views by extracting the actual HTTP method handler + 2. Automatic addition of code.function.name, code.file.path, and code.line.number + 3. Graceful error handling and cleanup during uninstrument + """ + try: + # Import Django instrumentation classes and AWS code correlation function + from amazon.opentelemetry.distro.code_correlation import ( # pylint: disable=import-outside-toplevel + add_code_attributes_to_span, + ) + from opentelemetry.instrumentation.django import DjangoInstrumentor # pylint: disable=import-outside-toplevel + + # Store the original _instrument and _uninstrument methods + original_instrument = DjangoInstrumentor._instrument + original_uninstrument = DjangoInstrumentor._uninstrument + + # Store reference to original Django middleware process_view method + original_process_view = None + + def _patch_django_middleware(): + """Patch Django middleware's process_view method to add code attributes.""" + try: + # Import Django middleware class + # pylint: disable=import-outside-toplevel + from opentelemetry.instrumentation.django.middleware.otel_middleware import _DjangoMiddleware + + nonlocal original_process_view + if original_process_view is None: + original_process_view = _DjangoMiddleware.process_view + + def patched_process_view( + self, request, view_func, *args, **kwargs + ): # pylint: disable=too-many-locals,too-many-nested-blocks,too-many-branches + """Patched process_view method to add code attributes to the span.""" + # First call the original process_view method + result = original_process_view(self, request, view_func, *args, **kwargs) + + # Add code attributes if we have a span and view function + try: + if ( + self._environ_activation_key in request.META.keys() + and self._environ_span_key in request.META.keys() + ): + span = request.META[self._environ_span_key] + if span and view_func and span.is_recording(): + # Determine the target function/method to analyze + target = view_func + + # If it's a class-based view, get the corresponding HTTP method handler + view_class = getattr(view_func, "view_class", None) + if view_class: + method_name = request.method.lower() + handler = getattr(view_class, method_name, None) or view_class + target = handler + + # Call the existing add_code_attributes_to_span function + add_code_attributes_to_span(span, target) + _logger.debug( + "Added code attributes to span for Django view: %s", + getattr(target, "__name__", str(target)), + ) + except Exception as exc: # pylint: disable=broad-exception-caught + # Don't let code attributes addition break the request processing + _logger.warning("Failed to add code attributes to Django span: %s", exc) + + return result + + # Apply the patch + _DjangoMiddleware.process_view = patched_process_view + _logger.debug("Django middleware process_view patched successfully for code attributes") + + except Exception as exc: # pylint: disable=broad-exception-caught + _logger.warning("Failed to patch Django middleware process_view: %s", exc) + + def _unpatch_django_middleware(): + """Restore original Django middleware process_view method.""" + try: + # pylint: disable=import-outside-toplevel + from opentelemetry.instrumentation.django.middleware.otel_middleware import _DjangoMiddleware + + if original_process_view is not None: + _DjangoMiddleware.process_view = original_process_view + _logger.debug("Django middleware process_view restored successfully") + + except Exception as exc: # pylint: disable=broad-exception-caught + _logger.warning("Failed to restore Django middleware process_view: %s", exc) + + def patched_instrument(self, **kwargs): + """Patched _instrument method with Django middleware patching""" + # Apply Django middleware patches + _patch_django_middleware() + + # Call the original _instrument method + original_instrument(self, **kwargs) + + def patched_uninstrument(self, **kwargs): + """Patched _uninstrument method with Django middleware patch restoration""" + # Call the original _uninstrument method first + original_uninstrument(self, **kwargs) + + # Restore original Django middleware + _unpatch_django_middleware() + + # Apply the patches to DjangoInstrumentor + DjangoInstrumentor._instrument = patched_instrument + DjangoInstrumentor._uninstrument = patched_uninstrument + + _logger.debug("Django instrumentation code attributes patch applied successfully") + + except Exception as exc: # pylint: disable=broad-exception-caught + _logger.warning("Failed to apply Django code attributes patch: %s", exc) diff --git a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/patches/_instrumentation_patch.py b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/patches/_instrumentation_patch.py index 2f1f4bba5..9dec53a55 100644 --- a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/patches/_instrumentation_patch.py +++ b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/patches/_instrumentation_patch.py @@ -85,6 +85,13 @@ def apply_instrumentation_patches() -> None: _apply_fastapi_instrumentation_patches() + if is_installed("django"): + # pylint: disable=import-outside-toplevel + # Delay import to only occur if patches is safe to apply (e.g. the instrumented library is installed). + from amazon.opentelemetry.distro.patches._django_patches import _apply_django_instrumentation_patches + + _apply_django_instrumentation_patches() + # No need to check if library is installed as this patches opentelemetry.sdk, # which must be installed for the distro to work at all. _apply_resource_detector_patches() diff --git a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/code_correlation/__init__.py b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/code_correlation/__init__.py deleted file mode 100644 index 04f8b7b76..000000000 --- a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/code_correlation/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 diff --git a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/code_correlation/test_code_correlation.py b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/code_correlation/test_code_correlation.py index ba787cd8e..a09964375 100644 --- a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/code_correlation/test_code_correlation.py +++ b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/code_correlation/test_code_correlation.py @@ -3,7 +3,7 @@ import asyncio from unittest import TestCase -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import MagicMock, patch from amazon.opentelemetry.distro.code_correlation import ( CODE_FILE_PATH, @@ -28,173 +28,104 @@ def test_constants_values(self): class TestAddCodeAttributesToSpan(TestCase): """Test the add_code_attributes_to_span function.""" - def setUp(self): - """Set up test fixtures.""" - self.mock_span = MagicMock(spec=Span) - self.mock_span.is_recording.return_value = True - - def test_add_code_attributes_to_recording_span(self): - """Test adding code attributes to a recording span.""" + def test_add_code_attributes_to_recording_span_with_function(self): + """Test adding code attributes to a recording span with a regular function.""" + # Create independent mock_span for this test + mock_span = MagicMock(spec=Span) + mock_span.is_recording.return_value = True def test_function(): pass - add_code_attributes_to_span(self.mock_span, test_function) + add_code_attributes_to_span(mock_span, test_function) # Verify function name attribute is set - self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, "test_function") + expected_function_name = f"{test_function.__module__}.{test_function.__qualname__}" + mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, expected_function_name) # Verify file path attribute is set expected_file_path = test_function.__code__.co_filename - self.mock_span.set_attribute.assert_any_call(CODE_FILE_PATH, expected_file_path) + mock_span.set_attribute.assert_any_call(CODE_FILE_PATH, expected_file_path) # Verify line number attribute is set expected_line_number = test_function.__code__.co_firstlineno - self.mock_span.set_attribute.assert_any_call(CODE_LINE_NUMBER, expected_line_number) + mock_span.set_attribute.assert_any_call(CODE_LINE_NUMBER, expected_line_number) + + def test_add_code_attributes_to_recording_span_with_class(self): + """Test adding code attributes to a recording span with a class.""" + # Create independent mock_span for this test + mock_span = MagicMock(spec=Span) + mock_span.is_recording.return_value = True + + class TestClass: + pass + + add_code_attributes_to_span(mock_span, TestClass) + + # Verify class name attribute is set + expected_class_name = f"{TestClass.__module__}.{TestClass.__qualname__}" + mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, expected_class_name) + + # Verify file path attribute is set (classes have file paths too) + mock_span.set_attribute.assert_any_call(CODE_FILE_PATH, __file__) def test_add_code_attributes_to_non_recording_span(self): """Test that no attributes are added to a non-recording span.""" - self.mock_span.is_recording.return_value = False + # Create independent mock_span for this test + mock_span = MagicMock(spec=Span) + mock_span.is_recording.return_value = False def test_function(): pass - add_code_attributes_to_span(self.mock_span, test_function) + add_code_attributes_to_span(mock_span, test_function) # Verify no attributes are set - self.mock_span.set_attribute.assert_not_called() + mock_span.set_attribute.assert_not_called() def test_add_code_attributes_function_without_code(self): """Test handling of functions without __code__ attribute.""" + # Create independent mock_span for this test + mock_span = MagicMock(spec=Span) + mock_span.is_recording.return_value = True + # Create a mock function without __code__ attribute mock_func = MagicMock() mock_func.__name__ = "mock_function" delattr(mock_func, "__code__") - add_code_attributes_to_span(self.mock_span, mock_func) + add_code_attributes_to_span(mock_span, mock_func) - # Verify only function name attribute is set - self.mock_span.set_attribute.assert_called_once_with(CODE_FUNCTION_NAME, "mock_function") + # Functions without __code__ attribute don't get any attributes set + mock_span.set_attribute.assert_not_called() def test_add_code_attributes_builtin_function(self): """Test handling of built-in functions.""" - # Use a built-in function like len - add_code_attributes_to_span(self.mock_span, len) - - # Verify only function name attribute is set - self.mock_span.set_attribute.assert_called_once_with(CODE_FUNCTION_NAME, "len") + # Create independent mock_span for this test + mock_span = MagicMock(spec=Span) + mock_span.is_recording.return_value = True - def test_add_code_attributes_function_without_name(self): - """Test handling of functions without __name__ attribute.""" - # Create an object without __name__ attribute - mock_func = MagicMock() - delattr(mock_func, "__name__") - mock_func.__code__ = MagicMock() - mock_func.__code__.co_filename = "/test/file.py" - mock_func.__code__.co_firstlineno = 10 - - add_code_attributes_to_span(self.mock_span, mock_func) + # Use a built-in function like len + add_code_attributes_to_span(mock_span, len) - # Verify function name uses str() representation - self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, str(mock_func)) - self.mock_span.set_attribute.assert_any_call(CODE_FILE_PATH, "/test/file.py") - self.mock_span.set_attribute.assert_any_call(CODE_LINE_NUMBER, 10) + # Built-in functions don't have __code__ attribute, so no attributes are set + mock_span.set_attribute.assert_not_called() def test_add_code_attributes_exception_handling(self): """Test that exceptions are handled gracefully.""" - # Create a function that will cause an exception when accessing attributes - mock_func = MagicMock() - mock_func.__name__ = "test_func" - mock_func.__code__ = MagicMock() - mock_func.__code__.co_filename = "/test/file.py" - mock_func.__code__.co_firstlineno = MagicMock(side_effect=Exception("Test exception")) + # Create independent mock_span for this test + mock_span = MagicMock(spec=Span) + mock_span.is_recording.return_value = True - # This should not raise an exception - add_code_attributes_to_span(self.mock_span, mock_func) - - # Verify function name and file path are still set - self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, "test_func") - self.mock_span.set_attribute.assert_any_call(CODE_FILE_PATH, "/test/file.py") - - @patch("amazon.opentelemetry.distro.code_correlation.getattr") - def test_add_code_attributes_getattr_exception(self, mock_getattr): - """Test exception handling when getattr fails.""" - mock_getattr.side_effect = Exception("Test exception") - - def test_function(): - pass - - # This should not raise an exception - add_code_attributes_to_span(self.mock_span, test_function) - - # Verify no attributes are set due to exception - self.mock_span.set_attribute.assert_not_called() - - def test_add_code_attributes_co_filename_exception(self): - """Test exception handling when accessing co_filename raises exception.""" - # Create a mock function with __code__ that raises exception on co_filename access + # Create a function that will cause an exception when accessing __name__ mock_func = MagicMock() - mock_func.__name__ = "test_func" - mock_code = MagicMock() - mock_code.co_firstlineno = 10 - - # Make co_filename raise AttributeError - type(mock_code).co_filename = PropertyMock(side_effect=AttributeError("Test exception")) - mock_func.__code__ = mock_code - - # This should not raise an exception - add_code_attributes_to_span(self.mock_span, mock_func) - - # Verify function name and line number are still set, but not file path - self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, "test_func") - self.mock_span.set_attribute.assert_any_call(CODE_LINE_NUMBER, 10) - # File path should not be called due to exception - with self.assertRaises(AssertionError): - self.mock_span.set_attribute.assert_any_call(CODE_FILE_PATH, MagicMock()) - - def test_add_code_attributes_co_firstlineno_exception(self): - """Test exception handling when accessing co_firstlineno raises exception.""" - # Create a mock function with __code__ that raises exception on co_firstlineno access - mock_func = MagicMock() - mock_func.__name__ = "test_func" - mock_code = MagicMock() - mock_code.co_filename = "/test/file.py" - - # Make co_firstlineno raise TypeError - type(mock_code).co_firstlineno = PropertyMock(side_effect=TypeError("Test exception")) - mock_func.__code__ = mock_code - - # This should not raise an exception - add_code_attributes_to_span(self.mock_span, mock_func) - - # Verify function name and file path are still set, but not line number - self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, "test_func") - self.mock_span.set_attribute.assert_any_call(CODE_FILE_PATH, "/test/file.py") - # Line number should not be called due to exception - with self.assertRaises(AssertionError): - self.mock_span.set_attribute.assert_any_call(CODE_LINE_NUMBER, MagicMock()) - - def test_add_code_attributes_co_filename_type_error(self): - """Test exception handling when accessing co_filename raises TypeError.""" - # Create a mock function with __code__ that raises TypeError on co_filename access - mock_func = MagicMock() - mock_func.__name__ = "test_func" - mock_code = MagicMock() - mock_code.co_firstlineno = 10 - - # Make co_filename raise TypeError - type(mock_code).co_filename = PropertyMock(side_effect=TypeError("Test exception")) - mock_func.__code__ = mock_code + mock_func.__name__ = MagicMock(side_effect=Exception("Test exception")) # This should not raise an exception - add_code_attributes_to_span(self.mock_span, mock_func) + add_code_attributes_to_span(mock_span, mock_func) - # Verify function name and line number are still set, but not file path - self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, "test_func") - self.mock_span.set_attribute.assert_any_call(CODE_LINE_NUMBER, 10) - # File path should not be called due to TypeError - with self.assertRaises(AssertionError): - self.mock_span.set_attribute.assert_any_call(CODE_FILE_PATH, MagicMock()) + # No attributes should be set due to exception + mock_span.set_attribute.assert_not_called() class TestRecordCodeAttributesDecorator(TestCase): @@ -221,7 +152,8 @@ def test_sync_function(arg1, arg2=None): self.assertEqual(result, "sync result: test_arg, test_kwarg") # Verify span attributes were set - self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, "test_sync_function") + expected_function_name = f"{test_sync_function.__module__}.{test_sync_function.__qualname__}" + self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, expected_function_name) @patch("amazon.opentelemetry.distro.code_correlation.trace.get_current_span") def test_decorator_async_function(self, mock_get_current_span): @@ -244,7 +176,8 @@ async def test_async_function(arg1, arg2=None): self.assertEqual(result, "async result: test_arg, test_kwarg") # Verify span attributes were set - self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, "test_async_function") + expected_function_name = f"{test_async_function.__module__}.{test_async_function.__qualname__}" + self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, expected_function_name) @patch("amazon.opentelemetry.distro.code_correlation.trace.get_current_span") def test_decorator_no_current_span(self, mock_get_current_span): @@ -261,7 +194,7 @@ def test_function(): # Verify the function still works correctly self.assertEqual(result, "test result") - # Verify no span attributes were set + # Verify no span attributes were set since there's no span self.mock_span.set_attribute.assert_not_called() @patch("amazon.opentelemetry.distro.code_correlation.trace.get_current_span") @@ -326,7 +259,8 @@ def test_function(): test_function() # Verify span attributes were still set before exception - self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, "test_function") + expected_function_name = f"{test_function.__module__}.{test_function.__qualname__}" + self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, expected_function_name) @patch("amazon.opentelemetry.distro.code_correlation.trace.get_current_span") def test_decorator_with_async_function_that_raises_exception(self, mock_get_current_span): @@ -347,7 +281,8 @@ async def test_async_function(): loop.close() # Verify span attributes were still set before exception - self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, "test_async_function") + expected_function_name = f"{test_async_function.__module__}.{test_async_function.__qualname__}" + self.mock_span.set_attribute.assert_any_call(CODE_FUNCTION_NAME, expected_function_name) @patch("amazon.opentelemetry.distro.code_correlation.add_code_attributes_to_span") @patch("amazon.opentelemetry.distro.code_correlation.trace.get_current_span") diff --git a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/patches/test_django_patches.py b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/patches/test_django_patches.py new file mode 100644 index 000000000..beaf36437 --- /dev/null +++ b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/patches/test_django_patches.py @@ -0,0 +1,293 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from unittest.mock import Mock, patch + +from amazon.opentelemetry.distro.patches._django_patches import ( + _apply_django_code_attributes_patch, + _apply_django_instrumentation_patches, +) +from opentelemetry.test.test_base import TestBase + +try: + import django + from django.conf import settings + from django.http import HttpResponse + from django.test import RequestFactory + from django.urls import path + + from opentelemetry.instrumentation.django import DjangoInstrumentor + from opentelemetry.instrumentation.django.middleware.otel_middleware import _DjangoMiddleware + + DJANGO_AVAILABLE = True +except ImportError: + DJANGO_AVAILABLE = False + + +class TestDjangoPatches(TestBase): + """Test Django patches functionality.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + + def tearDown(self): + """Clean up after tests.""" + super().tearDown() + + @patch("amazon.opentelemetry.distro.patches._django_patches.get_code_correlation_enabled_status") + def test_apply_django_instrumentation_patches_enabled(self, mock_get_status): + """Test Django instrumentation patches when code correlation is enabled.""" + mock_get_status.return_value = True + + with patch( + "amazon.opentelemetry.distro.patches._django_patches._apply_django_code_attributes_patch" + ) as mock_patch: + _apply_django_instrumentation_patches() + mock_get_status.assert_called_once() + mock_patch.assert_called_once() + + @patch("amazon.opentelemetry.distro.patches._django_patches.get_code_correlation_enabled_status") + def test_apply_django_instrumentation_patches_disabled(self, mock_get_status): + """Test Django instrumentation patches when code correlation is disabled.""" + mock_get_status.return_value = False + + with patch( + "amazon.opentelemetry.distro.patches._django_patches._apply_django_code_attributes_patch" + ) as mock_patch: + _apply_django_instrumentation_patches() + mock_get_status.assert_called_once() + mock_patch.assert_not_called() + + @patch("amazon.opentelemetry.distro.patches._django_patches.get_code_correlation_enabled_status") + def test_apply_django_instrumentation_patches_none_status(self, mock_get_status): + """Test Django instrumentation patches when status is None.""" + mock_get_status.return_value = None + + with patch( + "amazon.opentelemetry.distro.patches._django_patches._apply_django_code_attributes_patch" + ) as mock_patch: + _apply_django_instrumentation_patches() + mock_get_status.assert_called_once() + mock_patch.assert_not_called() + + +class TestDjangoCodeAttributesPatches(TestBase): + """Test Django code attributes patches functionality.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + + def tearDown(self): + """Clean up after tests.""" + super().tearDown() + + @patch("amazon.opentelemetry.distro.patches._django_patches._logger") + def test_apply_django_code_attributes_patch_success(self, mock_logger): + """Test successful application of Django code attributes patch.""" + # Mock Django modules and classes + mock_django_instrumentor = Mock() + mock_middleware_class = Mock() + + # Mock the original methods + original_instrument = Mock() + original_uninstrument = Mock() + + mock_django_instrumentor._instrument = original_instrument + mock_django_instrumentor._uninstrument = original_uninstrument + + with patch.dict( + "sys.modules", + { + "amazon.opentelemetry.distro.code_correlation": Mock(), + "opentelemetry.instrumentation.django": Mock(DjangoInstrumentor=mock_django_instrumentor), + "opentelemetry.instrumentation.django.middleware.otel_middleware": Mock( + _DjangoMiddleware=mock_middleware_class + ), + }, + ): + _apply_django_code_attributes_patch() + mock_logger.debug.assert_called_with("Django instrumentation code attributes patch applied successfully") + + @patch("amazon.opentelemetry.distro.patches._django_patches._logger") + def test_apply_django_code_attributes_patch_import_error(self, mock_logger): + """Test Django code attributes patch with import error.""" + with patch("builtins.__import__", side_effect=ImportError("Module not found")): + _apply_django_code_attributes_patch() + # Check that warning was called with the format string and an ImportError + mock_logger.warning.assert_called() + args, kwargs = mock_logger.warning.call_args + self.assertEqual(args[0], "Failed to apply Django code attributes patch: %s") + self.assertIsInstance(args[1], ImportError) + self.assertEqual(str(args[1]), "Module not found") + + def test_apply_django_code_attributes_patch_exception_handling(self): + """Test Django code attributes patch handles exceptions gracefully.""" + with patch("amazon.opentelemetry.distro.patches._django_patches._logger"): + # Test that the function doesn't raise exceptions even with import failures + _apply_django_code_attributes_patch() + # Should complete without errors regardless of Django availability + self.assertTrue(True) # If we get here, no exception was raised + + +@patch("amazon.opentelemetry.distro.patches._django_patches.get_code_correlation_enabled_status", return_value=True) +class TestDjangoRealIntegration(TestBase): + """Test Django patches with real Django integration.""" + + def setUp(self): + """Set up test fixtures with Django configuration.""" + super().setUp() + self.skipTest("Django not available") if not DJANGO_AVAILABLE else None + + # Configure Django with minimal settings + if not settings.configured: + settings.configure( + DEBUG=True, + SECRET_KEY="test-secret-key-for-django-patches-test", + ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"], + INSTALLED_APPS=[ + "django.contrib.contenttypes", + "django.contrib.auth", + ], + MIDDLEWARE=[ + "opentelemetry.instrumentation.django.middleware.otel_middleware._DjangoMiddleware", + ], + ROOT_URLCONF=__name__, + USE_TZ=True, + ) + + django.setup() + self.factory = RequestFactory() + + def tearDown(self): + """Clean up after tests.""" + # Uninstrument Django if it was instrumented + try: + instrumentor = DjangoInstrumentor() + if instrumentor.is_instrumented_by_opentelemetry: + instrumentor.uninstrument() + except Exception: + pass + super().tearDown() + + def test_django_view_function_patch_process_view(self, mock_get_status): + """Test Django patch with real view function triggering process_view method.""" + + # Define a simple Django view function + def test_view(request): + """Test view function for Django patch testing.""" + return HttpResponse("Hello from test view") + + # Apply the Django code attributes patch + _apply_django_code_attributes_patch() + + # Instrument Django + instrumentor = DjangoInstrumentor() + instrumentor.instrument() + + try: + # Create a mock span + from unittest.mock import Mock + + mock_span = Mock() + mock_span.is_recording.return_value = True + + # Create Django request + request = self.factory.get("/test/") + + # Create middleware instance + middleware = _DjangoMiddleware(get_response=lambda req: HttpResponse()) + + # Manually set up the request environment as Django middleware would + middleware_key = middleware._environ_activation_key + span_key = middleware._environ_span_key + + request.META[middleware_key] = "test_activation" + request.META[span_key] = mock_span + + # Call process_view method which should trigger the patch + result = middleware.process_view(request, test_view, [], {}) + + # The result should be None (original process_view returns None) + self.assertIsNone(result) + + # Verify span methods were called (this confirms the patched code ran) + mock_span.is_recording.assert_called() + + # Test passes if no exceptions are raised and the method returns correctly + # The main goal is to ensure the removal of _code_cache doesn't break functionality + + finally: + # Clean up instrumentation + instrumentor.uninstrument() + + def test_django_class_based_view_patch_process_view(self, mock_get_status): + """Test Django patch with class-based view to test handler targeting logic.""" + + # Define a class-based Django view + class TestClassView: + """Test class-based view for Django patch testing.""" + + def get(self, request): + return HttpResponse("Hello from class view") + + # Create a mock view function that mimics Django's class-based view structure + def mock_view_func(request): + return HttpResponse("Mock response") + + # Add view_class attribute to simulate Django's class-based view wrapper + mock_view_func.view_class = TestClassView + + # Apply the Django code attributes patch + _apply_django_code_attributes_patch() + + # Instrument Django + instrumentor = DjangoInstrumentor() + instrumentor.instrument() + + try: + # Create a mock span + from unittest.mock import Mock + + mock_span = Mock() + mock_span.is_recording.return_value = True + + # Create Django request with GET method + request = self.factory.get("/test/") + + # Create middleware instance + middleware = _DjangoMiddleware(get_response=lambda req: HttpResponse()) + + # Manually set up the request environment as Django middleware would + middleware_key = middleware._environ_activation_key + span_key = middleware._environ_span_key + + request.META[middleware_key] = "test_activation" + request.META[span_key] = mock_span + + # Call process_view method with the class-based view function + # This should trigger the class-based view logic where it extracts the handler + result = middleware.process_view(request, mock_view_func, [], {}) + + # The result should be None (original process_view returns None) + self.assertIsNone(result) + + # Verify span methods were called (this confirms the patched code ran) + mock_span.is_recording.assert_called() + + # Test passes if no exceptions are raised and the method returns correctly + # The main goal is to ensure the removal of _code_cache doesn't break functionality + + finally: + # Clean up instrumentation + instrumentor.uninstrument() + + +# Simple URL pattern for Django testing (referenced by ROOT_URLCONF) +def dummy_view(request): + return HttpResponse("dummy") + + +urlpatterns = [ + path("test/", dummy_view, name="test"), +] diff --git a/dev-requirements.txt b/dev-requirements.txt index 646ad8e24..68f54a26e 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -18,3 +18,4 @@ botocore==1.34.67 flask>=2.0.0 fastapi>=0.68.0 starlette>=0.14.2 +django>=3.2 diff --git a/tox.ini b/tox.ini index def5dc92e..77e29c2a7 100644 --- a/tox.ini +++ b/tox.ini @@ -18,6 +18,7 @@ deps = test: fastapi test: starlette test: flask + test: django setenv = ; TODO: The two repos branches need manual updated over time, need to figure out a more sustainable solution.