diff options
Diffstat (limited to 'opentelemetry-sdk/tests')
16 files changed, 1796 insertions, 102 deletions
diff --git a/opentelemetry-sdk/tests/_configuration/file/test_env_substitution.py b/opentelemetry-sdk/tests/_configuration/file/test_env_substitution.py index fee46c9..b9f71f3 100644 --- a/opentelemetry-sdk/tests/_configuration/file/test_env_substitution.py +++ b/opentelemetry-sdk/tests/_configuration/file/test_env_substitution.py @@ -119,7 +119,7 @@ line2: value2""" {"SERVICE_NAME": "legit-service\nmalicious_key: injected_value"}, ): result = substitute_env_vars( - "file_format: '0.1'\nservice_name: ${SERVICE_NAME}" + "file_format: '1.0'\nservice_name: ${SERVICE_NAME}" ) parsed = yaml.safe_load(result) self.assertNotIn("malicious_key", parsed) diff --git a/opentelemetry-sdk/tests/_configuration/file/test_loader.py b/opentelemetry-sdk/tests/_configuration/file/test_loader.py index b38f301..ecb485c 100644 --- a/opentelemetry-sdk/tests/_configuration/file/test_loader.py +++ b/opentelemetry-sdk/tests/_configuration/file/test_loader.py @@ -7,11 +7,32 @@ import unittest from pathlib import Path from unittest.mock import patch +from opentelemetry.sdk._configuration._tracer_provider import ( + create_tracer_provider, +) from opentelemetry.sdk._configuration.file import ( ConfigurationError, load_config_file, ) +from opentelemetry.sdk._configuration.models import ( + BatchSpanProcessor as BatchSpanProcessorConfig, +) from opentelemetry.sdk._configuration.models import OpenTelemetryConfiguration +from opentelemetry.sdk._configuration.models import ( + ParentBasedSampler as ParentBasedSamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanProcessor as SpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + TracerProvider as TracerProviderConfig, +) +from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider +from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, +) +from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased class TestConfigLoader(unittest.TestCase): @@ -231,3 +252,118 @@ class TestConfigLoader(unittest.TestCase): self.assertIn("schema", str(ctx.exception).lower()) finally: os.unlink(temp_path) + + +class TestConfigLoaderEndToEnd(unittest.TestCase): + """Smoke-test the full YAML -> typed config -> SDK object pipeline. + + Unit tests in test_conversion.py exercise the dict-to-dataclass + conversion in isolation; these tests verify it composes with the + real loader and downstream factory functions on a representative + nested configuration. + """ + + _YAML = """ +file_format: '1.0' +tracer_provider: + processors: + - batch: + exporter: + console: {} + sampler: + parent_based: + root: + trace_id_ratio_based: {ratio: 0.5} +""" + + def _load(self) -> OpenTelemetryConfiguration: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False + ) as fh: + fh.write(self._YAML) + path = fh.name + try: + return load_config_file(path) + finally: + os.unlink(path) + + def test_nested_fields_are_typed_dataclasses(self): + config = self._load() + + self.assertIsInstance(config.tracer_provider, TracerProviderConfig) + self.assertIsInstance( + config.tracer_provider.sampler.parent_based, + ParentBasedSamplerConfig, + ) + # Lists of dataclasses are converted element-wise. + self.assertIsInstance( + config.tracer_provider.processors[0], SpanProcessorConfig + ) + self.assertIsInstance( + config.tracer_provider.processors[0].batch, + BatchSpanProcessorConfig, + ) + + # pylint: disable=protected-access + def test_typed_config_feeds_factory_function(self): + config = self._load() + + provider = create_tracer_provider(config.tracer_provider) + + self.assertIsInstance(provider, SdkTracerProvider) + # Sampler wiring from the YAML: parent_based(trace_id_ratio_based(0.5)). + self.assertIsInstance(provider.sampler, ParentBased) + self.assertIsInstance(provider.sampler._root, TraceIdRatioBased) + self.assertEqual(provider.sampler._root.rate, 0.5) + # Span processor wiring from the YAML: batch(console). + processors = provider._active_span_processor._span_processors + self.assertEqual(len(processors), 1) + self.assertIsInstance(processors[0], BatchSpanProcessor) + self.assertIsInstance(processors[0].span_exporter, ConsoleSpanExporter) + + +class TestFileFormatValidation(unittest.TestCase): + """Validate the file_format version per the configuration spec.""" + + @staticmethod + def _load(file_format: str) -> OpenTelemetryConfiguration: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False + ) as fh: + fh.write(f'file_format: "{file_format}"') + path = fh.name + try: + return load_config_file(path) + finally: + os.unlink(path) + + def test_supported_version_is_accepted(self): + config = self._load("1.0") + self.assertEqual(config.file_format, "1.0") + + def test_pre_release_meta_tag_is_accepted(self): + # The meta tag is stripped; "1.0-rc.2" is treated as 1.0. + config = self._load("1.0-rc.2") + self.assertEqual(config.file_format, "1.0-rc.2") + + def test_newer_minor_is_accepted_with_warning(self): + with self.assertLogs( + "opentelemetry.sdk._configuration.file._loader", level="WARNING" + ) as logs: + config = self._load("1.1") + self.assertEqual(config.file_format, "1.1") + self.assertTrue( + any("newer minor version" in message for message in logs.output) + ) + + def test_unsupported_major_is_rejected(self): + for version in ("2.0", "0.4"): + with self.subTest(version=version): + with self.assertRaises(ConfigurationError) as ctx: + self._load(version) + self.assertIn("file_format", str(ctx.exception)) + + def test_malformed_version_is_rejected(self): + with self.assertRaises(ConfigurationError) as ctx: + self._load("not-a-version") + self.assertIn("file_format", str(ctx.exception)) diff --git a/opentelemetry-sdk/tests/_configuration/test_common.py b/opentelemetry-sdk/tests/_configuration/test_common.py index d8236a8..46469f1 100644 --- a/opentelemetry-sdk/tests/_configuration/test_common.py +++ b/opentelemetry-sdk/tests/_configuration/test_common.py @@ -12,6 +12,7 @@ from opentelemetry.sdk._configuration._common import ( _additional_properties, _map_compression, _parse_headers, + _resolve_component, load_entry_point, ) from opentelemetry.sdk._configuration._exceptions import ConfigurationError @@ -289,3 +290,84 @@ class TestGeneratedModelsHaveAdditionalProperties(unittest.TestCase): def test_push_metric_exporter(self): self._assert_supports_additional_properties(PushMetricExporter) + + +class TestResolveComponent(unittest.TestCase): + def setUp(self): + @_additional_properties + @dataclass + class _Config: + builtin_a: dict | None = None + builtin_b: str | None = None + additional_properties: ClassVar[dict[str, Any]] + + self.cls = _Config + self.registry = { + "builtin_a": lambda v: ("resolved_a", v), + "builtin_b": lambda v: ("resolved_b", v), + } + + def test_resolves_builtin_from_registry(self): + config = self.cls(builtin_a={"key": "val"}) + result = _resolve_component( + config, self.registry, "test_group", "test component" + ) + self.assertEqual(result, ("resolved_a", {"key": "val"})) + + def test_resolves_plugin_via_entry_point(self): + mock_instance = MagicMock() + mock_class = MagicMock(return_value=mock_instance) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[MagicMock(**{"load.return_value": mock_class})], + ): + # pylint: disable=unexpected-keyword-arg + config = self.cls(my_plugin={"opt": "val"}) + result = _resolve_component( + config, self.registry, "test_group", "test component" + ) + self.assertIs(result, mock_instance) + mock_class.assert_called_once_with(opt="val") + + def test_plugin_with_empty_config(self): + mock_instance = MagicMock() + mock_class = MagicMock(return_value=mock_instance) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[MagicMock(**{"load.return_value": mock_class})], + ): + # pylint: disable=unexpected-keyword-arg + config = self.cls(my_plugin={}) + _resolve_component( + config, self.registry, "test_group", "test component" + ) + mock_class.assert_called_once_with() + + def test_no_component_raises_configuration_error(self): + config = self.cls() + with self.assertRaises(ConfigurationError): + _resolve_component( + config, self.registry, "test_group", "test component" + ) + + def test_plugin_not_found_raises_configuration_error(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + # pylint: disable=unexpected-keyword-arg + config = self.cls(missing_plugin={}) + with self.assertRaises(ConfigurationError): + _resolve_component( + config, self.registry, "test_group", "test component" + ) + + def test_first_registry_match_wins_when_multiple_set(self): + """When multiple built-in fields are set (which the schema should + prevent), the first registry match wins.""" + config = self.cls(builtin_a={"a": 1}, builtin_b="b") + result = _resolve_component( + config, self.registry, "test_group", "test component" + ) + # builtin_a comes first in the registry dict + self.assertEqual(result, ("resolved_a", {"a": 1})) diff --git a/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py new file mode 100644 index 0000000..89b2098 --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py @@ -0,0 +1,89 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# Tests access private members of SDK classes to assert correct configuration. +# pylint: disable=protected-access,no-self-use + +import unittest +from unittest.mock import patch + +from opentelemetry.sdk._configuration import _OTelSDKConfigurator +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk.environment_variables import OTEL_CONFIG_FILE + + +class TestConfiguratorFileRouting(unittest.TestCase): + def tearDown(self): + # _BaseConfigurator caches instances via a singleton; reset so sibling + # tests (e.g. test_configurator.py's CustomConfigurator subclass) are + # not affected by this class's singleton state. + _OTelSDKConfigurator._instance = None + + @patch.dict("os.environ", {}, clear=True) + @patch("opentelemetry.sdk._configuration._initialize_components") + def test_env_var_unset_runs_env_var_path(self, mock_init_components): + _OTelSDKConfigurator()._configure(auto_instrumentation_version="X") + mock_init_components.assert_called_once_with( + auto_instrumentation_version="X" + ) + + @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/tmp/otel.yaml"}) + @patch("opentelemetry.sdk._configuration._sdk.configure_sdk") + @patch("opentelemetry.sdk._configuration.file._loader.load_config_file") + @patch("opentelemetry.sdk._configuration._initialize_components") + def test_env_var_set_routes_to_declarative_path( + self, mock_init_components, mock_load, mock_configure_sdk + ): + sentinel_config = object() + mock_load.return_value = sentinel_config + + _OTelSDKConfigurator()._configure() + + mock_load.assert_called_once_with("/tmp/otel.yaml") + mock_configure_sdk.assert_called_once_with(sentinel_config) + mock_init_components.assert_not_called() + + @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/does/not/exist.yaml"}) + @patch("opentelemetry.sdk._configuration._initialize_components") + def test_env_var_set_missing_file_propagates(self, mock_init_components): + with self.assertRaises(ConfigurationError): + _OTelSDKConfigurator()._configure() + mock_init_components.assert_not_called() + + @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/tmp/otel.yaml"}) + @patch("opentelemetry.sdk._configuration._sdk.configure_sdk") + @patch("opentelemetry.sdk._configuration.file._loader.load_config_file") + def test_env_var_set_with_kwargs_warns_and_ignores( + self, mock_load, mock_configure_sdk + ): + mock_load.return_value = object() + + with self.assertLogs( + "opentelemetry.sdk._configuration", level="WARNING" + ) as captured: + _OTelSDKConfigurator()._configure( + sampler="X", auto_instrumentation_version="Y" + ) + + self.assertTrue( + any( + "OTEL_CONFIG_FILE" in msg and "sampler" in msg + for msg in captured.output + ), + f"Expected warning about ignored kwargs, got: {captured.output}", + ) + mock_configure_sdk.assert_called_once() + + @patch.dict("os.environ", {}, clear=True) + @patch("opentelemetry.sdk._configuration._initialize_components") + def test_distro_override_pattern_still_works(self, mock_init_components): + class CustomConfigurator(_OTelSDKConfigurator): + def _configure(self, **kwargs): + kwargs["sampler"] = "TEST_SAMPLER" + super()._configure(**kwargs) + + CustomConfigurator()._configure(auto_instrumentation_version="V") + + mock_init_components.assert_called_once_with( + auto_instrumentation_version="V", sampler="TEST_SAMPLER" + ) diff --git a/opentelemetry-sdk/tests/_configuration/test_conversion.py b/opentelemetry-sdk/tests/_configuration/test_conversion.py new file mode 100644 index 0000000..c6cc082 --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_conversion.py @@ -0,0 +1,110 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# Tests access private members of SDK classes to assert correct configuration. +# pylint: disable=protected-access + +import unittest +from dataclasses import dataclass +from typing import Any, ClassVar + +from opentelemetry.sdk._configuration._common import _additional_properties +from opentelemetry.sdk._configuration._conversion import _dict_to_dataclass +from opentelemetry.sdk._configuration.models import ExemplarFilter + + +@dataclass +class _Inner: + value: int | None = None + + +@dataclass +class _Middle: + inner: _Inner | None = None + items: list[_Inner] | None = None + + +@dataclass +class _Outer: + middle: _Middle | None = None + name: str | None = None + + +@_additional_properties +@dataclass +class _WithExtras: + known: str | None = None + additional_properties: ClassVar[dict[str, Any]] + + +@dataclass +class _WithEnum: + filter: ExemplarFilter | None = None + + +class TestDictToDataclass(unittest.TestCase): + def test_raises_on_non_dataclass(self): + # _dict_to_dataclass is internal and assumes cls is a dataclass. + with self.assertRaises(TypeError) as ctx: + _dict_to_dataclass({"x": 1}, dict) + self.assertIn("not a dataclass", str(ctx.exception)) + + def test_converts_flat_dict(self): + result = _dict_to_dataclass({"value": 42}, _Inner) + self.assertIsInstance(result, _Inner) + self.assertEqual(result.value, 42) + + def test_converts_nested_dataclass(self): + result = _dict_to_dataclass( + {"middle": {"inner": {"value": 7}}}, _Outer + ) + self.assertIsInstance(result, _Outer) + self.assertIsInstance(result.middle, _Middle) + self.assertIsInstance(result.middle.inner, _Inner) + self.assertEqual(result.middle.inner.value, 7) + + def test_converts_list_of_dataclasses(self): + result = _dict_to_dataclass( + {"middle": {"items": [{"value": 1}, {"value": 2}]}}, _Outer + ) + self.assertEqual(len(result.middle.items), 2) + self.assertIsInstance(result.middle.items[0], _Inner) + self.assertEqual(result.middle.items[0].value, 1) + self.assertEqual(result.middle.items[1].value, 2) + + def test_none_value_preserved(self): + result = _dict_to_dataclass({"middle": None, "name": "test"}, _Outer) + self.assertIsNone(result.middle) + self.assertEqual(result.name, "test") + + def test_missing_optional_fields_default_to_none(self): + result = _dict_to_dataclass({}, _Outer) + self.assertIsNone(result.middle) + self.assertIsNone(result.name) + + def test_unknown_keys_routed_to_additional_properties(self): + result = _dict_to_dataclass( + {"known": "yes", "my_plugin": {"opt": True}}, _WithExtras + ) + self.assertEqual(result.known, "yes") + self.assertEqual( + result.additional_properties, {"my_plugin": {"opt": True}} + ) + + def test_primitive_values_pass_through(self): + result = _dict_to_dataclass({"name": "hello"}, _Outer) + self.assertEqual(result.name, "hello") + + def test_empty_list_converted(self): + result = _dict_to_dataclass({"middle": {"items": []}}, _Outer) + self.assertEqual(result.middle.items, []) + + def test_enum_value_coerced_from_string(self): + result = _dict_to_dataclass({"filter": "always_on"}, _WithEnum) + self.assertIs(result.filter, ExemplarFilter.always_on) + + def test_enum_value_already_enum_passes_through(self): + result = _dict_to_dataclass( + {"filter": ExemplarFilter.trace_based}, _WithEnum + ) + self.assertIs(result.filter, ExemplarFilter.trace_based) diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index f3a48ac..566329c 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -27,6 +27,9 @@ from opentelemetry.sdk._configuration.models import ( ExemplarFilter as ExemplarFilterConfig, ) from opentelemetry.sdk._configuration.models import ( + ExperimentalPrometheusMetricExporter as PrometheusMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( ExplicitBucketHistogramAggregation as ExplicitBucketConfig, ) from opentelemetry.sdk._configuration.models import ( @@ -53,6 +56,12 @@ from opentelemetry.sdk._configuration.models import ( PeriodicMetricReader as PeriodicMetricReaderConfig, ) from opentelemetry.sdk._configuration.models import ( + PullMetricExporter as PullMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + PullMetricReader as PullMetricReaderConfig, +) +from opentelemetry.sdk._configuration.models import ( PushMetricExporter as PushMetricExporterConfig, ) from opentelemetry.sdk._configuration.models import ( @@ -99,7 +108,7 @@ class TestCreateMeterProviderBasic(unittest.TestCase): def test_none_config_no_readers(self): provider = create_meter_provider(None) - self.assertEqual(len(provider._sdk_config.metric_readers), 0) + self.assertEqual(len(provider._metric_readers), 0) def test_none_config_uses_trace_based_exemplar_filter(self): provider = create_meter_provider(None) @@ -130,7 +139,7 @@ class TestCreateMeterProviderBasic(unittest.TestCase): ) with patch.dict(os.environ, {"OTEL_METRIC_EXPORT_INTERVAL": "999999"}): provider = create_meter_provider(config) - reader = provider._sdk_config.metric_readers[0] + reader = provider._metric_readers[0] self.assertIsInstance(reader, PeriodicExportingMetricReader) self.assertEqual(reader._export_interval_millis, 60000.0) @@ -154,11 +163,6 @@ class TestCreateMeterProviderBasic(unittest.TestCase): arg = mock_set.call_args[0][0] self.assertIsInstance(arg, MeterProvider) - def test_empty_readers_list(self): - config = MeterProviderConfig(readers=[]) - provider = create_meter_provider(config) - self.assertEqual(len(provider._sdk_config.metric_readers), 0) - class TestCreateMetricReaders(unittest.TestCase): @staticmethod @@ -180,7 +184,7 @@ class TestCreateMetricReaders(unittest.TestCase): PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) ) provider = create_meter_provider(config) - reader = provider._sdk_config.metric_readers[0] + reader = provider._metric_readers[0] self.assertIsInstance(reader, PeriodicExportingMetricReader) self.assertIsInstance(reader._exporter, ConsoleMetricExporter) @@ -189,7 +193,7 @@ class TestCreateMetricReaders(unittest.TestCase): PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) ) provider = create_meter_provider(config) - reader = provider._sdk_config.metric_readers[0] + reader = provider._metric_readers[0] self.assertEqual(reader._export_interval_millis, 60000.0) def test_periodic_reader_default_timeout(self): @@ -197,7 +201,7 @@ class TestCreateMetricReaders(unittest.TestCase): PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) ) provider = create_meter_provider(config) - reader = provider._sdk_config.metric_readers[0] + reader = provider._metric_readers[0] self.assertEqual(reader._export_timeout_millis, 30000.0) def test_periodic_reader_explicit_interval(self): @@ -206,7 +210,7 @@ class TestCreateMetricReaders(unittest.TestCase): interval=5000, ) provider = create_meter_provider(config) - reader = provider._sdk_config.metric_readers[0] + reader = provider._metric_readers[0] self.assertEqual(reader._export_interval_millis, 5000.0) def test_periodic_reader_explicit_timeout(self): @@ -215,7 +219,7 @@ class TestCreateMetricReaders(unittest.TestCase): timeout=10000, ) provider = create_meter_provider(config) - reader = provider._sdk_config.metric_readers[0] + reader = provider._metric_readers[0] self.assertEqual(reader._export_timeout_millis, 10000.0) def test_otlp_http_missing_package_raises(self): @@ -306,13 +310,216 @@ class TestCreateMetricReaders(unittest.TestCase): create_meter_provider(config) self.assertIn("otlp-proto-grpc", str(ctx.exception)) - def test_pull_reader_raises(self): + +class TestCreatePullMetricReaders(unittest.TestCase): + def test_pull_prometheus_creates_reader(self): + mock_reader_cls = MagicMock() + mock_start_server = MagicMock() + mock_module = MagicMock() + mock_module.PrometheusMetricReader = mock_reader_cls + mock_module.start_http_server = mock_start_server + + with patch.dict( + sys.modules, + {"opentelemetry.exporter.prometheus": mock_module}, + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig( + prometheus_development=PrometheusMetricExporterConfig( + host="0.0.0.0", + port=9090, + without_target_info_development=True, + ) + ) + ) + ) + ] + ) + provider = create_meter_provider(config) + + mock_reader_cls.assert_called_once_with(disable_target_info=True) + mock_start_server.assert_called_once_with(port=9090, addr="0.0.0.0") + self.assertEqual(len(provider._metric_readers), 1) + + def test_pull_prometheus_defaults(self): + mock_reader_cls = MagicMock() + mock_start_server = MagicMock() + mock_module = MagicMock() + mock_module.PrometheusMetricReader = mock_reader_cls + mock_module.start_http_server = mock_start_server + + with patch.dict( + sys.modules, + {"opentelemetry.exporter.prometheus": mock_module}, + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig( + prometheus_development=PrometheusMetricExporterConfig() + ) + ) + ) + ] + ) + provider = create_meter_provider(config) + + mock_reader_cls.assert_called_once_with(disable_target_info=False) + mock_start_server.assert_called_once_with(port=9464, addr="localhost") + self.assertEqual(len(provider._metric_readers), 1) + + def test_pull_prometheus_missing_package_raises(self): + with patch.dict( + sys.modules, + {"opentelemetry.exporter.prometheus": None}, + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig( + prometheus_development=PrometheusMetricExporterConfig() + ) + ) + ) + ] + ) + with self.assertRaises(ConfigurationError): + create_meter_provider(config) + + def test_pull_no_exporter_raises(self): config = MeterProviderConfig( - readers=[MetricReaderConfig(pull=MagicMock())] + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig() + ) + ) + ] ) with self.assertRaises(ConfigurationError): create_meter_provider(config) + def test_pull_plugin_loads_via_entry_point(self): + mock_reader = MagicMock() + mock_class = MagicMock(return_value=mock_reader) + mock_entry_points = MagicMock( + return_value=[MagicMock(**{"load.return_value": mock_class})] + ) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + mock_entry_points, + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + # pylint: disable=unexpected-keyword-arg + exporter=PullMetricExporterConfig( + my_custom_reader={"port": 8080} + ) + ) + ) + ] + ) + provider = create_meter_provider(config) + self.assertEqual(len(provider._metric_readers), 1) + mock_class.assert_called_once_with(port=8080) + mock_entry_points.assert_called_once_with( + group="opentelemetry_pull_metric_exporter", + name="my_custom_reader", + ) + + def test_pull_plugin_not_found_raises(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + # pylint: disable=unexpected-keyword-arg + exporter=PullMetricExporterConfig( + no_such_reader={} + ) + ) + ) + ] + ) + with self.assertRaises(ConfigurationError): + create_meter_provider(config) + + def test_pull_producers_warns(self): + mock_module = MagicMock() + + with patch.dict( + sys.modules, + {"opentelemetry.exporter.prometheus": mock_module}, + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig( + prometheus_development=PrometheusMetricExporterConfig() + ), + producers=[MagicMock()], + ) + ) + ] + ) + with self.assertLogs( + "opentelemetry.sdk._configuration._meter_provider", + level="WARNING", + ) as cm: + create_meter_provider(config) + self.assertTrue(any("MetricProducer" in msg for msg in cm.output)) + + def test_pull_cardinality_limits_warns(self): + mock_module = MagicMock() + + with patch.dict( + sys.modules, + {"opentelemetry.exporter.prometheus": mock_module}, + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig( + prometheus_development=PrometheusMetricExporterConfig() + ), + cardinality_limits=MagicMock(), + ) + ) + ] + ) + with self.assertLogs( + "opentelemetry.sdk._configuration._meter_provider", + level="WARNING", + ) as cm: + create_meter_provider(config) + self.assertTrue(any("cardinality_limits" in msg for msg in cm.output)) + + +class TestCreateMetricReadersGeneral(unittest.TestCase): + @staticmethod + def _make_periodic_config(exporter_config): + return MeterProviderConfig( + readers=[ + MetricReaderConfig( + periodic=PeriodicMetricReaderConfig( + exporter=exporter_config + ) + ) + ] + ) + def test_no_reader_type_raises(self): config = MeterProviderConfig(readers=[MetricReaderConfig()]) with self.assertRaises(ConfigurationError): @@ -335,7 +542,7 @@ class TestCreateMetricReaders(unittest.TestCase): PushMetricExporterConfig(my_custom_exporter={}) ) provider = create_meter_provider(config) - self.assertEqual(len(provider._sdk_config.metric_readers), 1) + self.assertEqual(len(provider._metric_readers), 1) def test_unknown_metric_exporter_raises_configuration_error(self): with patch( @@ -369,7 +576,7 @@ class TestCreateMetricReaders(unittest.TestCase): ] ) provider = create_meter_provider(config) - self.assertEqual(len(provider._sdk_config.metric_readers), 2) + self.assertEqual(len(provider._metric_readers), 2) class TestTemporalityAndAggregation(unittest.TestCase): @@ -393,7 +600,7 @@ class TestTemporalityAndAggregation(unittest.TestCase): @staticmethod def _get_exporter(config): provider = create_meter_provider(config) - return provider._sdk_config.metric_readers[0]._exporter + return provider._metric_readers[0]._exporter def test_default_temporality_is_cumulative(self): exporter = self._get_exporter(self._make_console_config()) diff --git a/opentelemetry-sdk/tests/_configuration/test_resource.py b/opentelemetry-sdk/tests/_configuration/test_resource.py index 539a6e2..ac323bc 100644 --- a/opentelemetry-sdk/tests/_configuration/test_resource.py +++ b/opentelemetry-sdk/tests/_configuration/test_resource.py @@ -311,10 +311,10 @@ class TestServiceResourceDetector(unittest.TestCase): resource = create_resource(self._config_with_service()) self.assertIn(SERVICE_INSTANCE_ID, resource.attributes) - def test_service_instance_id_is_unique_per_call(self): + def test_service_instance_id_is_stable_within_process(self): r1 = create_resource(self._config_with_service()) r2 = create_resource(self._config_with_service()) - self.assertNotEqual( + self.assertEqual( r1.attributes[SERVICE_INSTANCE_ID], r2.attributes[SERVICE_INSTANCE_ID], ) diff --git a/opentelemetry-sdk/tests/_configuration/test_sdk.py b/opentelemetry-sdk/tests/_configuration/test_sdk.py new file mode 100644 index 0000000..b223ad3 --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_sdk.py @@ -0,0 +1,149 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# Tests access private members of SDK classes to assert correct configuration. +# pylint: disable=protected-access + +import unittest +from unittest.mock import patch + +from opentelemetry.sdk._configuration._sdk import configure_sdk +from opentelemetry.sdk._configuration.models import ( + OpenTelemetryConfiguration, +) +from opentelemetry.sdk._configuration.models import ( + Propagator as PropagatorConfig, +) +from opentelemetry.sdk._configuration.models import ( + Resource as ResourceConfig, +) +from opentelemetry.sdk._configuration.models import ( + SimpleSpanProcessor as SimpleSpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanExporter as SpanExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanProcessor as SpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + TracerProvider as TracerProviderConfig, +) +from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider + +_MIN_CONFIG_KWARGS = {"file_format": "1.0"} + + +def _config(**kwargs) -> OpenTelemetryConfiguration: + return OpenTelemetryConfiguration(**{**_MIN_CONFIG_KWARGS, **kwargs}) + + +class TestConfigureSdk(unittest.TestCase): + @patch("opentelemetry.sdk._configuration._sdk.configure_propagator") + @patch("opentelemetry.sdk._configuration._sdk.configure_logger_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.sdk._configuration._sdk.create_resource") + # pylint: disable=no-self-use + def test_calls_each_signal_with_resource( + self, + mock_create_resource, + mock_tracer, + mock_meter, + mock_logger, + mock_propagator, + ): + sentinel_resource = object() + mock_create_resource.return_value = sentinel_resource + + resource_cfg = ResourceConfig() + tracer_cfg = TracerProviderConfig(processors=[]) + propagator_cfg = PropagatorConfig() + config = _config( + resource=resource_cfg, + tracer_provider=tracer_cfg, + propagator=propagator_cfg, + ) + + configure_sdk(config) + + mock_create_resource.assert_called_once_with(resource_cfg) + mock_tracer.assert_called_once_with(tracer_cfg, sentinel_resource) + mock_meter.assert_called_once_with(None, sentinel_resource) + mock_logger.assert_called_once_with(None, sentinel_resource) + mock_propagator.assert_called_once_with(propagator_cfg) + + @patch("opentelemetry.sdk._configuration._sdk.configure_propagator") + @patch("opentelemetry.sdk._configuration._sdk.configure_logger_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.sdk._configuration._sdk.create_resource") + # pylint: disable=no-self-use + def test_disabled_skips_everything( + self, + mock_create_resource, + mock_tracer, + mock_meter, + mock_logger, + mock_propagator, + ): + config = _config( + disabled=True, + tracer_provider=TracerProviderConfig(processors=[]), + ) + + configure_sdk(config) + + mock_create_resource.assert_not_called() + mock_tracer.assert_not_called() + mock_meter.assert_not_called() + mock_logger.assert_not_called() + mock_propagator.assert_not_called() + + @patch("opentelemetry.sdk._configuration._sdk.configure_propagator") + @patch("opentelemetry.sdk._configuration._sdk.configure_logger_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.sdk._configuration._sdk.create_resource") + def test_absent_sections_pass_none( + self, + mock_create_resource, # noqa: ARG002 + mock_tracer, + mock_meter, + mock_logger, + mock_propagator, + ): + configure_sdk(_config()) + + # Each configure_* is called exactly once, with config=None. + self.assertEqual(mock_tracer.call_args.args[0], None) + self.assertEqual(mock_meter.call_args.args[0], None) + self.assertEqual(mock_logger.call_args.args[0], None) + self.assertEqual(mock_propagator.call_args.args[0], None) + + +class TestConfigureSdkIntegration(unittest.TestCase): + """End-to-end: build a real OpenTelemetryConfiguration and apply it.""" + + @patch( + "opentelemetry.sdk._configuration._tracer_provider.trace.set_tracer_provider" + ) + def test_applies_tracer_provider_globally(self, mock_set_tracer): + config = _config( + tracer_provider=TracerProviderConfig( + processors=[ + SpanProcessorConfig( + simple=SimpleSpanProcessorConfig( + exporter=SpanExporterConfig(console={}) + ) + ) + ] + ) + ) + + configure_sdk(config) + + mock_set_tracer.assert_called_once() + self.assertIsInstance( + mock_set_tracer.call_args[0][0], SdkTracerProvider + ) diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index c3ada36..544e80f 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -9,6 +9,7 @@ import sys import unittest from unittest.mock import MagicMock, patch +from opentelemetry import trace as trace_api from opentelemetry.sdk._configuration._tracer_provider import ( configure_tracer_provider, create_tracer_provider, @@ -18,6 +19,27 @@ from opentelemetry.sdk._configuration.models import ( BatchSpanProcessor as BatchSpanProcessorConfig, ) from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableProbabilitySampler as ComposableProbabilityConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableRuleBasedSampler as RuleBasedSamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableRuleBasedSamplerRule as RuleBasedSamplerRuleConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableRuleBasedSamplerRuleAttributePatterns as RuleAttributePatternsConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableRuleBasedSamplerRuleAttributeValues as RuleAttributeValuesConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableSampler as ComposableSamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalSpanParent as SpanParentConfig, +) +from opentelemetry.sdk._configuration.models import ( OtlpGrpcExporter as OtlpGrpcExporterConfig, ) from opentelemetry.sdk._configuration.models import ( @@ -36,6 +58,9 @@ from opentelemetry.sdk._configuration.models import ( SpanExporter as SpanExporterConfig, ) from opentelemetry.sdk._configuration.models import ( + SpanKind as SpanKindConfig, +) +from opentelemetry.sdk._configuration.models import ( SpanLimits as SpanLimitsConfig, ) from opentelemetry.sdk._configuration.models import ( @@ -57,10 +82,16 @@ from opentelemetry.sdk.trace.export import ( from opentelemetry.sdk.trace.sampling import ( ALWAYS_OFF, ALWAYS_ON, + Decision, ParentBased, Sampler, TraceIdRatioBased, ) +from opentelemetry.trace import SpanContext, TraceFlags +from opentelemetry.trace import SpanKind as TraceSpanKind + +TRACE_ID = int("00112233445566778800000000000000", 16) +SPAN_ID = int("0123456789abcdef", 16) class TestCreateTracerProviderBasic(unittest.TestCase): @@ -207,11 +238,11 @@ class TestCreateSampler(unittest.TestCase): self.assertIs(sampler._local_parent_sampled, ALWAYS_ON) self.assertIs(sampler._local_parent_not_sampled, ALWAYS_OFF) - def test_unknown_sampler_raises_configuration_error(self): + def test_no_sampler_raises_configuration_error(self): with self.assertRaises(ConfigurationError): self._make_provider(SamplerConfig()) - def test_plugin_sampler_loaded_via_entry_point(self): + def test_user_defined_sampler_loaded_via_entry_point(self): mock_sampler = MagicMock(spec=Sampler) mock_class = MagicMock(return_value=mock_sampler) with patch( @@ -222,7 +253,7 @@ class TestCreateSampler(unittest.TestCase): provider = self._make_provider(SamplerConfig(my_custom_sampler={})) self.assertIs(provider.sampler, mock_sampler) - def test_unknown_plugin_raises_configuration_error(self): + def test_user_defined_sampler_not_found_raises_configuration_error(self): with patch( "opentelemetry.sdk._configuration._common.entry_points", return_value=[], @@ -232,6 +263,267 @@ class TestCreateSampler(unittest.TestCase): self._make_provider(SamplerConfig(no_such_sampler={})) +class TestCreateCompositeRuleBasedSampler(unittest.TestCase): + @staticmethod + def _make_provider(rule_based_config): + return create_tracer_provider( + TracerProviderConfig( + processors=[], + sampler=SamplerConfig( + composite_development=ComposableSamplerConfig( + rule_based=rule_based_config + ) + ), + ) + ) + + @staticmethod + def _rule(sampler, **kwargs): + return RuleBasedSamplerRuleConfig(sampler=sampler, **kwargs) + + @staticmethod + def _always_on(): + return ComposableSamplerConfig(always_on={}) + + @staticmethod + def _always_off(): + return ComposableSamplerConfig(always_off={}) + + @staticmethod + def _attribute_values(key, values): + return RuleAttributeValuesConfig(key=key, values=values) + + @staticmethod + def _attribute_patterns(key, included=None, excluded=None): + return RuleAttributePatternsConfig( + key=key, + included=included, + excluded=excluded, + ) + + def _decision( + self, + rule_based_config, + *, + name="span", + kind=None, + attributes=None, + parent_context=None, + ): + provider = self._make_provider(rule_based_config) + return provider.sampler.should_sample( + parent_context, + TRACE_ID, + name, + kind, + attributes, + None, + ).decision + + def test_composite_rule_based_no_rules_drops(self): + decision = self._decision(RuleBasedSamplerConfig()) + + self.assertEqual(decision, Decision.DROP) + + def test_composite_rule_based_no_condition_rule_matches(self): + decision = self._decision( + RuleBasedSamplerConfig(rules=[self._rule(self._always_on())]) + ) + + self.assertEqual(decision, Decision.RECORD_AND_SAMPLE) + + def test_composite_rule_based_first_match_wins(self): + rule_based = RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_off(), + attribute_values=self._attribute_values( + "http.route", ["/health"] + ), + ), + self._rule( + self._always_on(), + attribute_values=self._attribute_values( + "http.route", ["/health"] + ), + ), + ] + ) + + decision = self._decision( + rule_based, attributes={"http.route": "/health"} + ) + + self.assertEqual(decision, Decision.DROP) + + def test_composite_rule_based_attribute_values_stringifies_values(self): + decision = self._decision( + RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + attribute_values=self._attribute_values( + "http.response.status_code", ["404"] + ), + ) + ] + ), + attributes={"http.response.status_code": 404}, + ) + + self.assertEqual(decision, Decision.RECORD_AND_SAMPLE) + + def test_composite_rule_based_attribute_values_match_array_item(self): + decision = self._decision( + RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + attribute_values=self._attribute_values( + "http.request.method", ["POST"] + ), + ) + ] + ), + attributes={"http.request.method": ("GET", "POST")}, + ) + + self.assertEqual(decision, Decision.RECORD_AND_SAMPLE) + + def test_composite_rule_based_attribute_patterns_include_exclude(self): + rule_based = RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + attribute_patterns=self._attribute_patterns( + "http.route", + ["/api/*"], + ["/api/private/*"], + ), + ) + ] + ) + + included = self._decision( + rule_based, attributes={"http.route": "/api/users"} + ) + excluded = self._decision( + rule_based, attributes={"http.route": "/api/private/user"} + ) + case_mismatch = self._decision( + rule_based, attributes={"http.route": "/API/users"} + ) + + self.assertEqual(included, Decision.RECORD_AND_SAMPLE) + self.assertEqual(excluded, Decision.DROP) + self.assertEqual(case_mismatch, Decision.DROP) + + def test_composite_rule_based_span_kind(self): + rule_based = RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + span_kinds=[SpanKindConfig.server], + ) + ] + ) + + server = self._decision(rule_based, kind=TraceSpanKind.SERVER) + client = self._decision(rule_based, kind=TraceSpanKind.CLIENT) + + self.assertEqual(server, Decision.RECORD_AND_SAMPLE) + self.assertEqual(client, Decision.DROP) + + def test_composite_rule_based_parent(self): + local_parent_context = trace_api.set_span_in_context( + trace_api.NonRecordingSpan( + SpanContext( + TRACE_ID, + SPAN_ID, + is_remote=False, + trace_flags=TraceFlags.get_default(), + ) + ) + ) + remote_parent_context = trace_api.set_span_in_context( + trace_api.NonRecordingSpan( + SpanContext( + TRACE_ID, + SPAN_ID, + is_remote=True, + trace_flags=TraceFlags.get_default(), + ) + ) + ) + rule_based = RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + parent=[SpanParentConfig.local], + ) + ] + ) + + local = self._decision(rule_based, parent_context=local_parent_context) + remote = self._decision( + rule_based, parent_context=remote_parent_context + ) + no_parent = self._decision(rule_based) + + self.assertEqual(local, Decision.RECORD_AND_SAMPLE) + self.assertEqual(remote, Decision.DROP) + self.assertEqual(no_parent, Decision.DROP) + + def test_composite_rule_based_multiple_conditions_are_anded(self): + rule_based = RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + attribute_values=self._attribute_values( + "http.route", ["/users"] + ), + span_kinds=[SpanKindConfig.server], + ) + ] + ) + + matching = self._decision( + rule_based, + kind=TraceSpanKind.SERVER, + attributes={"http.route": "/users"}, + ) + wrong_kind = self._decision( + rule_based, + kind=TraceSpanKind.CLIENT, + attributes={"http.route": "/users"}, + ) + + self.assertEqual(matching, Decision.RECORD_AND_SAMPLE) + self.assertEqual(wrong_kind, Decision.DROP) + + def test_composite_rule_based_nested_probability_sampler(self): + provider = self._make_provider( + RuleBasedSamplerConfig( + rules=[ + self._rule( + ComposableSamplerConfig( + probability=ComposableProbabilityConfig(ratio=0.0) + ) + ) + ] + ) + ) + + expected = ( + "ComposableRuleBased{[(AlwaysMatch:" + "ComposableTraceIDRatioBased{threshold=max, ratio=0.0})]}" + ) + self.assertEqual( + provider.sampler.get_description(), + expected, + ) + + class TestCreateSpanExporterAndProcessor(unittest.TestCase): # pylint: disable=no-self-use diff --git a/opentelemetry-sdk/tests/metrics/test_measurement_consumer.py b/opentelemetry-sdk/tests/metrics/test_measurement_consumer.py index 9f67271..d09368c 100644 --- a/opentelemetry-sdk/tests/metrics/test_measurement_consumer.py +++ b/opentelemetry-sdk/tests/metrics/test_measurement_consumer.py @@ -3,6 +3,7 @@ # pylint: disable=invalid-name,no-self-use +from threading import Event, Thread from time import sleep from unittest import TestCase from unittest.mock import MagicMock, Mock, patch @@ -23,7 +24,8 @@ from opentelemetry.sdk.metrics._internal.sdk_configuration import ( class TestSynchronousMeasurementConsumer(TestCase): def test_parent(self, _): self.assertIsInstance( - SynchronousMeasurementConsumer(MagicMock()), MeasurementConsumer + SynchronousMeasurementConsumer(MagicMock(), metric_readers=()), + MeasurementConsumer, ) def test_creates_metric_reader_storages(self, MockMetricReaderStorage): @@ -33,9 +35,9 @@ class TestSynchronousMeasurementConsumer(TestCase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=reader_mocks, views=Mock(), - ) + ), + metric_readers=reader_mocks, ) self.assertEqual(len(MockMetricReaderStorage.mock_calls), 5) @@ -50,9 +52,9 @@ class TestSynchronousMeasurementConsumer(TestCase): SdkConfiguration( exemplar_filter=Mock(should_sample=Mock(return_value=False)), resource=Mock(), - metric_readers=reader_mocks, views=Mock(), - ) + ), + metric_readers=reader_mocks, ) measurement_mock = Mock() consumer.consume_measurement(measurement_mock) @@ -72,9 +74,9 @@ class TestSynchronousMeasurementConsumer(TestCase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=reader_mocks, views=Mock(), - ) + ), + metric_readers=reader_mocks, ) for r_mock, rs_mock in zip(reader_mocks, reader_storage_mocks): rs_mock.collect.assert_not_called() @@ -91,9 +93,9 @@ class TestSynchronousMeasurementConsumer(TestCase): SdkConfiguration( exemplar_filter=Mock(should_sample=Mock(return_value=False)), resource=Mock(), - metric_readers=[reader_mock], views=Mock(), - ) + ), + metric_readers=[reader_mock], ) async_instrument_mocks = [MagicMock() for _ in range(5)] for i_mock in async_instrument_mocks: @@ -122,9 +124,9 @@ class TestSynchronousMeasurementConsumer(TestCase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=[reader_mock], views=Mock(), - ) + ), + metric_readers=[reader_mock], ) def sleep_1(*args, **kwargs): @@ -156,9 +158,9 @@ class TestSynchronousMeasurementConsumer(TestCase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=[reader_mock], views=Mock(), - ) + ), + metric_readers=[reader_mock], ) consumer.register_asynchronous_instrument( @@ -188,3 +190,123 @@ class TestSynchronousMeasurementConsumer(TestCase): callback_options_time_call, 10000, ) + + +class TestSynchronousMeasurementConsumerConcurrency(TestCase): + def test_consume_measurement_does_not_acquire_lock(self): + """consume_measurement must stay lock free on the hot path.""" + with patch( + "opentelemetry.sdk.metrics._internal." + "measurement_consumer.MetricReaderStorage" + ): + consumer = SynchronousMeasurementConsumer( + SdkConfiguration( + exemplar_filter=Mock( + should_sample=Mock(return_value=False) + ), + resource=Mock(), + views=Mock(), + ), + metric_readers=[Mock()], + ) + + mock_lock = MagicMock() + # pylint: disable-next=protected-access + consumer._lock = mock_lock + + consumer.consume_measurement(Mock()) + + mock_lock.__enter__.assert_not_called() + + def test_concurrent_changes_to_metric_readers(self): + timeout = 1 + failure = None + iteration_started = Event() + mutation_done = Event() + iteration_timeout_error = "Timed out waiting for iteration to start" + mutation_timeout_error = "Timed out waiting for mutation to be done" + + consumer = SynchronousMeasurementConsumer( + SdkConfiguration( + exemplar_filter=MagicMock(), + resource=MagicMock(), + views=MagicMock(), + ), + metric_readers=[MagicMock()], + ) + + def _hooked_iter(iterable): + nonlocal failure + + iterable = iter(iterable) + iteration_started.set() + if not mutation_done.wait(timeout): + failure = mutation_timeout_error + yield next(iterable, None) + yield from iterable + + class HookedDict(dict): + def values(self): + return _hooked_iter(super().values()) + + with patch.object( + consumer, + "_reader_storages", + # pylint: disable-next=protected-access + HookedDict(consumer._reader_storages), + ): + + def mutate(): + """Directly mutate _reader_storages after iteration starts""" + nonlocal failure + if not iteration_started.wait(timeout): + failure = iteration_timeout_error + # pylint: disable-next=protected-access + consumer._reader_storages.clear() + mutation_done.set() + + # Verify that test setup works (direct mutation with no synchronization fails) + with self.assertRaises(RuntimeError) as cm: + t = Thread(target=mutate) + t.start() + try: + consumer.consume_measurement(MagicMock()) + finally: + t.join() + self.assertEqual( + "dictionary changed size during iteration", str(cm.exception) + ) + self.assertIsNone(failure) + + # Reset the events for the second scenario + iteration_started.clear() + mutation_done.clear() + failure = None + + with patch.object( + consumer, + "_reader_storages", + # pylint: disable-next=protected-access + HookedDict(consumer._reader_storages), + ): + + def add_and_remove_readers(): + """Mutate via the public API while consume_measurement runs""" + nonlocal failure + if not iteration_started.wait(timeout): + failure = iteration_timeout_error + reader = MagicMock() + consumer.add_metric_reader(reader) + consumer.remove_metric_reader(reader) + mutation_done.set() + + # The copy-on-write API never mutates the mapping being + # iterated, so `consume_measurement` completes without raising even + # though add/remove run concurrently mid-iteration. + t = Thread(target=add_and_remove_readers) + t.start() + try: + consumer.consume_measurement(MagicMock()) + finally: + t.join() + self.assertIsNone(failure) diff --git a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py index ec70ff1..281aae9 100644 --- a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py +++ b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py @@ -68,7 +68,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=(view1, view2), ), MagicMock( @@ -135,7 +134,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=(view1, view2), ), MagicMock( @@ -245,7 +243,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=(view1,), ), MagicMock( @@ -276,7 +273,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=(View(instrument_name="test"),), ), MagicMock( @@ -322,7 +318,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=(), ), MagicMock( @@ -358,7 +353,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=( View( instrument_name="name", aggregation=DropAggregation() @@ -386,7 +380,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=(View(instrument_name="name"),), ), MagicMock( @@ -433,7 +426,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=( View( instrument_name="observable_counter", @@ -482,7 +474,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=( View(instrument_name="observable_counter_0", name="foo"), View(instrument_name="observable_counter_1", name="foo"), @@ -540,7 +531,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=( View(instrument_name="bar", name="foo"), View(instrument_name="baz", name="foo"), @@ -609,7 +599,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=( View(instrument_name="foo"), View(instrument_name="bar"), @@ -662,7 +651,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=( View(instrument_name="bar", name="foo"), View(instrument_name="baz", name="foo"), @@ -713,7 +701,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=( View(instrument_name="bar", name="foo"), View(instrument_name="baz", name="foo"), @@ -760,7 +747,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=( View(instrument_name="observable_counter_0", name="foo"), View(instrument_name="observable_counter_1", name="foo"), @@ -815,7 +801,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=( View(instrument_name="observable_counter", name="foo"), View(instrument_name="histogram", name="foo"), @@ -870,7 +855,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=( View(instrument_name="observable_counter_0", name="foo"), View(instrument_name="observable_counter_1", name="foo"), @@ -925,7 +909,6 @@ class TestMetricReaderStorage(ConcurrencyTestBase): SdkConfiguration( exemplar_filter=Mock(), resource=Mock(), - metric_readers=(), views=( View(instrument_name="up_down_counter", name="foo"), View( diff --git a/opentelemetry-sdk/tests/metrics/test_metrics.py b/opentelemetry-sdk/tests/metrics/test_metrics.py index beb8dbb..433ad05 100644 --- a/opentelemetry-sdk/tests/metrics/test_metrics.py +++ b/opentelemetry-sdk/tests/metrics/test_metrics.py @@ -2,10 +2,9 @@ # SPDX-License-Identifier: Apache-2.0 # pylint: disable=protected-access,no-self-use,too-many-lines - import weakref from collections.abc import Callable, Iterable, Sequence -from logging import WARNING +from logging import DEBUG, WARNING from threading import Lock from time import sleep from typing import Any @@ -34,6 +33,7 @@ from opentelemetry.sdk.metrics._internal import ( _RuleBasedMeterConfigurator, ) from opentelemetry.sdk.metrics.export import ( + InMemoryMetricReader, Metric, MetricExporter, MetricExportResult, @@ -488,6 +488,36 @@ class TestMeterProvider(ConcurrencyTestBase, TestCase): sync_consumer_instance.consume_measurement.assert_called() + def test_addition_of_metric_reader(self): + internal_logger = "opentelemetry.sdk.metrics._internal" + export_logger = "opentelemetry.sdk.metrics._internal.export" + + reader = InMemoryMetricReader() + meter_provider = MeterProvider() + meter = meter_provider.get_meter(__name__) + counter = meter.create_counter("counter") + counter.add(1) + # Suppress warnings for calling collect on an unregistered metric reader + with self.assertLogs(export_logger, DEBUG): + self.assertIsNone(reader.get_metrics_data()) + + meter_provider.add_metric_reader(reader) + counter.add(1) + self.assertIsNotNone(reader.get_metrics_data()) + + with self.assertLogs(internal_logger, DEBUG) as cm: + meter_provider.add_metric_reader(reader) + self.assertIn("has been registered already!", cm.output[0]) + + meter_provider.remove_metric_reader(reader) + counter.add(1) + with self.assertLogs(export_logger, DEBUG): + self.assertIsNone(reader.get_metrics_data()) + + with self.assertLogs(internal_logger, DEBUG) as cm: + meter_provider.remove_metric_reader(reader) + self.assertIn("has not been registered!", cm.output[0]) + class TestMeterConcurrency(ConcurrencyTestBase, TestCase): def test_create_instrument_concurrency(self): @@ -548,9 +578,7 @@ class TestMeter(TestCase): def setUp(self): self.meter = Meter(Mock(), Mock()) - # TODO: convert to assertNoLogs instead of mocking logger when 3.10 is baseline - @patch("opentelemetry.sdk.metrics._internal._logger") - def test_repeated_instrument_names(self, logger_mock): + def test_repeated_instrument_names(self): with self.assertNotRaises(Exception): self.meter.create_counter("counter") self.meter.create_up_down_counter("up_down_counter") @@ -572,18 +600,24 @@ class TestMeter(TestCase): "histogram", "gauge", ]: - getattr(self.meter, f"create_{instrument_name}")(instrument_name) - logger_mock.warning.assert_not_called() + with self.assertNoLogs( + "opentelemetry.sdk.metrics._internal", level="WARNING" + ): + getattr(self.meter, f"create_{instrument_name}")( + instrument_name + ) for instrument_name in [ "observable_counter", "observable_gauge", "observable_up_down_counter", ]: - getattr(self.meter, f"create_{instrument_name}")( - instrument_name, callbacks=[Mock()] - ) - logger_mock.warning.assert_not_called() + with self.assertNoLogs( + "opentelemetry.sdk.metrics._internal", level="WARNING" + ): + getattr(self.meter, f"create_{instrument_name}")( + instrument_name, callbacks=[Mock()] + ) def test_repeated_instrument_names_with_different_advisory(self): with self.assertNotRaises(Exception): diff --git a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py index 1de1af3..73b0e6e 100644 --- a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py +++ b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py @@ -71,7 +71,6 @@ class Test_ViewInstrumentMatch(TestCase): # pylint: disable=invalid-name cls.sdk_configuration = SdkConfiguration( exemplar_filter=Mock(), resource=cls.mock_resource, - metric_readers=[], views=[], ) diff --git a/opentelemetry-sdk/tests/resources/test_resources.py b/opentelemetry-sdk/tests/resources/test_resources.py index 8c6ff46..2b945ea 100644 --- a/opentelemetry-sdk/tests/resources/test_resources.py +++ b/opentelemetry-sdk/tests/resources/test_resources.py @@ -1,6 +1,10 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=too-many-lines + import os +import subprocess import sys import unittest import uuid @@ -10,6 +14,7 @@ from os import environ from unittest.mock import Mock, patch from urllib import parse +import opentelemetry.sdk.resources as _resources_module from opentelemetry.sdk.environment_variables import ( OTEL_EXPERIMENTAL_RESOURCE_DETECTORS, ) @@ -34,6 +39,7 @@ from opentelemetry.sdk.resources import ( PROCESS_RUNTIME_DESCRIPTION, PROCESS_RUNTIME_NAME, PROCESS_RUNTIME_VERSION, + SERVICE_INSTANCE_ID, SERVICE_NAME, TELEMETRY_SDK_LANGUAGE, TELEMETRY_SDK_NAME, @@ -43,6 +49,7 @@ from opentelemetry.sdk.resources import ( ProcessResourceDetector, Resource, ResourceDetector, + ServiceInstanceIdResourceDetector, _HostResourceDetector, get_aggregated_resources, ) @@ -59,6 +66,11 @@ except ImportError: class TestResources(unittest.TestCase): def setUp(self) -> None: environ[OTEL_RESOURCE_ATTRIBUTES] = "" + self._service_instance_id = ( + ServiceInstanceIdResourceDetector() + .detect() + .attributes[SERVICE_INSTANCE_ID] + ) def tearDown(self) -> None: environ.pop(OTEL_RESOURCE_ATTRIBUTES) @@ -79,6 +91,7 @@ class TestResources(unittest.TestCase): TELEMETRY_SDK_NAME: "opentelemetry", TELEMETRY_SDK_LANGUAGE: "python", TELEMETRY_SDK_VERSION: _OPENTELEMETRY_SDK_VERSION, + SERVICE_INSTANCE_ID: self._service_instance_id, SERVICE_NAME: "unknown_service", } @@ -105,40 +118,30 @@ class TestResources(unittest.TestCase): resource = Resource.get_empty() self.assertEqual(resource, _EMPTY_RESOURCE) - resource = Resource.create(None) - self.assertEqual( - resource, - _DEFAULT_RESOURCE.merge( - Resource({SERVICE_NAME: "unknown_service"}, "") - ), + expected_default = _DEFAULT_RESOURCE.merge( + Resource( + { + SERVICE_INSTANCE_ID: self._service_instance_id, + SERVICE_NAME: "unknown_service", + }, + "", + ) ) + + resource = Resource.create(None) + self.assertEqual(resource, expected_default) self.assertEqual(resource.schema_url, "") resource = Resource.create(None, None) - self.assertEqual( - resource, - _DEFAULT_RESOURCE.merge( - Resource({SERVICE_NAME: "unknown_service"}, "") - ), - ) + self.assertEqual(resource, expected_default) self.assertEqual(resource.schema_url, "") resource = Resource.create({}) - self.assertEqual( - resource, - _DEFAULT_RESOURCE.merge( - Resource({SERVICE_NAME: "unknown_service"}, "") - ), - ) + self.assertEqual(resource, expected_default) self.assertEqual(resource.schema_url, "") resource = Resource.create({}, None) - self.assertEqual( - resource, - _DEFAULT_RESOURCE.merge( - Resource({SERVICE_NAME: "unknown_service"}, "") - ), - ) + self.assertEqual(resource, expected_default) self.assertEqual(resource.schema_url, "") def test_resource_merge(self): @@ -202,6 +205,7 @@ class TestResources(unittest.TestCase): TELEMETRY_SDK_NAME: "opentelemetry", TELEMETRY_SDK_LANGUAGE: "python", TELEMETRY_SDK_VERSION: _OPENTELEMETRY_SDK_VERSION, + SERVICE_INSTANCE_ID: self._service_instance_id, SERVICE_NAME: "unknown_service", } @@ -257,7 +261,13 @@ class TestResources(unittest.TestCase): self.assertEqual( aggregated_resources, _DEFAULT_RESOURCE.merge( - Resource({SERVICE_NAME: "unknown_service"}, "") + Resource( + { + SERVICE_INSTANCE_ID: self._service_instance_id, + SERVICE_NAME: "unknown_service", + }, + "", + ) ), ) @@ -308,7 +318,13 @@ class TestResources(unittest.TestCase): [resource_detector1, resource_detector2, resource_detector3] ), _DEFAULT_RESOURCE.merge( - Resource({SERVICE_NAME: "unknown_service"}, "") + Resource( + { + SERVICE_INSTANCE_ID: self._service_instance_id, + SERVICE_NAME: "unknown_service", + }, + "", + ) ).merge( Resource( { @@ -351,7 +367,13 @@ class TestResources(unittest.TestCase): self.assertEqual( get_aggregated_resources([resource_detector1, resource_detector2]), _DEFAULT_RESOURCE.merge( - Resource({SERVICE_NAME: "unknown_service"}, "") + Resource( + { + SERVICE_INSTANCE_ID: self._service_instance_id, + SERVICE_NAME: "unknown_service", + }, + "", + ) ).merge( Resource( {"key1": "value1", "key2": "value2", "key3": "value3"}, @@ -365,7 +387,13 @@ class TestResources(unittest.TestCase): [resource_detector2, resource_detector3] ), _DEFAULT_RESOURCE.merge( - Resource({SERVICE_NAME: "unknown_service"}, "") + Resource( + { + SERVICE_INSTANCE_ID: self._service_instance_id, + SERVICE_NAME: "unknown_service", + }, + "", + ) ).merge( Resource({"key2": "value2", "key3": "value3"}, "url1") ), @@ -383,7 +411,13 @@ class TestResources(unittest.TestCase): ] ), _DEFAULT_RESOURCE.merge( - Resource({SERVICE_NAME: "unknown_service"}, "") + Resource( + { + SERVICE_INSTANCE_ID: self._service_instance_id, + SERVICE_NAME: "unknown_service", + }, + "", + ) ).merge( Resource( { @@ -407,7 +441,13 @@ class TestResources(unittest.TestCase): self.assertEqual( get_aggregated_resources([resource_detector]), _DEFAULT_RESOURCE.merge( - Resource({SERVICE_NAME: "unknown_service"}, "") + Resource( + { + SERVICE_INSTANCE_ID: self._service_instance_id, + SERVICE_NAME: "unknown_service", + }, + "", + ) ), ) @@ -427,7 +467,13 @@ class TestResources(unittest.TestCase): self.assertEqual( get_aggregated_resources([resource_detector]), _DEFAULT_RESOURCE.merge( - Resource({SERVICE_NAME: "unknown_service"}, "") + Resource( + { + SERVICE_INSTANCE_ID: self._service_instance_id, + SERVICE_NAME: "unknown_service", + }, + "", + ) ), ) mock_logger.warning.assert_called_with( @@ -912,3 +958,113 @@ class TestHostResourceDetector(unittest.TestCase): resource.attributes["telemetry.sdk.language"], "python" ) self.assertIn(HOST_NAME, resource.attributes) + + +# pylint: disable=protected-access +class TestServiceInstanceIdResourceDetector(unittest.TestCase): + def setUp(self) -> None: + self._orig_instance_id = _resources_module._service_instance_id + self._orig_instance_pid = _resources_module._service_instance_id_pid + + def tearDown(self) -> None: + _resources_module._service_instance_id = self._orig_instance_id + _resources_module._service_instance_id_pid = self._orig_instance_pid + + def test_detect_value_is_valid_uuid4(self): + _resources_module._service_instance_id = None + _resources_module._service_instance_id_pid = None + detector = ServiceInstanceIdResourceDetector() + value = detector.detect().attributes[SERVICE_INSTANCE_ID] + parsed = uuid.UUID(value) + self.assertEqual(parsed.version, 4) + + def test_detect_stable_within_instance(self): + _resources_module._service_instance_id = None + _resources_module._service_instance_id_pid = None + detector = ServiceInstanceIdResourceDetector() + id1 = detector.detect().attributes[SERVICE_INSTANCE_ID] + id2 = detector.detect().attributes[SERVICE_INSTANCE_ID] + self.assertEqual(id1, id2) + + def test_detect_shared_across_instances(self): + _resources_module._service_instance_id = None + _resources_module._service_instance_id_pid = None + id1 = ( + ServiceInstanceIdResourceDetector() + .detect() + .attributes[SERVICE_INSTANCE_ID] + ) + id2 = ( + ServiceInstanceIdResourceDetector() + .detect() + .attributes[SERVICE_INSTANCE_ID] + ) + self.assertEqual(id1, id2) + + def test_detect_pid_change_generates_new_id(self): + _resources_module._service_instance_id = "old-id" + _resources_module._service_instance_id_pid = os.getpid() - 1 + new_id = ( + ServiceInstanceIdResourceDetector() + .detect() + .attributes[SERVICE_INSTANCE_ID] + ) + self.assertNotEqual(new_id, "old-id") + self.assertEqual( + _resources_module._service_instance_id_pid, os.getpid() + ) + uuid.UUID(new_id) + + def test_detect_pid_unchanged_returns_same_id(self): + known_id = "known-stable-id" + _resources_module._service_instance_id = known_id + _resources_module._service_instance_id_pid = os.getpid() + result = ( + ServiceInstanceIdResourceDetector() + .detect() + .attributes[SERVICE_INSTANCE_ID] + ) + self.assertEqual(result, known_id) + + @unittest.skipUnless(hasattr(os, "fork"), "requires os.fork") + def test_detect_fork_generates_new_id(self): + script = """\ +import os +import sys + +from opentelemetry.sdk.resources import ServiceInstanceIdResourceDetector, SERVICE_INSTANCE_ID + +parent_id = ServiceInstanceIdResourceDetector().detect().attributes[SERVICE_INSTANCE_ID] + +pid = os.fork() +if not pid: + child_id = ServiceInstanceIdResourceDetector().detect().attributes[SERVICE_INSTANCE_ID] + print(f"child:{child_id}", flush=True) + os._exit(0) +else: + os.waitpid(pid, 0) + print(f"parent:{parent_id}", flush=True) +""" + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + check=True, + ) + ids = dict( + line.split(":", 1) for line in result.stdout.strip().splitlines() + ) + parent_id, child_id = ids["parent"], ids["child"] + self.assertNotEqual(parent_id, child_id) + self.assertEqual(uuid.UUID(parent_id).version, 4) + self.assertEqual(uuid.UUID(child_id).version, 4) + + @patch.dict( + environ, + {OTEL_EXPERIMENTAL_RESOURCE_DETECTORS: "service_instance"}, + clear=True, + ) + def test_resource_detector_entry_points_service_instance(self): + resource = Resource.create() + self.assertIn(SERVICE_INSTANCE_ID, resource.attributes) + uuid.UUID(resource.attributes[SERVICE_INSTANCE_ID]) diff --git a/opentelemetry-sdk/tests/test_configurator.py b/opentelemetry-sdk/tests/test_configurator.py index fbf4bd3..67c82f4 100644 --- a/opentelemetry-sdk/tests/test_configurator.py +++ b/opentelemetry-sdk/tests/test_configurator.py @@ -1255,7 +1255,7 @@ class TestMetricsInit(TestCase): provider._sdk_config.resource.attributes.get("service.name"), "otlp-service", ) - reader = provider._sdk_config.metric_readers[0] + reader = provider._metric_readers[0] self.assertIsInstance(reader, DummyMetricReader) self.assertIsInstance(reader.exporter, DummyOTLPMetricExporter) @@ -1268,7 +1268,7 @@ class TestMetricsInit(TestCase): self.assertEqual(self.set_provider_mock.call_count, 1) provider = self.set_provider_mock.call_args[0][0] self.assertIsInstance(provider, DummyMeterProvider) - reader = provider._sdk_config.metric_readers[0] + reader = provider._metric_readers[0] self.assertIsInstance(reader, DummyMetricReaderPullExporter) def test_metrics_init_exporter_uses_exporter_args_map(self): @@ -1282,7 +1282,7 @@ class TestMetricsInit(TestCase): }, ) provider = self.set_provider_mock.call_args[0][0] - reader = provider._sdk_config.metric_readers[0] + reader = provider._metric_readers[0] self.assertEqual(reader.exporter.compression, "gzip") def test_metrics_init_meter_configurator_none_by_default(self): @@ -1565,3 +1565,118 @@ class TestClearLoggingHandlers(TestCase): self.assertEqual(logging.config.dictConfig.__name__, "dictConfig") self.assertEqual(logging.basicConfig.__name__, "basicConfig") self.assertEqual(logging.config.fileConfig.__name__, "fileConfig") + + +class TestOpAMPInit(TestCase): + @patch("opentelemetry.sdk._configuration._init_metrics") + @patch("opentelemetry.sdk._configuration._init_tracing") + @patch("opentelemetry.sdk._configuration._init_logging") + @patch("opentelemetry.sdk._configuration.entry_points") + @patch("opentelemetry.sdk._configuration.Resource") + def test_pre_sdk_init_function_found( + self, + mock_resource, + mock_entry_points, + mock_logging, + mock_tracing, + mock_metrics, + ): + init_function = mock.Mock() + mock_entry_points.side_effect = [ + [IterEntryPoint("pre_sdk_init_function", init_function)], + [], + ] + + _initialize_components(id_generator=1) + + mock_entry_points.assert_has_calls( + [ + mock.call( + group="_opentelemetry_opamp", name="pre_sdk_init_function" + ) + ] + ) + init_function.assert_called_once_with( + mock_resource.create.return_value + ) + + @patch("opentelemetry.sdk._configuration._init_metrics") + @patch("opentelemetry.sdk._configuration._init_tracing") + @patch("opentelemetry.sdk._configuration._init_logging") + @patch("opentelemetry.sdk._configuration.entry_points") + @patch("opentelemetry.sdk._configuration.Resource") + def test_post_sdk_init_function_found( + self, + mock_resource, + mock_entry_points, + mock_logging, + mock_tracing, + mock_metrics, + ): + init_function = mock.Mock() + mock_entry_points.side_effect = [ + [], + [IterEntryPoint("post_sdk_init_function", init_function)], + ] + + _initialize_components(id_generator=1) + + mock_entry_points.assert_has_calls( + [ + mock.call( + group="_opentelemetry_opamp", name="post_sdk_init_function" + ) + ] + ) + init_function.assert_called_once_with( + mock_resource.create.return_value + ) + + @patch("opentelemetry.sdk._configuration._init_metrics") + @patch("opentelemetry.sdk._configuration._init_tracing") + @patch("opentelemetry.sdk._configuration._init_logging") + @patch("opentelemetry.sdk._configuration.entry_points") + def test_init_function_load_failure( + self, mock_entry_points, mock_logging, mock_tracing, mock_metrics + ): + entry_point_mock = mock.Mock() + entry_point_mock.load.side_effect = AttributeError( + "module 'foo' has no attribute 'OpampInit'" + ) + mock_entry_points.configure_mock( + return_value=[entry_point_mock], + ) + entry_point_mock.__str__ = lambda x: "<EntryPoint>" + + with self.assertLogs(level="WARNING") as cm: + _initialize_components(id_generator=1) + + mock_entry_points.assert_has_calls( + [ + mock.call( + group="_opentelemetry_opamp", name="pre_sdk_init_function" + ) + ] + ) + + self.assertIn( + "WARNING:opentelemetry.sdk._configuration:Failed to load OpAMP init function from entry point," + " <EntryPoint>: module 'foo' has no attribute 'OpampInit'", + cm.output, + ) + + @patch("opentelemetry.sdk._configuration._init_metrics") + @patch("opentelemetry.sdk._configuration._init_tracing") + @patch("opentelemetry.sdk._configuration._init_logging") + @patch("opentelemetry.sdk._configuration.entry_points") + def test_init_function_not_found( + self, mock_entry_points, mock_logging, mock_tracing, mock_metrics + ): + mock_entry_points.configure_mock(return_value=[]) + + with self.assertLogs(level="DEBUG") as cm: + _initialize_components(id_generator=1) + self.assertIn( + "DEBUG:opentelemetry.sdk._configuration:No OpAMP init function found", + cm.output, + ) diff --git a/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py b/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py index a6bc11c..0a398f2 100644 --- a/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py @@ -8,10 +8,26 @@ from opentelemetry.sdk.trace._sampling_experimental import ( composite_sampler, ) from opentelemetry.sdk.trace._sampling_experimental._rule_based import ( + AllPredicate, + AlwaysMatchPredicate, + AttributePatternsPredicate, AttributePredicate, + AttributeValuesPredicate, + ParentPredicate, + SpanKindPredicate, ) from opentelemetry.sdk.trace.id_generator import RandomIdGenerator from opentelemetry.sdk.trace.sampling import Decision +from opentelemetry.trace import ( + NonRecordingSpan, + SpanContext, + SpanKind, + TraceFlags, + set_span_in_context, +) + +TRACE_ID = int("00112233445566778800000000000000", 16) +SPAN_ID = int("0123456789abcdef", 16) class NameIsFooPredicate: @@ -30,6 +46,36 @@ class NameIsFooPredicate: return "NameIsFooPredicate" +def _predicate_result( + predicate, + *, + parent_ctx=None, + span_kind=None, + attributes=None, +): + return predicate( + parent_ctx=parent_ctx, + name="span", + span_kind=span_kind, + attributes=attributes, + links=None, + trace_state=None, + ) + + +def _parent_context(is_remote: bool): + return set_span_in_context( + NonRecordingSpan( + SpanContext( + TRACE_ID, + SPAN_ID, + is_remote=is_remote, + trace_flags=TraceFlags.get_default(), + ) + ) + ) + + def test_description_with_no_rules(): assert ( composable_rule_based(rules=[]).get_description() @@ -37,14 +83,188 @@ def test_description_with_no_rules(): ) +def test_always_match_predicate(): + assert _predicate_result(AlwaysMatchPredicate()) is True + assert str(AlwaysMatchPredicate()) == "AlwaysMatch" + + +def test_all_predicate_matches_when_all_predicates_match(): + predicate = AllPredicate( + [ + AttributeValuesPredicate("http.route", ["/users"]), + SpanKindPredicate([SpanKind.SERVER]), + ] + ) + + assert ( + _predicate_result( + predicate, + span_kind=SpanKind.SERVER, + attributes={"http.route": "/users"}, + ) + is True + ) + + +def test_all_predicate_does_not_match_when_any_predicate_does_not_match(): + predicate = AllPredicate( + [ + AttributeValuesPredicate("http.route", ["/users"]), + SpanKindPredicate([SpanKind.SERVER]), + ] + ) + + assert ( + _predicate_result( + predicate, + span_kind=SpanKind.CLIENT, + attributes={"http.route": "/users"}, + ) + is False + ) + + +def test_attribute_values_predicate_no_attributes(): + assert ( + _predicate_result(AttributeValuesPredicate("http.route", ["/users"])) + is False + ) + + +def test_attribute_values_predicate_stringifies_values(): + assert ( + _predicate_result( + AttributeValuesPredicate("http.response.status_code", ["404"]), + attributes={"http.response.status_code": 404}, + ) + is True + ) + + +def test_attribute_values_predicate_matches_array_item(): + assert ( + _predicate_result( + AttributeValuesPredicate("http.request.method", ["POST"]), + attributes={"http.request.method": ("GET", "POST")}, + ) + is True + ) + + +def test_attribute_values_predicate_no_match(): + assert ( + _predicate_result( + AttributeValuesPredicate("http.route", ["/users"]), + attributes={"http.route": "/health"}, + ) + is False + ) + + +def test_attribute_patterns_predicate_includes_all_by_default(): + assert ( + _predicate_result( + AttributePatternsPredicate("http.route"), + attributes={"http.route": "/users"}, + ) + is True + ) + + +def test_attribute_patterns_predicate_include_exclude_precedence(): + predicate = AttributePatternsPredicate( + "http.route", + included=["/api/*"], + excluded=["/api/private/*"], + ) + + assert ( + _predicate_result(predicate, attributes={"http.route": "/api/users"}) + is True + ) + assert ( + _predicate_result( + predicate, attributes={"http.route": "/api/private/user"} + ) + is False + ) + + +def test_attribute_patterns_predicate_is_case_sensitive(): + assert ( + _predicate_result( + AttributePatternsPredicate("http.route", included=["/api/*"]), + attributes={"http.route": "/API/users"}, + ) + is False + ) + + +def test_attribute_patterns_predicate_matches_array_item(): + assert ( + _predicate_result( + AttributePatternsPredicate("http.request.method", ["POST"]), + attributes={"http.request.method": ("GET", "POST")}, + ) + is True + ) + + +def test_span_kind_predicate(): + predicate = SpanKindPredicate([SpanKind.SERVER]) + + assert _predicate_result(predicate, span_kind=SpanKind.SERVER) is True + assert _predicate_result(predicate, span_kind=SpanKind.CLIENT) is False + assert _predicate_result(predicate) is False + + +def test_parent_predicate_matches_no_parent(): + assert _predicate_result(ParentPredicate(["none"])) is True + assert _predicate_result(ParentPredicate(["local"])) is False + + +def test_parent_predicate_matches_local_parent(): + local_parent_ctx = _parent_context(is_remote=False) + + assert ( + _predicate_result( + ParentPredicate(["local"]), parent_ctx=local_parent_ctx + ) + is True + ) + assert ( + _predicate_result( + ParentPredicate(["remote"]), parent_ctx=local_parent_ctx + ) + is False + ) + + +def test_parent_predicate_matches_remote_parent(): + remote_parent_ctx = _parent_context(is_remote=True) + + assert ( + _predicate_result( + ParentPredicate(["remote"]), parent_ctx=remote_parent_ctx + ) + is True + ) + assert ( + _predicate_result( + ParentPredicate(["local"]), parent_ctx=remote_parent_ctx + ) + is False + ) + + def test_description_with_rules(): rules = [ - (AttributePredicate("foo", "bar"), composable_always_on()), + (AttributeValuesPredicate("foo", ["bar"]), composable_always_on()), (NameIsFooPredicate(), composable_always_off()), ] assert ( composable_rule_based(rules=rules).get_description() - == "ComposableRuleBased{[(foo=bar:ComposableAlwaysOn),(NameIsFooPredicate:ComposableAlwaysOff)]}" + == "ComposableRuleBased{[(foo in [bar]:ComposableAlwaysOn),(NameIsFooPredicate:ComposableAlwaysOff)]}" ) @@ -95,7 +315,7 @@ def test_should_sample_match(): def test_should_sample_match_multiple_rules(): rules = [ - (AttributePredicate("foo", "bar"), composable_always_off()), + (AttributeValuesPredicate("foo", ["bar"]), composable_always_off()), (NameIsFooPredicate(), composable_always_on()), ] sampler = composite_sampler(composable_rule_based(rules=rules)) |
