diff --git a/.changelog/5365.added b/.changelog/5365.added new file mode 100644 index 00000000000..c522a006681 --- /dev/null +++ b/.changelog/5365.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: wire top-level `attribute_limits` into per-signal providers via declarative config; add `log_record_limits` support to `LoggerProvider` \ No newline at end of file diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py index 4214b2f9b76..92fe801a526 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py @@ -16,6 +16,9 @@ ConfigurationError, MissingDependencyError, ) +from opentelemetry.configuration.models import ( + AttributeLimits, +) from opentelemetry.configuration.models import ( BatchLogRecordProcessor as BatchLogRecordProcessorConfig, ) @@ -28,6 +31,9 @@ from opentelemetry.configuration.models import ( LogRecordExporter as LogRecordExporterConfig, ) +from opentelemetry.configuration.models import ( + LogRecordLimits as LogRecordLimitsConfig, +) from opentelemetry.configuration.models import ( LogRecordProcessor as LogRecordProcessorConfig, ) @@ -41,6 +47,7 @@ SimpleLogRecordProcessor as SimpleLogRecordProcessorConfig, ) from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs._internal import LogRecordLimits from opentelemetry.sdk._logs._internal.export import ( BatchLogRecordProcessor, ConsoleLogRecordExporter, @@ -51,6 +58,8 @@ _logger = logging.getLogger(__name__) +_DEFAULT_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT = 128 + # BatchLogRecordProcessor defaults per OTel spec (milliseconds). _DEFAULT_SCHEDULE_DELAY_MILLIS = 1000 _DEFAULT_EXPORT_TIMEOUT_MILLIS = 30000 @@ -216,9 +225,38 @@ def _create_log_record_processor( ) +def _create_log_record_limits( + config: LogRecordLimitsConfig, + global_limits: AttributeLimits | None = None, +) -> LogRecordLimits: + """Create LogRecordLimits from config. + + Absent fields fall back to global_limits (if provided), then to OTel spec + defaults (128 for counts, unlimited for lengths). + Explicit values suppress env-var reading — matching Java SDK behavior. + """ + attribute_count_limit = config.attribute_count_limit + if attribute_count_limit is None and global_limits is not None: + attribute_count_limit = global_limits.attribute_count_limit + + attribute_value_length_limit = config.attribute_value_length_limit + if attribute_value_length_limit is None and global_limits is not None: + attribute_value_length_limit = global_limits.attribute_value_length_limit + + return LogRecordLimits( + max_attributes=( + attribute_count_limit if attribute_count_limit is not None else _DEFAULT_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT + ), + max_attribute_length=( + attribute_value_length_limit if attribute_value_length_limit is not None else LogRecordLimits.UNSET + ), + ) + + def create_logger_provider( config: LoggerProviderConfig | None, resource: Resource | None = None, + global_attribute_limits: AttributeLimits | None = None, ) -> LoggerProvider: """Create an SDK LoggerProvider from declarative config. @@ -228,21 +266,25 @@ def create_logger_provider( Args: config: LoggerProvider config from the parsed config file, or None. resource: Resource to attach to the provider. + global_attribute_limits: Top-level attribute_limits from the root config, + used as a fallback when per-signal limits are not specified. Returns: A configured LoggerProvider. """ - provider = LoggerProvider(resource=resource) + if config is not None and config.limits is not None: + limits = config.limits + + else: + limits = LogRecordLimitsConfig() + + log_record_limits = _create_log_record_limits(limits, global_attribute_limits) + + provider = LoggerProvider(resource=resource, log_record_limits=log_record_limits) 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)) @@ -252,6 +294,7 @@ def create_logger_provider( def configure_logger_provider( config: LoggerProviderConfig | None, resource: Resource | None = None, + global_attribute_limits: AttributeLimits | None = None, ) -> None: """Configure the global LoggerProvider from declarative config. @@ -261,7 +304,9 @@ def configure_logger_provider( Args: config: LoggerProvider config from the parsed config file, or None. resource: Resource to attach to the provider. + global_attribute_limits: Top-level attribute_limits from the root config, + used as a fallback when per-signal limits are not specified. """ if config is None: return - set_logger_provider(create_logger_provider(config, resource)) + set_logger_provider(create_logger_provider(config, resource, global_attribute_limits)) diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py index 698c88cce5d..12b5081b834 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py @@ -99,9 +99,10 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None: level = _SEVERITY_TO_LOGGING_LEVEL.get(config.log_level, INFO) getLogger("opentelemetry").setLevel(level) + global_attribute_limits = config.attribute_limits resource = create_resource(config.resource) - configure_tracer_provider(config.tracer_provider, resource) + configure_tracer_provider(config.tracer_provider, resource, global_attribute_limits) configure_meter_provider(config.meter_provider, resource) - configure_logger_provider(config.logger_provider, resource) + configure_logger_provider(config.logger_provider, resource, global_attribute_limits) configure_propagator(config.propagator) configure_instrumentation(config.instrumentation_development) diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py index 733691dc67d..53a69c3359b 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py @@ -16,6 +16,9 @@ ConfigurationError, MissingDependencyError, ) +from opentelemetry.configuration.models import ( + AttributeLimits, +) from opentelemetry.configuration.models import ( ExperimentalComposableRuleBasedSampler as RuleBasedSamplerConfig, ) @@ -357,17 +360,27 @@ def _create_parent_based_sampler(config: ParentBasedSamplerConfig) -> Sampler: return ParentBased(**kwargs) -def _create_span_limits(config: SpanLimitsConfig) -> SpanLimits: +def _create_span_limits( + config: SpanLimitsConfig, + global_limits: AttributeLimits | None = None, +) -> SpanLimits: """Create SpanLimits from config. - Absent fields use the OTel spec defaults (128 for counts, unlimited for lengths). + Absent fields fall back to global_limits (if provided), then to OTel spec + defaults (128 for counts, unlimited for lengths). Explicit values suppress env-var reading — matching Java SDK behavior. """ + attribute_count_limit = config.attribute_count_limit + if attribute_count_limit is None and global_limits is not None: + attribute_count_limit = global_limits.attribute_count_limit + + attribute_value_length_limit = config.attribute_value_length_limit + if attribute_value_length_limit is None and global_limits is not None: + attribute_value_length_limit = global_limits.attribute_value_length_limit + return SpanLimits( max_span_attributes=( - config.attribute_count_limit - if config.attribute_count_limit is not None - else _DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT + attribute_count_limit if attribute_count_limit is not None else _DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT ), max_events=( config.event_count_limit if config.event_count_limit is not None else _DEFAULT_OTEL_SPAN_EVENT_COUNT_LIMIT @@ -385,13 +398,19 @@ def _create_span_limits(config: SpanLimitsConfig) -> SpanLimits: if config.link_attribute_count_limit is not None else _DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT ), - max_attribute_length=config.attribute_value_length_limit, + max_attribute_length=( + attribute_value_length_limit if attribute_value_length_limit is not None else SpanLimits.UNSET + ), + max_span_attribute_length=( + attribute_value_length_limit if attribute_value_length_limit is not None else SpanLimits.UNSET + ), ) def create_tracer_provider( config: TracerProviderConfig | None, resource: Resource | None = None, + global_attribute_limits: AttributeLimits | None = None, ) -> TracerProvider: """Create an SDK TracerProvider from declarative config. @@ -402,6 +421,8 @@ def create_tracer_provider( Args: config: TracerProvider config from the parsed config file, or None. resource: Resource to attach to the provider. + global_attribute_limits: Top-level attribute_limits from the root config, + used as a fallback when per-signal limits are not specified. Returns: A configured TracerProvider. @@ -410,17 +431,13 @@ def create_tracer_provider( id_generator = ( _create_id_generator(config.id_generator) if config is not None and config.id_generator is not None else None ) - span_limits = ( - _create_span_limits(config.limits) - if config is not None and config.limits is not None - else SpanLimits( - max_span_attributes=_DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, - max_events=_DEFAULT_OTEL_SPAN_EVENT_COUNT_LIMIT, - max_links=_DEFAULT_OTEL_SPAN_LINK_COUNT_LIMIT, - max_event_attributes=_DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT, - max_link_attributes=_DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT, - ) - ) + if config is not None and config.limits is not None: + limits = config.limits + + else: + limits = SpanLimitsConfig() + + span_limits = _create_span_limits(limits, global_attribute_limits) provider = TracerProvider( resource=resource, @@ -439,6 +456,7 @@ def create_tracer_provider( def configure_tracer_provider( config: TracerProviderConfig | None, resource: Resource | None = None, + global_attribute_limits: AttributeLimits | None = None, ) -> None: """Configure the global TracerProvider from declarative config. @@ -449,7 +467,9 @@ def configure_tracer_provider( Args: config: TracerProvider config from the parsed config file, or None. resource: Resource to attach to the provider. + global_attribute_limits: Top-level attribute_limits from the root config, + used as a fallback when per-signal limits are not specified. """ if config is None: return - trace.set_tracer_provider(create_tracer_provider(config, resource)) + trace.set_tracer_provider(create_tracer_provider(config, resource, global_attribute_limits)) diff --git a/opentelemetry-configuration/tests/test_logger_provider.py b/opentelemetry-configuration/tests/test_logger_provider.py index 9965e4b6bfe..306e3e76ffa 100644 --- a/opentelemetry-configuration/tests/test_logger_provider.py +++ b/opentelemetry-configuration/tests/test_logger_provider.py @@ -4,6 +4,7 @@ # Tests access private members of SDK classes to assert correct configuration. # pylint: disable=protected-access +import os import sys import unittest from unittest.mock import MagicMock, patch @@ -22,6 +23,10 @@ create_logger_provider, ) from opentelemetry.configuration.file._loader import ConfigurationError +from opentelemetry.configuration.models import ( + AttributeLimits, + NameStringValuePair, +) from opentelemetry.configuration.models import ( BatchLogRecordProcessor as BatchLogRecordProcessorConfig, ) @@ -40,9 +45,6 @@ from opentelemetry.configuration.models import ( LogRecordProcessor as LogRecordProcessorConfig, ) -from opentelemetry.configuration.models import ( - NameStringValuePair, -) from opentelemetry.configuration.models import ( OtlpGrpcExporter as OtlpGrpcExporterConfig, ) @@ -409,20 +411,34 @@ def test_otlp_grpc_exporter_endpoint(self): class TestLogRecordLimits(unittest.TestCase): - def test_limits_logs_warning(self): + def test_default_limits(self): + provider = create_logger_provider(None) + self.assertEqual(provider._log_record_limits.max_attributes, 128) + self.assertIsNone(provider._log_record_limits.max_attribute_length) + + def test_default_limits_do_not_read_env_vars(self): + with patch.dict( + os.environ, + { + "OTEL_ATTRIBUTE_COUNT_LIMIT": "1", + "OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT": "2", + }, + ): + provider = create_logger_provider(None) + self.assertEqual(provider._log_record_limits.max_attributes, 128) + self.assertIsNone(provider._log_record_limits.max_attribute_length) + + def test_limits_from_config(self): config = LoggerProviderConfig( processors=[], - limits=LogRecordLimitsConfig(attribute_count_limit=64), - ) - with self.assertLogs( - "opentelemetry.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", + limits=LogRecordLimitsConfig( + attribute_count_limit=64, + attribute_value_length_limit=256, + ), ) + provider = create_logger_provider(config) + self.assertEqual(provider._log_record_limits.max_attributes, 64) + self.assertEqual(provider._log_record_limits.max_attribute_length, 256) @staticmethod def test_no_limits_no_warning(): @@ -431,6 +447,31 @@ def test_no_limits_no_warning(): create_logger_provider(config) mock_logger.warning.assert_not_called() + def test_global_attribute_count_limit_used_when_no_per_signal_limits(self): + global_limits = AttributeLimits(attribute_count_limit=42) + provider = create_logger_provider(None, global_attribute_limits=global_limits) + self.assertEqual(provider._log_record_limits.max_attributes, 42) + + def test_global_attribute_value_length_limit_used_when_no_per_signal_limits( + self, + ): + global_limits = AttributeLimits(attribute_value_length_limit=64) + provider = create_logger_provider(None, global_attribute_limits=global_limits) + self.assertEqual(provider._log_record_limits.max_attribute_length, 64) + + def test_per_signal_limits_override_global(self): + global_limits = AttributeLimits(attribute_count_limit=100, attribute_value_length_limit=200) + config = LoggerProviderConfig( + processors=[], + limits=LogRecordLimitsConfig( + attribute_count_limit=7, + attribute_value_length_limit=16, + ), + ) + provider = create_logger_provider(config, global_attribute_limits=global_limits) + self.assertEqual(provider._log_record_limits.max_attributes, 7) + self.assertEqual(provider._log_record_limits.max_attribute_length, 16) + if __name__ == "__main__": unittest.main() diff --git a/opentelemetry-configuration/tests/test_sdk.py b/opentelemetry-configuration/tests/test_sdk.py index 6a6d2cf1be6..f2a1fc6ab69 100644 --- a/opentelemetry-configuration/tests/test_sdk.py +++ b/opentelemetry-configuration/tests/test_sdk.py @@ -70,9 +70,9 @@ def test_calls_each_signal_with_resource( configure_sdk(config) mock_create_resource.assert_called_once_with(resource_cfg) - mock_tracer.assert_called_once_with(tracer_cfg, sentinel_resource) + mock_tracer.assert_called_once_with(tracer_cfg, sentinel_resource, None) mock_meter.assert_called_once_with(None, sentinel_resource) - mock_logger.assert_called_once_with(None, sentinel_resource) + mock_logger.assert_called_once_with(None, sentinel_resource, None) mock_propagator.assert_called_once_with(propagator_cfg) @patch("opentelemetry.configuration._sdk.configure_propagator") diff --git a/opentelemetry-configuration/tests/test_tracer_provider.py b/opentelemetry-configuration/tests/test_tracer_provider.py index 9c950a0e049..266eaf0f0ad 100644 --- a/opentelemetry-configuration/tests/test_tracer_provider.py +++ b/opentelemetry-configuration/tests/test_tracer_provider.py @@ -16,6 +16,9 @@ create_tracer_provider, ) from opentelemetry.configuration.file._loader import ConfigurationError +from opentelemetry.configuration.models import ( + AttributeLimits, +) from opentelemetry.configuration.models import ( BatchSpanProcessor as BatchSpanProcessorConfig, ) @@ -780,11 +783,15 @@ def test_absent_limits_do_not_read_env_vars(self): { "OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT": "1", "OTEL_SPAN_EVENT_COUNT_LIMIT": "2", + "OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT": "3", + "OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT": "4", }, ): provider = self._create_with_limits(SpanLimitsConfig()) self.assertEqual(provider._span_limits.max_span_attributes, 128) self.assertEqual(provider._span_limits.max_events, 128) + self.assertIsNone(provider._span_limits.max_attribute_length) + self.assertIsNone(provider._span_limits.max_span_attribute_length) class TestCreateIdGenerator(unittest.TestCase): @@ -830,3 +837,66 @@ def test_empty_id_generator_raises_configuration_error(self): """Empty IdGenerator config (no type specified) raises ConfigurationError.""" with self.assertRaises(ConfigurationError): self._make_provider(IdGeneratorConfig()) + + +class TestGlobalAttributeLimitsFallback(unittest.TestCase): + # pylint: disable=no-self-use + + def test_global_attribute_count_limit_used_when_no_per_signal_limits(self): + global_limits = AttributeLimits(attribute_count_limit=42) + provider = create_tracer_provider( + TracerProviderConfig(processors=[]), + global_attribute_limits=global_limits, + ) + self.assertEqual(provider._span_limits.max_span_attributes, 42) + + def test_global_attribute_value_length_limit_used_when_no_per_signal_limits( + self, + ): + global_limits = AttributeLimits(attribute_value_length_limit=64) + provider = create_tracer_provider( + TracerProviderConfig(processors=[]), + global_attribute_limits=global_limits, + ) + self.assertEqual(provider._span_limits.max_attribute_length, 64) + + def test_per_signal_limits_take_precedence_over_global(self): + global_limits = AttributeLimits( + attribute_count_limit=99, + attribute_value_length_limit=99, + ) + provider = create_tracer_provider( + TracerProviderConfig( + processors=[], + limits=SpanLimitsConfig( + attribute_count_limit=7, + attribute_value_length_limit=16, + ), + ), + global_attribute_limits=global_limits, + ) + self.assertEqual(provider._span_limits.max_span_attributes, 7) + self.assertEqual(provider._span_limits.max_attribute_length, 16) + + def test_global_limits_absent_uses_sdk_defaults(self): + provider = create_tracer_provider( + TracerProviderConfig(processors=[]), + ) + self.assertEqual(provider._span_limits.max_span_attributes, 128) + self.assertIsNone(provider._span_limits.max_attribute_length) + + def test_global_limits_absent_does_not_read_env_vars(self): + with patch.dict( + os.environ, + { + "OTEL_ATTRIBUTE_COUNT_LIMIT": "1", + "OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT": "2", + "OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT": "3", + }, + ): + provider = create_tracer_provider( + TracerProviderConfig(processors=[]), + ) + self.assertEqual(provider._span_limits.max_span_attributes, 128) + self.assertIsNone(provider._span_limits.max_attribute_length) + self.assertIsNone(provider._span_limits.max_span_attribute_length) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 300f5c3cecc..077044cc968 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -123,7 +123,7 @@ class LogRecordLimits: This class does not enforce any limits itself. It only provides a way to read limits from env, default values and from user provided arguments. - All limit arguments must be either a non-negative integer or ``None``. + All limit arguments must be either a non-negative integer, ``None`` or ``LogRecordLimits.UNSET``. - All limit arguments are optional. - If a limit argument is not set, the class will try to read its value from the corresponding @@ -151,6 +151,8 @@ class LogRecordLimits: Falls back to ``max_attribute_length`` when unset. """ + UNSET = -1 + def __init__( self, max_attributes: int | None = None, @@ -193,6 +195,9 @@ def __repr__(self): @classmethod def _from_env_if_absent(cls, value: int | None, env_var: str, default: int | None = None) -> int | None: + if value == cls.UNSET: + return None + err_msg = "{} must be a non-negative integer but got {}" # if no value is provided for the limit, try to load it from env @@ -310,11 +315,13 @@ def _from_api_log_record( record: LogRecord, resource: Resource, instrumentation_scope: InstrumentationScope | None = None, + limits: LogRecordLimits | None = None, ) -> ReadWriteLogRecord: return cls( log_record=record, resource=resource, instrumentation_scope=instrumentation_scope, + **({} if limits is None else {"limits": limits}), ) @@ -682,6 +689,7 @@ def __init__( instrumentation_scope: InstrumentationScope, *, logger_metrics: LoggerMetricsT, + log_record_limits: LogRecordLimits | None = None, _logger_config: _LoggerConfig, ): super().__init__( @@ -695,6 +703,7 @@ def __init__( self._instrumentation_scope = instrumentation_scope self._logger_metrics = logger_metrics self._logger_config = _logger_config + self._log_record_limits = log_record_limits or LogRecordLimits() def _is_enabled(self) -> bool: return self._logger_config.is_enabled @@ -743,6 +752,7 @@ def emit( record=record, resource=self._resource, instrumentation_scope=self._instrumentation_scope, + limits=self._log_record_limits, ) else: _set_log_record_exception_attributes(record.log_record) @@ -765,6 +775,7 @@ def emit( record=log_record, resource=self._resource, instrumentation_scope=self._instrumentation_scope, + limits=self._log_record_limits, ) self._logger_metrics.emit_log() @@ -797,6 +808,7 @@ def __init__( | None = None, *, meter_provider: MeterProvider | None = None, + log_record_limits: LogRecordLimits | None = None, _logger_configurator: _LoggerConfiguratorT | None = None, ): if resource is None: @@ -811,6 +823,7 @@ def __init__( disabled = environ.get(OTEL_SDK_DISABLED, "") self._disabled = disabled.lower().strip() == "true" self._logger_configurator = _logger_configurator or _default_logger_configurator + self._log_record_limits = log_record_limits or LogRecordLimits() self._at_exit_handler = None if shutdown_on_exit: self._at_exit_handler = atexit.register(self.shutdown) @@ -858,6 +871,7 @@ def _get_logger_no_cache( scope, logger_metrics=self._logger_metrics, _logger_config=self._apply_logger_configurator(scope), + log_record_limits=self._log_record_limits, ) def _get_logger_cached(