diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5f0c4452..4b30b97661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-sdk`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars + ([#4990](https://github.com/open-telemetry/opentelemetry-python/pull/4990)) - `opentelemetry-sdk`: Add shared `_parse_headers` helper for declarative config OTLP exporters ([#5021](https://github.com/open-telemetry/opentelemetry-python/pull/5021)) - `opentelemetry-api`: Replace a broad exception in attribute cleaning tests to satisfy pylint in the `lint-opentelemetry-api` CI job diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py new file mode 100644 index 0000000000..5091031156 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py @@ -0,0 +1,271 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import logging +from typing import Optional + +from opentelemetry._logs import set_logger_provider +from opentelemetry.sdk._configuration._common import _parse_headers +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + BatchLogRecordProcessor as BatchLogRecordProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + LoggerProvider as LoggerProviderConfig, +) +from opentelemetry.sdk._configuration.models import ( + LogRecordExporter as LogRecordExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + LogRecordProcessor as LogRecordProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpGrpcExporter as OtlpGrpcExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpHttpExporter as OtlpHttpExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + SimpleLogRecordProcessor as SimpleLogRecordProcessorConfig, +) +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs._internal.export import ( + BatchLogRecordProcessor, + ConsoleLogRecordExporter, + LogRecordExporter, + SimpleLogRecordProcessor, +) +from opentelemetry.sdk.resources import Resource + +_logger = logging.getLogger(__name__) + +# BatchLogRecordProcessor defaults per OTel spec (milliseconds). +# Note: The Python SDK reads OTEL_BLRP_SCHEDULE_DELAY with an incorrect default +# of 5000ms. The spec mandates 1000ms. We pass 1000 explicitly here to match +# the spec and suppress env var reading. +_DEFAULT_SCHEDULE_DELAY_MILLIS = 1000 +_DEFAULT_EXPORT_TIMEOUT_MILLIS = 30000 +_DEFAULT_MAX_QUEUE_SIZE = 2048 +_DEFAULT_MAX_EXPORT_BATCH_SIZE = 512 + + +def _map_compression( + value: Optional[str], compression_enum: type +) -> Optional[object]: + """Map a compression string to the given Compression enum value.""" + if value is None or value.lower() == "none": + return None + if value.lower() == "gzip": + return compression_enum.Gzip # type: ignore[attr-defined] + raise ConfigurationError( + f"Unsupported compression value '{value}'. Supported values: 'gzip', 'none'." + ) + + +def _create_console_log_exporter() -> ConsoleLogRecordExporter: + """Create a ConsoleLogRecordExporter.""" + return ConsoleLogRecordExporter() + + +def _create_otlp_http_log_exporter( + config: OtlpHttpExporterConfig, +) -> LogRecordExporter: + """Create an OTLP HTTP log exporter from config.""" + try: + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.exporter.otlp.proto.http import ( # type: ignore[import-untyped] # noqa: PLC0415 + Compression, + ) + from opentelemetry.exporter.otlp.proto.http._log_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415 + OTLPLogExporter, + ) + except ImportError as exc: + raise ConfigurationError( + "otlp_http log exporter requires 'opentelemetry-exporter-otlp-proto-http'. " + "Install it with: pip install opentelemetry-exporter-otlp-proto-http" + ) from exc + + compression = _map_compression(config.compression, Compression) + headers = _parse_headers(config.headers, config.headers_list) + timeout = (config.timeout / 1000.0) if config.timeout is not None else None + + return OTLPLogExporter( # type: ignore[return-value] + endpoint=config.endpoint, + headers=headers, + timeout=timeout, + compression=compression, # type: ignore[arg-type] + ) + + +def _create_otlp_grpc_log_exporter( + config: OtlpGrpcExporterConfig, +) -> LogRecordExporter: + """Create an OTLP gRPC log exporter from config.""" + try: + # pylint: disable=import-outside-toplevel,no-name-in-module + import grpc # type: ignore[import-untyped] # noqa: PLC0415 + + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415 + OTLPLogExporter, + ) + except ImportError as exc: + raise ConfigurationError( + "otlp_grpc log exporter requires 'opentelemetry-exporter-otlp-proto-grpc'. " + "Install it with: pip install opentelemetry-exporter-otlp-proto-grpc" + ) from exc + + compression = _map_compression(config.compression, grpc.Compression) + headers = _parse_headers(config.headers, config.headers_list) + timeout = (config.timeout / 1000.0) if config.timeout is not None else None + + return OTLPLogExporter( # type: ignore[return-value] + endpoint=config.endpoint, + headers=headers, + timeout=timeout, + compression=compression, # type: ignore[arg-type] + ) + + +def _create_log_record_exporter( + config: LogRecordExporterConfig, +) -> LogRecordExporter: + """Create a log record exporter from config.""" + if config.console is not None: + return _create_console_log_exporter() + if config.otlp_http is not None: + return _create_otlp_http_log_exporter(config.otlp_http) + if config.otlp_grpc is not None: + return _create_otlp_grpc_log_exporter(config.otlp_grpc) + if config.otlp_file_development is not None: + raise ConfigurationError( + "otlp_file_development log exporter is experimental and not yet supported." + ) + raise ConfigurationError( + "No exporter type specified in log record exporter config. " + "Supported types: console, otlp_http, otlp_grpc." + ) + + +def _create_batch_log_record_processor( + config: BatchLogRecordProcessorConfig, +) -> BatchLogRecordProcessor: + """Create a BatchLogRecordProcessor from config. + + Passes explicit defaults to suppress OTEL_BLRP_* env var reading. + """ + exporter = _create_log_record_exporter(config.exporter) + schedule_delay = ( + config.schedule_delay + if config.schedule_delay is not None + else _DEFAULT_SCHEDULE_DELAY_MILLIS + ) + export_timeout = ( + config.export_timeout + if config.export_timeout is not None + else _DEFAULT_EXPORT_TIMEOUT_MILLIS + ) + max_queue_size = ( + config.max_queue_size + if config.max_queue_size is not None + else _DEFAULT_MAX_QUEUE_SIZE + ) + max_export_batch_size = ( + config.max_export_batch_size + if config.max_export_batch_size is not None + else _DEFAULT_MAX_EXPORT_BATCH_SIZE + ) + return BatchLogRecordProcessor( + exporter=exporter, + schedule_delay_millis=float(schedule_delay), + export_timeout_millis=float(export_timeout), + max_queue_size=max_queue_size, + max_export_batch_size=max_export_batch_size, + ) + + +def _create_simple_log_record_processor( + config: SimpleLogRecordProcessorConfig, +) -> SimpleLogRecordProcessor: + """Create a SimpleLogRecordProcessor from config.""" + exporter = _create_log_record_exporter(config.exporter) + return SimpleLogRecordProcessor(exporter) + + +def _create_log_record_processor( + config: LogRecordProcessorConfig, +) -> BatchLogRecordProcessor | SimpleLogRecordProcessor: + """Create a log record processor from config.""" + if config.batch is not None: + return _create_batch_log_record_processor(config.batch) + if config.simple is not None: + return _create_simple_log_record_processor(config.simple) + raise ConfigurationError( + "No processor type specified in log record processor config. " + "Supported types: batch, simple." + ) + + +def create_logger_provider( + config: Optional[LoggerProviderConfig], + resource: Optional[Resource] = None, +) -> LoggerProvider: + """Create an SDK LoggerProvider from declarative config. + + Does NOT read OTEL_BLRP_* or other env vars for values explicitly + controlled by the config. Absent config values use OTel spec defaults. + + Args: + config: LoggerProvider config from the parsed config file, or None. + resource: Resource to attach to the provider. + + Returns: + A configured LoggerProvider. + """ + provider = LoggerProvider(resource=resource) + + if config is None: + return provider + + if config.limits is not None: + _logger.warning( + "log_record_limits are specified in config but are not supported " + "by the Python SDK LoggerProvider constructor; limits will be ignored." + ) + + for processor_config in config.processors: + provider.add_log_record_processor( + _create_log_record_processor(processor_config) + ) + + return provider + + +def configure_logger_provider( + config: Optional[LoggerProviderConfig], + resource: Optional[Resource] = None, +) -> None: + """Configure the global LoggerProvider from declarative config. + + When config is None (logger_provider section absent from config file), + the global is not set — matching Java/JS SDK behavior. + + Args: + config: LoggerProvider config from the parsed config file, or None. + resource: Resource to attach to the provider. + """ + if config is None: + return + set_logger_provider(create_logger_provider(config, resource)) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py index 60cc904f13..3c6372bb73 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py @@ -36,13 +36,16 @@ def _load_entry_point_propagator(name: str) -> TextMapPropagator: """Load a propagator by name from the opentelemetry_propagator entry point group.""" try: - eps = list(entry_points(group="opentelemetry_propagator", name=name)) - if not eps: + ep = next( + iter(entry_points(group="opentelemetry_propagator", name=name)), + None, + ) + if not ep: raise ConfigurationError( f"Propagator '{name}' not found. " "It may not be installed or may be misspelled." ) - return eps[0].load()() + return ep.load()() except ConfigurationError: raise except Exception as exc: @@ -85,19 +88,13 @@ def create_propagator( if config is None: return CompositePropagator([]) - propagators: list[TextMapPropagator] = [] - seen_types: set[type] = set() - - def _add_deduped(propagator: TextMapPropagator) -> None: - if type(propagator) not in seen_types: - seen_types.add(type(propagator)) - propagators.append(propagator) + propagators: dict[type[TextMapPropagator], TextMapPropagator] = {} # Process structured composite list if config.composite: for entry in config.composite: for propagator in _propagators_from_textmap_config(entry): - _add_deduped(propagator) + propagators.setdefault(type(propagator), propagator) # Process composite_list (comma-separated propagator names via entry_points) if config.composite_list: @@ -105,9 +102,10 @@ def _add_deduped(propagator: TextMapPropagator) -> None: name = name.strip() if not name or name.lower() == "none": continue - _add_deduped(_load_entry_point_propagator(name)) + propagator = _load_entry_point_propagator(name) + propagators.setdefault(type(propagator), propagator) - return CompositePropagator(propagators) + return CompositePropagator(list(propagators.values())) def configure_propagator(config: Optional[PropagatorConfig]) -> None: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index d58bd4d31d..66e13a3145 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -170,13 +170,9 @@ def _filter_attributes( if not included and not excluded: return attrs - effective_included = included if included else None # [] → include all - result: dict[str, object] = {} for key, value in attrs.items(): - if effective_included is not None and not any( - fnmatch.fnmatch(key, pat) for pat in effective_included - ): + if included and not any(fnmatch.fnmatch(key, pat) for pat in included): continue if excluded and any(fnmatch.fnmatch(key, pat) for pat in excluded): continue diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py index 664bb8bc29..d827b375c1 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py @@ -25,6 +25,10 @@ """ from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration._logger_provider import ( + configure_logger_provider, + create_logger_provider, +) from opentelemetry.sdk._configuration._propagator import ( configure_propagator, create_propagator, @@ -44,4 +48,6 @@ "create_resource", "create_propagator", "configure_propagator", + "create_logger_provider", + "configure_logger_provider", ] diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py index eeab3f2694..2649398170 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py @@ -60,6 +60,10 @@ def _get_schema() -> dict: _logger = logging.getLogger(__name__) +# Re-export for backwards compatibility +__all__ = ["ConfigurationError", "load_config_file"] + + def load_config_file(file_path: str) -> OpenTelemetryConfiguration: """Load and parse an OpenTelemetry configuration file. diff --git a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py new file mode 100644 index 0000000000..ff820d105a --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py @@ -0,0 +1,381 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Tests access private members of SDK classes to assert correct configuration. +# pylint: disable=protected-access + +import sys +import unittest +from unittest.mock import MagicMock, patch + +from opentelemetry._logs import get_logger_provider +from opentelemetry.sdk._configuration._logger_provider import ( + _DEFAULT_EXPORT_TIMEOUT_MILLIS, + _DEFAULT_MAX_EXPORT_BATCH_SIZE, + _DEFAULT_MAX_QUEUE_SIZE, + _DEFAULT_SCHEDULE_DELAY_MILLIS, + _create_batch_log_record_processor, + _create_log_record_exporter, + _create_log_record_processor, + _create_simple_log_record_processor, + configure_logger_provider, + create_logger_provider, +) +from opentelemetry.sdk._configuration.file._loader import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + BatchLogRecordProcessor as BatchLogRecordProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + LoggerProvider as LoggerProviderConfig, +) +from opentelemetry.sdk._configuration.models import ( + LogRecordExporter as LogRecordExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + LogRecordLimits as LogRecordLimitsConfig, +) +from opentelemetry.sdk._configuration.models import ( + LogRecordProcessor as LogRecordProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + NameStringValuePair, +) +from opentelemetry.sdk._configuration.models import ( + OtlpGrpcExporter as OtlpGrpcExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpHttpExporter as OtlpHttpExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + SimpleLogRecordProcessor as SimpleLogRecordProcessorConfig, +) +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs._internal.export import ( + BatchLogRecordProcessor, + ConsoleLogRecordExporter, + SimpleLogRecordProcessor, +) +from opentelemetry.sdk.resources import Resource + + +class TestCreateLoggerProviderBasic(unittest.TestCase): + def test_none_config_returns_provider(self): + provider = create_logger_provider(None) + self.assertIsInstance(provider, LoggerProvider) + + def test_none_config_uses_supplied_resource(self): + resource = Resource({"service.name": "svc"}) + provider = create_logger_provider(None, resource) + self.assertIs(provider.resource, resource) + + def test_config_with_no_processors(self): + config = LoggerProviderConfig(processors=[]) + provider = create_logger_provider(config) + self.assertIsInstance(provider, LoggerProvider) + + def test_configure_none_is_noop(self): + original = get_logger_provider() + configure_logger_provider(None) + self.assertIs(get_logger_provider(), original) + + +class TestCreateLogRecordProcessors(unittest.TestCase): + @staticmethod + def _make_batch_config( + exporter_config=None, + schedule_delay=None, + export_timeout=None, + max_queue_size=None, + max_export_batch_size=None, + ): + if exporter_config is None: + exporter_config = LogRecordExporterConfig(console={}) + return BatchLogRecordProcessorConfig( + exporter=exporter_config, + schedule_delay=schedule_delay, + export_timeout=export_timeout, + max_queue_size=max_queue_size, + max_export_batch_size=max_export_batch_size, + ) + + def test_batch_processor_default_schedule_delay(self): + processor = _create_batch_log_record_processor( + self._make_batch_config() + ) + self.assertEqual( + processor._batch_processor._schedule_delay_millis, + _DEFAULT_SCHEDULE_DELAY_MILLIS, + ) + + def test_batch_processor_default_export_timeout(self): + processor = _create_batch_log_record_processor( + self._make_batch_config() + ) + self.assertEqual( + processor._batch_processor._export_timeout_millis, + _DEFAULT_EXPORT_TIMEOUT_MILLIS, + ) + + def test_batch_processor_default_max_queue_size(self): + processor = _create_batch_log_record_processor( + self._make_batch_config() + ) + self.assertEqual( + processor._batch_processor._max_queue_size, + _DEFAULT_MAX_QUEUE_SIZE, + ) + + def test_batch_processor_default_max_export_batch_size(self): + processor = _create_batch_log_record_processor( + self._make_batch_config() + ) + self.assertEqual( + processor._batch_processor._max_export_batch_size, + _DEFAULT_MAX_EXPORT_BATCH_SIZE, + ) + + def test_batch_processor_explicit_schedule_delay(self): + processor = _create_batch_log_record_processor( + self._make_batch_config(schedule_delay=2000) + ) + self.assertEqual( + processor._batch_processor._schedule_delay_millis, 2000.0 + ) + + def test_batch_processor_explicit_export_timeout(self): + processor = _create_batch_log_record_processor( + self._make_batch_config(export_timeout=5000) + ) + self.assertEqual( + processor._batch_processor._export_timeout_millis, 5000.0 + ) + + def test_batch_processor_explicit_max_queue_size(self): + processor = _create_batch_log_record_processor( + self._make_batch_config(max_queue_size=512) + ) + self.assertEqual(processor._batch_processor._max_queue_size, 512) + + def test_batch_processor_explicit_max_export_batch_size(self): + processor = _create_batch_log_record_processor( + self._make_batch_config(max_export_batch_size=128) + ) + self.assertEqual( + processor._batch_processor._max_export_batch_size, 128 + ) + + def test_batch_processor_uses_console_exporter(self): + processor = _create_batch_log_record_processor( + self._make_batch_config() + ) + self.assertIsInstance( + processor._batch_processor._exporter, ConsoleLogRecordExporter + ) + + def test_simple_processor_uses_console_exporter(self): + config = SimpleLogRecordProcessorConfig( + exporter=LogRecordExporterConfig(console={}) + ) + processor = _create_simple_log_record_processor(config) + self.assertIsInstance(processor, SimpleLogRecordProcessor) + self.assertIsInstance(processor._exporter, ConsoleLogRecordExporter) + + def test_batch_processor_dispatched_from_processor_config(self): + config = LogRecordProcessorConfig(batch=self._make_batch_config()) + processor = _create_log_record_processor(config) + self.assertIsInstance(processor, BatchLogRecordProcessor) + + def test_simple_processor_dispatched_from_processor_config(self): + config = LogRecordProcessorConfig( + simple=SimpleLogRecordProcessorConfig( + exporter=LogRecordExporterConfig(console={}) + ) + ) + processor = _create_log_record_processor(config) + self.assertIsInstance(processor, SimpleLogRecordProcessor) + + def test_no_processor_type_raises(self): + config = LogRecordProcessorConfig() + with self.assertRaises(ConfigurationError): + _create_log_record_processor(config) + + def test_batch_processor_suppresses_env_var(self): + """schedule_delay default must not read OTEL_BLRP_SCHEDULE_DELAY.""" + with patch.dict("os.environ", {"OTEL_BLRP_SCHEDULE_DELAY": "9999"}): + processor = _create_batch_log_record_processor( + self._make_batch_config() + ) + self.assertEqual( + processor._batch_processor._schedule_delay_millis, + _DEFAULT_SCHEDULE_DELAY_MILLIS, + ) + + +class TestCreateLogRecordExporters(unittest.TestCase): + def test_console_exporter(self): + config = LogRecordExporterConfig(console={}) + exporter = _create_log_record_exporter(config) + self.assertIsInstance(exporter, ConsoleLogRecordExporter) + + def test_otlp_file_development_raises(self): + config = LogRecordExporterConfig(otlp_file_development={}) + with self.assertRaises(ConfigurationError): + _create_log_record_exporter(config) + + def test_no_exporter_type_raises(self): + config = LogRecordExporterConfig() + with self.assertRaises(ConfigurationError): + _create_log_record_exporter(config) + + def test_otlp_http_missing_package_raises(self): + config = LogRecordExporterConfig( + otlp_http=OtlpHttpExporterConfig(endpoint="http://localhost:4318") + ) + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http": None, + "opentelemetry.exporter.otlp.proto.http._log_exporter": None, + }, + ): + with self.assertRaises(ConfigurationError): + _create_log_record_exporter(config) + + def test_otlp_grpc_missing_package_raises(self): + config = LogRecordExporterConfig( + otlp_grpc=OtlpGrpcExporterConfig(endpoint="http://localhost:4317") + ) + with patch.dict( + sys.modules, + { + "grpc": None, + "opentelemetry.exporter.otlp.proto.grpc._log_exporter": None, + }, + ): + with self.assertRaises(ConfigurationError): + _create_log_record_exporter(config) + + def test_otlp_http_exporter_endpoint(self): + mock_exporter_cls = MagicMock() + mock_compression_cls = MagicMock() + mock_compression_cls.Gzip = "gzip" + + mock_module = MagicMock() + mock_module.Compression = mock_compression_cls + mock_log_module = MagicMock() + mock_log_module.OTLPLogExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http": mock_module, + "opentelemetry.exporter.otlp.proto.http._log_exporter": mock_log_module, + }, + ): + config = LogRecordExporterConfig( + otlp_http=OtlpHttpExporterConfig( + endpoint="http://collector:4318", + timeout=5000, + ) + ) + _create_log_record_exporter(config) + + mock_exporter_cls.assert_called_once() + call_kwargs = mock_exporter_cls.call_args.kwargs + self.assertEqual(call_kwargs["endpoint"], "http://collector:4318") + self.assertAlmostEqual(call_kwargs["timeout"], 5.0) + + def test_otlp_http_exporter_headers(self): + mock_exporter_cls = MagicMock() + mock_compression_cls = MagicMock() + mock_module = MagicMock() + mock_module.Compression = mock_compression_cls + mock_log_module = MagicMock() + mock_log_module.OTLPLogExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http": mock_module, + "opentelemetry.exporter.otlp.proto.http._log_exporter": mock_log_module, + }, + ): + config = LogRecordExporterConfig( + otlp_http=OtlpHttpExporterConfig( + headers=[ + NameStringValuePair(name="x-api-key", value="secret") + ] + ) + ) + _create_log_record_exporter(config) + + call_kwargs = mock_exporter_cls.call_args.kwargs + self.assertEqual(call_kwargs["headers"], {"x-api-key": "secret"}) + + def test_otlp_grpc_exporter_endpoint(self): + mock_exporter_cls = MagicMock() + mock_grpc = MagicMock() + mock_grpc.Compression = MagicMock() + mock_grpc_log_module = MagicMock() + mock_grpc_log_module.OTLPLogExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "grpc": mock_grpc, + "opentelemetry.exporter.otlp.proto.grpc._log_exporter": mock_grpc_log_module, + }, + ): + config = LogRecordExporterConfig( + otlp_grpc=OtlpGrpcExporterConfig( + endpoint="http://collector:4317", + timeout=10000, + ) + ) + _create_log_record_exporter(config) + + mock_exporter_cls.assert_called_once() + call_kwargs = mock_exporter_cls.call_args.kwargs + self.assertEqual(call_kwargs["endpoint"], "http://collector:4317") + self.assertAlmostEqual(call_kwargs["timeout"], 10.0) + + +class TestLogRecordLimits(unittest.TestCase): + def test_limits_logs_warning(self): + config = LoggerProviderConfig( + processors=[], + limits=LogRecordLimitsConfig(attribute_count_limit=64), + ) + with self.assertLogs( + "opentelemetry.sdk._configuration._logger_provider", + level="WARNING", + ) as cm: + create_logger_provider(config) + self.assertTrue( + any("limits" in msg for msg in cm.output), + "Expected warning about unsupported limits", + ) + + @staticmethod + def test_no_limits_no_warning(): + config = LoggerProviderConfig(processors=[]) + with patch( + "opentelemetry.sdk._configuration._logger_provider._logger" + ) as mock_logger: + create_logger_provider(config) + mock_logger.warning.assert_not_called() + + +if __name__ == "__main__": + unittest.main()