diff --git a/.coverage b/.coverage new file mode 100644 index 00000000..b80673b4 Binary files /dev/null and b/.coverage differ diff --git a/dev_requirements.txt b/dev_requirements.txt index f48cbaee..1398bb12 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,2 +1,3 @@ pytest>=8.0 +pytest-cov>=5.0 black>=24.0 diff --git a/pyproject.toml b/pyproject.toml index f83b342a..73272e48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ [project.optional-dependencies] test = [ "pytest>=8.0", + "pytest-cov>=5.0", ] dev = [ "black>=24.0", @@ -36,6 +37,7 @@ dev = [ "mypy>=1.10", "pyright>=1.1", "pytest>=8.0", + "pytest-cov>=5.0", ] [tool.setuptools] @@ -55,6 +57,12 @@ py-version = "3.10" [tool.pylint.format] max-line-length = 120 +[tool.coverage.run] +source = ["microsoft"] + +[tool.coverage.report] +show_missing = true + [tool.pylint.messages_control] disable = [ "missing-function-docstring", diff --git a/src/microsoft/opentelemetry/_azure_monitor/_browser_sdk_loader/django_middleware.py b/src/microsoft/opentelemetry/_azure_monitor/_browser_sdk_loader/django_middleware.py index b0cfaf51..acf3db55 100644 --- a/src/microsoft/opentelemetry/_azure_monitor/_browser_sdk_loader/django_middleware.py +++ b/src/microsoft/opentelemetry/_azure_monitor/_browser_sdk_loader/django_middleware.py @@ -42,7 +42,8 @@ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: :type get_response: Callable[[HttpRequest], HttpResponse] :rtype: None """ - super().__init__(get_response) + if MiddlewareMixin is not object: + super().__init__(get_response) self.get_response = get_response self._injector: Optional[WebSnippetInjector] = None if not DJANGO_AVAILABLE: diff --git a/tests/azure_monitor/browserSdkLoader/test_init.py b/tests/azure_monitor/browserSdkLoader/test_init.py new file mode 100644 index 00000000..c52870a9 --- /dev/null +++ b/tests/azure_monitor/browserSdkLoader/test_init.py @@ -0,0 +1,190 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import unittest +from unittest.mock import MagicMock, patch + +from microsoft.opentelemetry._azure_monitor._browser_sdk_loader._config import BrowserSDKConfig + +_MW_PATH = ( + "microsoft.opentelemetry._azure_monitor._browser_sdk_loader" + ".django_middleware.ApplicationInsightsWebSnippetMiddleware" +) + + +class TestSetupSnippetInjection(unittest.TestCase): + def setUp(self): + self.config = BrowserSDKConfig( + enabled=True, + connection_string="InstrumentationKey=test;IngestionEndpoint=https://test.in.ai.azure.com/", + ) + + @patch("microsoft.opentelemetry._azure_monitor._browser_sdk_loader._setup_django_injection") + def test_setup_snippet_injection_calls_django(self, mock_django): + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader import setup_snippet_injection + + setup_snippet_injection(self.config) + mock_django.assert_called_once_with(self.config) + + @patch( + "microsoft.opentelemetry._azure_monitor._browser_sdk_loader._setup_django_injection", + side_effect=Exception("boom"), + ) + def test_setup_snippet_injection_handles_exception(self, mock_django): + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader import setup_snippet_injection + + # Should not raise + setup_snippet_injection(self.config) + + +class TestSetupDjangoInjection(unittest.TestCase): + def setUp(self): + self.config = BrowserSDKConfig( + enabled=True, + connection_string="InstrumentationKey=test;IngestionEndpoint=https://test.in.ai.azure.com/", + ) + + @patch.dict("sys.modules", {"django": None, "django.conf": None}) + def test_django_not_available(self): + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader import _setup_django_injection + + # Should not raise when django is not importable + _setup_django_injection(self.config) + + @patch("microsoft.opentelemetry._azure_monitor._browser_sdk_loader._register_django_middleware") + def test_django_available_and_configured(self, mock_register): + settings_mock = MagicMock() + settings_mock.configured = True + + with patch.dict( + "sys.modules", + { + "django": MagicMock(), + "django.conf": MagicMock(settings=settings_mock), + }, + ): + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader import _setup_django_injection + + _setup_django_injection(self.config) + mock_register.assert_called_once_with(self.config) + + def test_django_not_configured(self): + settings_mock = MagicMock() + settings_mock.configured = False + + with patch.dict( + "sys.modules", + { + "django": MagicMock(), + "django.conf": MagicMock(settings=settings_mock), + }, + ): + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader import _setup_django_injection + + # Should return early without raising + _setup_django_injection(self.config) + + +class TestRegisterDjangoMiddleware(unittest.TestCase): + def setUp(self): + self.config = BrowserSDKConfig( + enabled=True, + connection_string="InstrumentationKey=test;IngestionEndpoint=https://test.in.ai.azure.com/", + ) + + def test_register_with_middleware_attr(self): + settings_mock = MagicMock(spec_set=["MIDDLEWARE"]) + settings_mock.MIDDLEWARE = ["django.middleware.common.CommonMiddleware"] + + with patch.dict( + "sys.modules", + { + "django": MagicMock(), + "django.conf": MagicMock(settings=settings_mock), + }, + ): + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader import _register_django_middleware + + _register_django_middleware(self.config) + # Our middleware should have been appended + middleware_list = settings_mock.MIDDLEWARE + self.assertIn(_MW_PATH, middleware_list) + + def test_register_with_middleware_classes_attr(self): + settings_mock = MagicMock(spec=[]) + settings_mock.MIDDLEWARE_CLASSES = ["django.middleware.common.CommonMiddleware"] + + with patch.dict( + "sys.modules", + { + "django": MagicMock(), + "django.conf": MagicMock(settings=settings_mock), + }, + ): + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader import _register_django_middleware + + _register_django_middleware(self.config) + self.assertIn(_MW_PATH, settings_mock.MIDDLEWARE_CLASSES) + + def test_already_registered(self): + middleware_path = _MW_PATH + settings_mock = MagicMock() + settings_mock.MIDDLEWARE = [middleware_path] + + with patch.dict( + "sys.modules", + { + "django": MagicMock(), + "django.conf": MagicMock(settings=settings_mock), + }, + ): + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader import _register_django_middleware + + _register_django_middleware(self.config) + # Should not duplicate + self.assertEqual(settings_mock.MIDDLEWARE.count(middleware_path), 1) + + +class TestStoreDjangoConfig(unittest.TestCase): + def setUp(self): + self.config = BrowserSDKConfig( + enabled=True, + connection_string="InstrumentationKey=test;IngestionEndpoint=https://test.in.ai.azure.com/", + ) + + def test_store_config(self): + settings_mock = MagicMock(spec=[]) + # No AZURE_MONITOR_WEB_SNIPPET_CONFIG attribute yet + + with patch.dict( + "sys.modules", + { + "django": MagicMock(), + "django.conf": MagicMock(settings=settings_mock), + }, + ): + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader import _store_django_config + + _store_django_config(self.config) + self.assertEqual(settings_mock.AZURE_MONITOR_WEB_SNIPPET_CONFIG, self.config) + + def test_store_config_already_set(self): + existing_config = BrowserSDKConfig(enabled=False) + settings_mock = MagicMock() + settings_mock.AZURE_MONITOR_WEB_SNIPPET_CONFIG = existing_config + + with patch.dict( + "sys.modules", + { + "django": MagicMock(), + "django.conf": MagicMock(settings=settings_mock), + }, + ): + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader import _store_django_config + + _store_django_config(self.config) + # Should not overwrite + self.assertEqual(settings_mock.AZURE_MONITOR_WEB_SNIPPET_CONFIG, existing_config) diff --git a/tests/azure_monitor/browserSdkLoader/test_snippet_injector.py b/tests/azure_monitor/browserSdkLoader/test_snippet_injector.py index 02797fab..d5335fb4 100644 --- a/tests/azure_monitor/browserSdkLoader/test_snippet_injector.py +++ b/tests/azure_monitor/browserSdkLoader/test_snippet_injector.py @@ -377,6 +377,182 @@ def test_should_inject_script_url_detection(self): result = self.injector.should_inject("GET", "text/html", content) self.assertFalse(result, f"Should detect existing script URL in: {content}") + def test_mark_browser_loader_feature_disabled(self): + """Test _mark_browser_loader_feature does nothing when disabled.""" + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader.snippet_injector import ( + _mark_browser_loader_feature, + ) + + # Should not raise + _mark_browser_loader_feature(False) + + def test_mark_browser_loader_feature_enabled_statsbeat_available(self): + """Test _mark_browser_loader_feature when statsbeat is available.""" + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader.snippet_injector import ( + _mark_browser_loader_feature, + ) + + mock_module = unittest.mock.MagicMock() + mock_module.is_statsbeat_enabled.return_value = True + mock_module.get_statsbeat_shutdown.return_value = False + mock_module.get_statsbeat_browser_sdk_loader_feature_set.return_value = False + with patch.dict( + "sys.modules", + { + "azure.monitor.opentelemetry.exporter.statsbeat._state": mock_module, + }, + ): + _mark_browser_loader_feature(True) + mock_module.set_statsbeat_browser_sdk_loader_feature_set.assert_called_once() + + def test_mark_browser_loader_feature_enabled_statsbeat_exception(self): + """Test _mark_browser_loader_feature handles statsbeat exception.""" + from microsoft.opentelemetry._azure_monitor._browser_sdk_loader.snippet_injector import ( + _mark_browser_loader_feature, + ) + + mock_module = unittest.mock.MagicMock() + mock_module.is_statsbeat_enabled.side_effect = Exception("boom") + with patch.dict( + "sys.modules", + { + "azure.monitor.opentelemetry.exporter.statsbeat._state": mock_module, + }, + ): + # Should not raise + _mark_browser_loader_feature(True) + + def test_inject_snippet_no_insertion_point(self): + """Test inject_snippet returns original content when no insertion point found.""" + content = b"just some text without html tags" + result = self.injector.inject_snippet(content) + self.assertEqual(result, content) + + def test_inject_snippet_exception(self): + """Test inject_snippet handles exceptions gracefully.""" + content = b"
" + with patch.object(self.injector, "_find_insertion_point", side_effect=Exception("boom")): + result = self.injector.inject_snippet(content) + self.assertEqual(result, content) + + def test_find_insertion_point_body_fallback(self): + """Test _find_insertion_point falls back to when no .""" + html = "content
" + idx = self.injector._find_insertion_point(html) + self.assertEqual(idx, html.lower().find(" when no or .""" + html = "content
" + idx = self.injector._find_insertion_point(html) + self.assertEqual(idx, 0) + + def test_find_insertion_point_no_tags(self): + """Test _find_insertion_point returns -1 when no tags found.""" + html = "just plain text" + idx = self.injector._find_insertion_point(html) + self.assertEqual(idx, -1) + + def test_has_existing_web_sdk_plain(self): + """Test _has_existing_web_sdk with plain content.""" + content = b"" + self.assertTrue(self.injector._has_existing_web_sdk(content)) + + def test_has_existing_web_sdk_no_sdk(self): + """Test _has_existing_web_sdk with no SDK present.""" + content = b"hello" + self.assertFalse(self.injector._has_existing_web_sdk(content)) + + def test_has_existing_web_sdk_gzip(self): + """Test _has_existing_web_sdk with gzip compressed content.""" + content = gzip.compress(b"") + self.assertTrue(self.injector._has_existing_web_sdk(content, "gzip")) + + def test_appears_compressed_gzip(self): + """Test _appears_compressed detects gzip magic bytes.""" + self.assertTrue(self.injector._appears_compressed(b"\x1f\x8b\x08rest")) + + def test_appears_compressed_zlib(self): + """Test _appears_compressed detects zlib magic bytes.""" + self.assertTrue(self.injector._appears_compressed(b"\x78\x9c\x00rest")) + + def test_appears_compressed_short_content(self): + """Test _appears_compressed returns False for short content.""" + self.assertFalse(self.injector._appears_compressed(b"ab")) + + def test_appears_compressed_plain(self): + """Test _appears_compressed returns False for plain content.""" + self.assertFalse(self.injector._appears_compressed(b"hello")) + + def test_decompress_content_deflate(self): + """Test _decompress_content with deflate encoding.""" + import zlib + + original = b"hello" + compressed = zlib.compress(original) + result = self.injector._decompress_content(compressed, "deflate") + self.assertEqual(result, original) + + def test_compress_content_deflate(self): + """Test _compress_content with deflate encoding.""" + import zlib + + original = b"hello" + result = self.injector._compress_content(original, "deflate") + decompressed = zlib.decompress(result) + self.assertEqual(decompressed, original) + + @patch("microsoft.opentelemetry._azure_monitor._browser_sdk_loader.snippet_injector.HAS_ZLIB", False) + def test_decompress_content_deflate_not_available(self): + """Test _decompress_content when zlib is not available.""" + content = b"some content" + result = self.injector._decompress_content(content, "deflate") + self.assertEqual(result, content) + + @patch("microsoft.opentelemetry._azure_monitor._browser_sdk_loader.snippet_injector.HAS_ZLIB", False) + def test_compress_content_deflate_not_available(self): + """Test _compress_content when zlib is not available.""" + content = b"some content" + result = self.injector._compress_content(content, "deflate") + self.assertEqual(result, content) + + def test_format_config_value_dict(self): + """Test _format_config_value with dict value.""" + result = self.injector._format_config_value({"key": "val"}) + self.assertIn("key", result) + self.assertIn('"val"', result) + + def test_format_config_value_other(self): + """Test _format_config_value with unsupported type.""" + result = self.injector._format_config_value([1, 2, 3]) + self.assertEqual(result, '"[1, 2, 3]"') + + def test_get_decompressed_content_caching(self): + """Test _get_decompressed_content uses cache on second call.""" + content = gzip.compress(b"hello") + # First call populates cache + result1 = self.injector._get_decompressed_content(content, "gzip") + # Second call should use cache + result2 = self.injector._get_decompressed_content(content, "gzip") + self.assertEqual(result1, result2) + self.assertEqual(result1, b"hello") + + def test_get_decompressed_content_auto_detect_gzip(self): + """Test _get_decompressed_content auto-detects gzip without encoding.""" + original = b"hello" + content = gzip.compress(original) + result = self.injector._get_decompressed_content(content, None) + self.assertEqual(result, original) + + def test_inject_with_compression_gzip_recompress(self): + """Test inject_with_compression recompresses after injection.""" + html = b"Test" + compressed = gzip.compress(html) + result_content, result_encoding = self.injector.inject_with_compression(compressed, "gzip") + self.assertEqual(result_encoding, "gzip") + decompressed = gzip.decompress(result_content) + self.assertIn(b"appInsightsSDK", decompressed) + if __name__ == "__main__": unittest.main() diff --git a/tests/azure_monitor/test_configure.py b/tests/azure_monitor/test_configure.py index 87cafb0c..80228a22 100644 --- a/tests/azure_monitor/test_configure.py +++ b/tests/azure_monitor/test_configure.py @@ -18,7 +18,6 @@ from microsoft.opentelemetry._azure_monitor._configure import ( _send_attach_warning, - _setup_instrumentations, _setup_live_metrics, _setup_logging, _setup_metrics, @@ -26,6 +25,7 @@ configure_azure_monitor, ) from microsoft.opentelemetry._azure_monitor._diagnostics.diagnostic_logging import _DISTRO_DETECTS_ATTACH +from microsoft.opentelemetry._distro import _setup_instrumentations TEST_RESOURCE = Resource({"foo": "bar"}) @@ -36,7 +36,10 @@ class TestConfigure(unittest.TestCase): "microsoft.opentelemetry._azure_monitor._configure._send_attach_warning", ) @patch( - "microsoft.opentelemetry._azure_monitor._configure._setup_instrumentations", + "microsoft.opentelemetry._azure_monitor._configure._setup_browser_sdk_loader", + ) + @patch( + "microsoft.opentelemetry._azure_monitor._configure._setup_azure_instrumentations", ) @patch( "microsoft.opentelemetry._azure_monitor._configure._setup_live_metrics", @@ -56,7 +59,8 @@ def test_configure_azure_monitor( logging_mock, metrics_mock, live_metrics_mock, - instrumentation_mock, + azure_instr_mock, + browser_sdk_mock, detect_attach_mock, ): kwargs = { @@ -67,11 +71,15 @@ def test_configure_azure_monitor( logging_mock.assert_called_once() metrics_mock.assert_called_once() live_metrics_mock.assert_called_once() - instrumentation_mock.assert_called_once() + azure_instr_mock.assert_called_once() + browser_sdk_mock.assert_called_once() detect_attach_mock.assert_called_once() @patch( - "microsoft.opentelemetry._azure_monitor._configure._setup_instrumentations", + "microsoft.opentelemetry._azure_monitor._configure._setup_browser_sdk_loader", + ) + @patch( + "microsoft.opentelemetry._azure_monitor._configure._setup_azure_instrumentations", ) @patch( "microsoft.opentelemetry._azure_monitor._configure._setup_live_metrics", @@ -95,7 +103,8 @@ def test_configure_azure_monitor_disable_tracing( logging_mock, metrics_mock, live_metrics_mock, - instrumentation_mock, + azure_instr_mock, + browser_sdk_mock, ): configurations = { "connection_string": "test_cs", @@ -122,12 +131,15 @@ def test_configure_azure_monitor_disable_tracing( logging_mock.assert_called_once_with(configurations) metrics_mock.assert_called_once_with(configurations) live_metrics_mock.assert_called_once_with(configurations) - instrumentation_mock.assert_called_once_with(configurations) + azure_instr_mock.assert_called_once_with(configurations) # Assert setup_metrics is called before setup_logging self.assertLess(call_order.index("metrics"), call_order.index("logging")) @patch( - "microsoft.opentelemetry._azure_monitor._configure._setup_instrumentations", + "microsoft.opentelemetry._azure_monitor._configure._setup_browser_sdk_loader", + ) + @patch( + "microsoft.opentelemetry._azure_monitor._configure._setup_azure_instrumentations", ) @patch( "microsoft.opentelemetry._azure_monitor._configure._setup_live_metrics", @@ -151,7 +163,8 @@ def test_configure_azure_monitor_disable_logging( logging_mock, metrics_mock, live_metrics_mock, - instrumentation_mock, + azure_instr_mock, + browser_sdk_mock, ): configurations = { "connection_string": "test_cs", @@ -173,12 +186,15 @@ def test_configure_azure_monitor_disable_logging( logging_mock.assert_not_called() metrics_mock.assert_called_once_with(configurations) live_metrics_mock.assert_called_once_with(configurations) - instrumentation_mock.assert_called_once_with(configurations) + azure_instr_mock.assert_called_once_with(configurations) # Assert setup_metrics is called before setup_tracing self.assertLess(call_order.index("metrics"), call_order.index("tracing")) @patch( - "microsoft.opentelemetry._azure_monitor._configure._setup_instrumentations", + "microsoft.opentelemetry._azure_monitor._configure._setup_browser_sdk_loader", + ) + @patch( + "microsoft.opentelemetry._azure_monitor._configure._setup_azure_instrumentations", ) @patch( "microsoft.opentelemetry._azure_monitor._configure._setup_live_metrics", @@ -202,7 +218,8 @@ def test_configure_azure_monitor_disable_metrics( logging_mock, metrics_mock, live_metrics_mock, - instrumentation_mock, + azure_instr_mock, + browser_sdk_mock, ): configurations = { "connection_string": "test_cs", @@ -219,10 +236,13 @@ def test_configure_azure_monitor_disable_metrics( logging_mock.assert_called_once_with(configurations) metrics_mock.assert_not_called() live_metrics_mock.assert_called_once_with(configurations) - instrumentation_mock.assert_called_once_with(configurations) + azure_instr_mock.assert_called_once_with(configurations) @patch( - "microsoft.opentelemetry._azure_monitor._configure._setup_instrumentations", + "microsoft.opentelemetry._azure_monitor._configure._setup_browser_sdk_loader", + ) + @patch( + "microsoft.opentelemetry._azure_monitor._configure._setup_azure_instrumentations", ) @patch( "microsoft.opentelemetry._azure_monitor._configure._setup_live_metrics", @@ -246,7 +266,8 @@ def test_configure_azure_monitor_enable_live_metrics( logging_mock, metrics_mock, live_metrics_mock, - instrumentation_mock, + azure_instr_mock, + browser_sdk_mock, ): configurations = { "connection_string": "test_cs", @@ -268,13 +289,16 @@ def test_configure_azure_monitor_enable_live_metrics( logging_mock.assert_called_once_with(configurations) metrics_mock.assert_called_once_with(configurations) live_metrics_mock.assert_called_once_with(configurations) - instrumentation_mock.assert_called_once_with(configurations) + azure_instr_mock.assert_called_once_with(configurations) # Assert setup_metrics is called before setup_logging and setup_tracing self.assertLess(call_order.index("metrics"), call_order.index("logging")) self.assertLess(call_order.index("metrics"), call_order.index("tracing")) @patch( - "microsoft.opentelemetry._azure_monitor._configure._setup_instrumentations", + "microsoft.opentelemetry._azure_monitor._configure._setup_browser_sdk_loader", + ) + @patch( + "microsoft.opentelemetry._azure_monitor._configure._setup_azure_instrumentations", ) @patch( "microsoft.opentelemetry._azure_monitor._configure._setup_live_metrics", @@ -298,7 +322,8 @@ def test_configure_azure_monitor_disable_perf_counters( logging_mock, metrics_mock, live_metrics_mock, - instrumentation_mock, + azure_instr_mock, + browser_sdk_mock, ): configurations = { "connection_string": "test_cs", @@ -315,7 +340,7 @@ def test_configure_azure_monitor_disable_perf_counters( logging_mock.assert_called_once_with(configurations) metrics_mock.assert_called_once_with(configurations) live_metrics_mock.assert_not_called() - instrumentation_mock.assert_called_once_with(configurations) + azure_instr_mock.assert_called_once_with(configurations) @patch( "microsoft.opentelemetry._azure_monitor._configure._PerformanceCountersSpanProcessor", @@ -357,9 +382,6 @@ def test_setup_tracing( pcsp_mock.return_value = pcsp_init_mock custom_sp = Mock() - settings_mock = Mock() - opentelemetry_span_mock = Mock() - configurations = { "connection_string": "test_cs", "enable_performance_counters": True, @@ -368,27 +390,15 @@ def test_setup_tracing( "span_processors": [custom_sp], "resource": TEST_RESOURCE, } - with patch("microsoft.opentelemetry._azure_monitor._configure._is_instrumentation_enabled") as instr_mock: - instr_mock.return_value = True - with patch.dict( - "sys.modules", - { - "azure.core.settings": Mock(settings=settings_mock), - "azure.core.tracing.ext.opentelemetry_span": Mock(OpenTelemetrySpan=opentelemetry_span_mock), - }, - ): - _setup_tracing(configurations) - sampler_mock.assert_called_once_with(sampling_ratio=0.5) - tp_mock.assert_called_once_with(sampler=sampler_init_mock, resource=TEST_RESOURCE) - set_tracer_provider_mock.assert_called_once_with(tp_init_mock) - trace_exporter_mock.assert_called_once_with(**configurations) - bsp_mock.assert_called_once_with(trace_exp_init_mock) - self.assertEqual(tp_init_mock.add_span_processor.call_count, 3) - tp_init_mock.add_span_processor.assert_has_calls( - [call(custom_sp), call(pcsp_init_mock), call(bsp_init_mock)] - ) - self.assertEqual(settings_mock.tracing_implementation, opentelemetry_span_mock) - pcsp_mock.assert_called_once_with() + _setup_tracing(configurations) + sampler_mock.assert_called_once_with(sampling_ratio=0.5) + tp_mock.assert_called_once_with(sampler=sampler_init_mock, resource=TEST_RESOURCE) + set_tracer_provider_mock.assert_called_once_with(tp_init_mock) + trace_exporter_mock.assert_called_once_with(**configurations) + bsp_mock.assert_called_once_with(trace_exp_init_mock) + self.assertEqual(tp_init_mock.add_span_processor.call_count, 3) + tp_init_mock.add_span_processor.assert_has_calls([call(custom_sp), call(pcsp_init_mock), call(bsp_init_mock)]) + pcsp_mock.assert_called_once_with() @patch( "microsoft.opentelemetry._azure_monitor._configure._PerformanceCountersSpanProcessor", @@ -430,9 +440,6 @@ def test_setup_tracing_rate_limited_sampler( pcsp_mock.return_value = pcsp_init_mock custom_sp = Mock() - settings_mock = Mock() - opentelemetry_span_mock = Mock() - configurations = { "connection_string": "test_cs", "enable_performance_counters": True, @@ -441,27 +448,15 @@ def test_setup_tracing_rate_limited_sampler( "span_processors": [custom_sp], "resource": TEST_RESOURCE, } - with patch("microsoft.opentelemetry._azure_monitor._configure._is_instrumentation_enabled") as instr_mock: - instr_mock.return_value = True - with patch.dict( - "sys.modules", - { - "azure.core.settings": Mock(settings=settings_mock), - "azure.core.tracing.ext.opentelemetry_span": Mock(OpenTelemetrySpan=opentelemetry_span_mock), - }, - ): - _setup_tracing(configurations) - sampler_mock.assert_called_once_with(target_spans_per_second_limit=2.0) - tp_mock.assert_called_once_with(sampler=sampler_init_mock, resource=TEST_RESOURCE) - set_tracer_provider_mock.assert_called_once_with(tp_init_mock) - trace_exporter_mock.assert_called_once_with(**configurations) - bsp_mock.assert_called_once_with(trace_exp_init_mock) - self.assertEqual(tp_init_mock.add_span_processor.call_count, 3) - tp_init_mock.add_span_processor.assert_has_calls( - [call(custom_sp), call(pcsp_init_mock), call(bsp_init_mock)] - ) - self.assertEqual(settings_mock.tracing_implementation, opentelemetry_span_mock) - pcsp_mock.assert_called_once_with() + _setup_tracing(configurations) + sampler_mock.assert_called_once_with(target_spans_per_second_limit=2.0) + tp_mock.assert_called_once_with(sampler=sampler_init_mock, resource=TEST_RESOURCE) + set_tracer_provider_mock.assert_called_once_with(tp_init_mock) + trace_exporter_mock.assert_called_once_with(**configurations) + bsp_mock.assert_called_once_with(trace_exp_init_mock) + self.assertEqual(tp_init_mock.add_span_processor.call_count, 3) + tp_init_mock.add_span_processor.assert_has_calls([call(custom_sp), call(pcsp_init_mock), call(bsp_init_mock)]) + pcsp_mock.assert_called_once_with() @patch( "microsoft.opentelemetry._azure_monitor._configure._PerformanceCountersSpanProcessor", @@ -503,9 +498,6 @@ def test_setup_tracing_perf_counters_disabled( pcsp_mock.return_value = pcsp_init_mock custom_sp = Mock() - settings_mock = Mock() - opentelemetry_span_mock = Mock() - configurations = { "connection_string": "test_cs", "enable_performance_counters": False, @@ -514,25 +506,15 @@ def test_setup_tracing_perf_counters_disabled( "span_processors": [custom_sp], "resource": TEST_RESOURCE, } - with patch("microsoft.opentelemetry._azure_monitor._configure._is_instrumentation_enabled") as instr_mock: - instr_mock.return_value = True - with patch.dict( - "sys.modules", - { - "azure.core.settings": Mock(settings=settings_mock), - "azure.core.tracing.ext.opentelemetry_span": Mock(OpenTelemetrySpan=opentelemetry_span_mock), - }, - ): - _setup_tracing(configurations) - sampler_mock.assert_called_once_with(sampling_ratio=0.5) - tp_mock.assert_called_once_with(sampler=sampler_init_mock, resource=TEST_RESOURCE) - set_tracer_provider_mock.assert_called_once_with(tp_init_mock) - trace_exporter_mock.assert_called_once_with(**configurations) - bsp_mock.assert_called_once_with(trace_exp_init_mock) - self.assertEqual(tp_init_mock.add_span_processor.call_count, 2) - tp_init_mock.add_span_processor.assert_has_calls([call(custom_sp), call(bsp_init_mock)]) - self.assertEqual(settings_mock.tracing_implementation, opentelemetry_span_mock) - pcsp_mock.assert_not_called() + _setup_tracing(configurations) + sampler_mock.assert_called_once_with(sampling_ratio=0.5) + tp_mock.assert_called_once_with(sampler=sampler_init_mock, resource=TEST_RESOURCE) + set_tracer_provider_mock.assert_called_once_with(tp_init_mock) + trace_exporter_mock.assert_called_once_with(**configurations) + bsp_mock.assert_called_once_with(trace_exp_init_mock) + self.assertEqual(tp_init_mock.add_span_processor.call_count, 2) + tp_init_mock.add_span_processor.assert_has_calls([call(custom_sp), call(bsp_init_mock)]) + pcsp_mock.assert_not_called() @patch("microsoft.opentelemetry._azure_monitor._configure._PerformanceCountersLogRecordProcessor") @patch("microsoft.opentelemetry._azure_monitor._configure.getLogger") @@ -876,10 +858,10 @@ def test_setup_live_metrics( enable_live_metrics_mock.assert_called_once_with(**configurations) - @patch("microsoft.opentelemetry._azure_monitor._configure._ALL_SUPPORTED_INSTRUMENTED_LIBRARIES", ("test_instr2")) - @patch("microsoft.opentelemetry._azure_monitor._configure._is_instrumentation_enabled") - @patch("microsoft.opentelemetry._azure_monitor._configure.get_dist_dependency_conflicts") - @patch("microsoft.opentelemetry._azure_monitor._configure.entry_points") + @patch("microsoft.opentelemetry._distro._SUPPORTED_INSTRUMENTED_LIBRARIES", ("test_instr2",)) + @patch("microsoft.opentelemetry._distro._is_instrumentation_enabled") + @patch("microsoft.opentelemetry._distro.get_dist_dependency_conflicts") + @patch("microsoft.opentelemetry._distro.entry_points") def test_setup_instrumentations_lib_not_supported( self, iter_mock, @@ -898,34 +880,15 @@ def test_setup_instrumentations_lib_not_supported( dep_mock.return_value = None enabled_mock.return_value = True _setup_instrumentations({}) - dep_mock.assert_called_with(ep2_mock.dist) ep_mock.load.assert_not_called() ep2_mock.load.assert_called_once() instrumentor_mock.instrument.assert_called_once() - @patch("microsoft.opentelemetry._azure_monitor._configure._setup_additional_azure_sdk_instrumentations") - @patch("microsoft.opentelemetry._azure_monitor._configure._ALL_SUPPORTED_INSTRUMENTED_LIBRARIES", ("azure_sdk")) - @patch("microsoft.opentelemetry._azure_monitor._configure._is_instrumentation_enabled") - @patch("microsoft.opentelemetry._azure_monitor._configure.entry_points") - def test_setup_instrumentations_additional_azure( - self, - iter_mock, - enabled_mock, - additional_instrumentations_mock, - ): - ep_mock = Mock() - ep_mock.name = "azure_sdk" - iter_mock.return_value = (ep_mock,) - - enabled_mock.return_value = True - _setup_instrumentations({}) - additional_instrumentations_mock.assert_called_once() - - @patch("microsoft.opentelemetry._azure_monitor._configure._ALL_SUPPORTED_INSTRUMENTED_LIBRARIES", ("test_instr")) - @patch("microsoft.opentelemetry._azure_monitor._configure._is_instrumentation_enabled") - @patch("microsoft.opentelemetry._azure_monitor._configure._logger") - @patch("microsoft.opentelemetry._azure_monitor._configure.get_dist_dependency_conflicts") - @patch("microsoft.opentelemetry._azure_monitor._configure.entry_points") + @patch("microsoft.opentelemetry._distro._SUPPORTED_INSTRUMENTED_LIBRARIES", ("test_instr",)) + @patch("microsoft.opentelemetry._distro._is_instrumentation_enabled") + @patch("microsoft.opentelemetry._distro._logger") + @patch("microsoft.opentelemetry._distro.get_dist_dependency_conflicts") + @patch("microsoft.opentelemetry._distro.entry_points") def test_setup_instrumentations_conflict( self, iter_mock, @@ -943,16 +906,15 @@ def test_setup_instrumentations_conflict( dep_mock.return_value = True enabled_mock.return_value = True _setup_instrumentations({}) - dep_mock.assert_called_with(ep_mock.dist) ep_mock.load.assert_not_called() instrumentor_mock.instrument.assert_not_called() logger_mock.debug.assert_called_once() - @patch("microsoft.opentelemetry._azure_monitor._configure._ALL_SUPPORTED_INSTRUMENTED_LIBRARIES", ("test_instr")) - @patch("microsoft.opentelemetry._azure_monitor._configure._is_instrumentation_enabled") - @patch("microsoft.opentelemetry._azure_monitor._configure._logger") - @patch("microsoft.opentelemetry._azure_monitor._configure.get_dist_dependency_conflicts") - @patch("microsoft.opentelemetry._azure_monitor._configure.entry_points") + @patch("microsoft.opentelemetry._distro._SUPPORTED_INSTRUMENTED_LIBRARIES", ("test_instr",)) + @patch("microsoft.opentelemetry._distro._is_instrumentation_enabled") + @patch("microsoft.opentelemetry._distro._logger") + @patch("microsoft.opentelemetry._distro.get_dist_dependency_conflicts") + @patch("microsoft.opentelemetry._distro.entry_points") def test_setup_instrumentations_exception( self, iter_mock, @@ -970,19 +932,18 @@ def test_setup_instrumentations_exception( dep_mock.return_value = None enabled_mock.return_value = True _setup_instrumentations({}) - dep_mock.assert_called_with(ep_mock.dist) ep_mock.load.assert_called_once() instrumentor_mock.instrument.assert_not_called() logger_mock.warning.assert_called_once() @patch( - "microsoft.opentelemetry._azure_monitor._configure._ALL_SUPPORTED_INSTRUMENTED_LIBRARIES", + "microsoft.opentelemetry._distro._SUPPORTED_INSTRUMENTED_LIBRARIES", ("test_instr1", "test_instr2"), ) - @patch("microsoft.opentelemetry._azure_monitor._configure._is_instrumentation_enabled") - @patch("microsoft.opentelemetry._azure_monitor._configure._logger") - @patch("microsoft.opentelemetry._azure_monitor._configure.get_dist_dependency_conflicts") - @patch("microsoft.opentelemetry._azure_monitor._configure.entry_points") + @patch("microsoft.opentelemetry._distro._is_instrumentation_enabled") + @patch("microsoft.opentelemetry._distro._logger") + @patch("microsoft.opentelemetry._distro.get_dist_dependency_conflicts") + @patch("microsoft.opentelemetry._distro.entry_points") def test_setup_instrumentations_disabled( self, iter_mock, @@ -1002,7 +963,6 @@ def test_setup_instrumentations_disabled( dep_mock.return_value = None enabled_mock.side_effect = [False, True] _setup_instrumentations({}) - dep_mock.assert_called_with(ep2_mock.dist) ep_mock.load.assert_not_called() ep2_mock.load.assert_called_once() instrumentor_mock.instrument.assert_called_once() diff --git a/tests/azure_monitor/utils/test_instrumentation.py b/tests/azure_monitor/utils/test_instrumentation.py new file mode 100644 index 00000000..bd8d8779 --- /dev/null +++ b/tests/azure_monitor/utils/test_instrumentation.py @@ -0,0 +1,127 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import unittest +from unittest.mock import MagicMock + +from packaging.requirements import Requirement + +from microsoft.opentelemetry._azure_monitor._utils.instrumentation import ( + DependencyConflict, + _get_dependency_conflicts_any, + get_dependency_conflicts, + get_dist_dependency_conflicts, +) + + +class TestDependencyConflict(unittest.TestCase): + def test_str_required(self): + c = DependencyConflict(required="foo>=1.0", found="foo 0.9") + self.assertIn("foo>=1.0", str(c)) + self.assertIn("foo 0.9", str(c)) + + def test_str_required_any(self): + c = DependencyConflict(required_any=["a", "b"], found_any=["a 0.1"]) + result = str(c) + self.assertIn("requested any", result) + self.assertIn("['a', 'b']", result) + + def test_str_no_required_with_found_any(self): + c = DependencyConflict(found_any=["x 1.0"]) + result = str(c) + self.assertIn("requested any", result) + + +class TestGetDistDependencyConflicts(unittest.TestCase): + def test_no_requires(self): + dist = MagicMock() + dist.requires = None + self.assertIsNone(get_dist_dependency_conflicts(dist)) + + def test_empty_requires(self): + dist = MagicMock() + dist.requires = [] + self.assertIsNone(get_dist_dependency_conflicts(dist)) + + def test_no_matching_extras(self): + dist = MagicMock() + dist.requires = ["some-package>=1.0"] + self.assertIsNone(get_dist_dependency_conflicts(dist)) + + def test_instruments_deps(self): + dist = MagicMock() + # Simulate a dep with 'extra == "instruments"' marker + dist.requires = ['pytest>=8.0; extra == "instruments"'] + result = get_dist_dependency_conflicts(dist) + # pytest is installed, so no conflict + self.assertIsNone(result) + + def test_instruments_any_deps(self): + dist = MagicMock() + dist.requires = ['pytest>=8.0; extra == "instruments-any"'] + result = get_dist_dependency_conflicts(dist) + self.assertIsNone(result) + + +class TestGetDependencyConflicts(unittest.TestCase): + def test_no_deps(self): + self.assertIsNone(get_dependency_conflicts([])) + + def test_requirement_object(self): + req = Requirement("pytest>=8.0") + self.assertIsNone(get_dependency_conflicts([req])) + + def test_string_dep_satisfied(self): + self.assertIsNone(get_dependency_conflicts(["pytest>=8.0"])) + + def test_string_dep_not_found(self): + result = get_dependency_conflicts(["nonexistent-package-xyz>=1.0"]) + self.assertIsNotNone(result) + self.assertIn("nonexistent-package-xyz", str(result)) + + def test_string_dep_wrong_version(self): + result = get_dependency_conflicts(["pytest>=99999.0"]) + self.assertIsNotNone(result) + self.assertIsNotNone(result.found) + + def test_invalid_requirement(self): + result = get_dependency_conflicts(["this is not valid!!!"]) + self.assertIsNotNone(result) + + def test_with_deps_any(self): + result = get_dependency_conflicts([], ["pytest>=8.0"]) + self.assertIsNone(result) + + +class TestGetDependencyConflictsAny(unittest.TestCase): + def test_empty(self): + self.assertIsNone(_get_dependency_conflicts_any([])) + + def test_one_satisfied(self): + self.assertIsNone(_get_dependency_conflicts_any(["pytest>=8.0"])) + + def test_one_not_found(self): + result = _get_dependency_conflicts_any(["nonexistent-xyz>=1.0"]) + self.assertIsNotNone(result) + self.assertIsNotNone(result.required_any) + + def test_one_wrong_version(self): + result = _get_dependency_conflicts_any(["pytest>=99999.0"]) + self.assertIsNotNone(result) + self.assertTrue(len(result.found_any) > 0) + + def test_mixed_one_satisfied(self): + # One not found + one satisfied = no conflict + result = _get_dependency_conflicts_any(["nonexistent-xyz>=1.0", "pytest>=8.0"]) + self.assertIsNone(result) + + def test_requirement_object(self): + req = Requirement("pytest>=8.0") + self.assertIsNone(_get_dependency_conflicts_any([req])) + + def test_invalid_requirement(self): + result = _get_dependency_conflicts_any(["this is not valid!!!"]) + self.assertIsNotNone(result) diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 466e715e..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,6 +0,0 @@ -import os - -# Exclude tests/azure_monitor from collection — these tests require -# azure-monitor-opentelemetry and related packages that are not -# installed as part of the base test dependencies. -collect_ignore = [os.path.join(os.path.dirname(__file__), "azure_monitor")] diff --git a/tests/test_distro.py b/tests/test_distro.py index 1ffacbdf..fe946822 100644 --- a/tests/test_distro.py +++ b/tests/test_distro.py @@ -23,6 +23,7 @@ use_microsoft_opentelemetry, _setup_tracing, _setup_metrics, + _setup_logging, ) TEST_RESOURCE = Resource({"service.name": "test-service"}) @@ -359,5 +360,68 @@ def test_all_options_end_to_end(self, tracing_mock, metrics_mock, logging_mock, self.assertNotIn("enable_azure_monitor", actual) +class TestSetupLogging(unittest.TestCase): + """Tests for _setup_logging().""" + + @patch("opentelemetry._logs.set_logger_provider") + def test_creates_logger_provider(self, set_lp_mock): + """_setup_logging creates and registers a LoggerProvider.""" + lp = _setup_logging(TEST_RESOURCE, {}) + self.assertIsNotNone(lp) + set_lp_mock.assert_called_once() + + @patch("opentelemetry._logs.set_logger_provider") + def test_adds_log_record_processors(self, set_lp_mock): + """_setup_logging adds user-supplied log record processors.""" + lrp = MagicMock() + lp = _setup_logging(TEST_RESOURCE, {"log_record_processors": [lrp]}) + self.assertIsNotNone(lp) + + @patch("opentelemetry._logs.set_logger_provider") + def test_attaches_handler_for_logger_name(self, set_lp_mock): + """_setup_logging attaches a LoggingHandler when logger_name is specified.""" + import logging + + test_logger_name = "test_distro_logging_handler" + test_logger = logging.getLogger(test_logger_name) + original_handlers = list(test_logger.handlers) + + try: + _setup_logging(TEST_RESOURCE, {"logger_name": test_logger_name}) + # Should have added a handler + self.assertGreater(len(test_logger.handlers), len(original_handlers)) + finally: + # Cleanup: remove any handlers we added + for h in list(test_logger.handlers): + if h not in original_handlers: + test_logger.removeHandler(h) + + @patch("opentelemetry._logs.set_logger_provider") + def test_attaches_handler_with_formatter(self, set_lp_mock): + """_setup_logging sets formatter on handler when logging_formatter is provided.""" + import logging + + test_logger_name = "test_distro_logging_formatter" + test_logger = logging.getLogger(test_logger_name) + original_handlers = list(test_logger.handlers) + formatter = logging.Formatter("%(message)s") + + try: + _setup_logging( + TEST_RESOURCE, + { + "logger_name": test_logger_name, + "logging_formatter": formatter, + }, + ) + new_handlers = [h for h in test_logger.handlers if h not in original_handlers] + self.assertTrue(len(new_handlers) > 0) + self.assertEqual(new_handlers[0].formatter, formatter) + finally: + for h in list(test_logger.handlers): + if h not in original_handlers: + test_logger.removeHandler(h) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_instrumentation.py b/tests/test_instrumentation.py new file mode 100644 index 00000000..2a47f2b1 --- /dev/null +++ b/tests/test_instrumentation.py @@ -0,0 +1,127 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import unittest +from unittest.mock import MagicMock + +from packaging.requirements import Requirement + +from microsoft.opentelemetry._instrumentation import ( + DependencyConflict, + _get_dependency_conflicts_any, + get_dependency_conflicts, + get_dist_dependency_conflicts, +) + + +class TestDependencyConflict(unittest.TestCase): + def test_str_required(self): + c = DependencyConflict(required="foo>=1.0", found="foo 0.9") + self.assertIn("foo>=1.0", str(c)) + self.assertIn("foo 0.9", str(c)) + + def test_str_required_any(self): + c = DependencyConflict(required_any=["a", "b"], found_any=["a 0.1"]) + result = str(c) + self.assertIn("requested any", result) + + def test_str_no_required_with_found_any(self): + c = DependencyConflict(found_any=["x 1.0"]) + result = str(c) + self.assertIn("requested any", result) + + def test_str_default(self): + c = DependencyConflict(required="pkg>=1.0") + self.assertIn("pkg>=1.0", str(c)) + self.assertIn("None", str(c)) + + +class TestGetDistDependencyConflicts(unittest.TestCase): + def test_no_requires(self): + dist = MagicMock() + dist.requires = None + self.assertIsNone(get_dist_dependency_conflicts(dist)) + + def test_empty_requires(self): + dist = MagicMock() + dist.requires = [] + self.assertIsNone(get_dist_dependency_conflicts(dist)) + + def test_instruments_dep_satisfied(self): + dist = MagicMock() + dist.requires = ['pytest>=8.0; extra == "instruments"'] + result = get_dist_dependency_conflicts(dist) + self.assertIsNone(result) + + def test_instruments_any_dep_satisfied(self): + dist = MagicMock() + dist.requires = ['pytest>=8.0; extra == "instruments-any"'] + result = get_dist_dependency_conflicts(dist) + self.assertIsNone(result) + + def test_no_extra_marker(self): + dist = MagicMock() + dist.requires = ["pytest>=8.0"] + self.assertIsNone(get_dist_dependency_conflicts(dist)) + + +class TestGetDependencyConflicts(unittest.TestCase): + def test_no_deps(self): + self.assertIsNone(get_dependency_conflicts([])) + + def test_requirement_object(self): + req = Requirement("pytest>=8.0") + self.assertIsNone(get_dependency_conflicts([req])) + + def test_string_dep_satisfied(self): + self.assertIsNone(get_dependency_conflicts(["pytest>=8.0"])) + + def test_string_dep_not_found(self): + result = get_dependency_conflicts(["nonexistent-xyz-package>=1.0"]) + self.assertIsNotNone(result) + + def test_string_dep_wrong_version(self): + result = get_dependency_conflicts(["pytest>=99999.0"]) + self.assertIsNotNone(result) + self.assertIsNotNone(result.found) + + def test_invalid_requirement(self): + result = get_dependency_conflicts(["this is not valid!!!"]) + self.assertIsNotNone(result) + + def test_with_deps_any_satisfied(self): + result = get_dependency_conflicts([], ["pytest>=8.0"]) + self.assertIsNone(result) + + +class TestGetDependencyConflictsAny(unittest.TestCase): + def test_empty(self): + self.assertIsNone(_get_dependency_conflicts_any([])) + + def test_one_satisfied(self): + self.assertIsNone(_get_dependency_conflicts_any(["pytest>=8.0"])) + + def test_one_not_found(self): + result = _get_dependency_conflicts_any(["nonexistent-xyz>=1.0"]) + self.assertIsNotNone(result) + self.assertIsNotNone(result.required_any) + + def test_one_wrong_version(self): + result = _get_dependency_conflicts_any(["pytest>=99999.0"]) + self.assertIsNotNone(result) + self.assertTrue(len(result.found_any) > 0) + + def test_mixed_one_satisfied(self): + result = _get_dependency_conflicts_any(["nonexistent-xyz>=1.0", "pytest>=8.0"]) + self.assertIsNone(result) + + def test_requirement_object(self): + req = Requirement("pytest>=8.0") + self.assertIsNone(_get_dependency_conflicts_any([req])) + + def test_invalid_requirement(self): + result = _get_dependency_conflicts_any(["this is not valid!!!"]) + self.assertIsNotNone(result) diff --git a/tox.ini b/tox.ini index 6ed738ab..2c668cc6 100644 --- a/tox.ini +++ b/tox.ini @@ -55,4 +55,4 @@ deps = pytest>=8.0 commands = python -m pip install -e {tox_root} - python -m pytest {tox_root}/tests {posargs} + python -m pytest --cov --cov-report=term-missing {tox_root}/tests {posargs}