diff --git a/.changelog/5365.added b/.changelog/5365.added new file mode 100644 index 0000000000..c522a00668 --- /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-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py index e2ecdfa3a8..d3ba06fa67 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py @@ -13,6 +13,7 @@ load_entry_point, ) from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration.models import AttributeLimits from opentelemetry.sdk._configuration.models import ( BatchLogRecordProcessor as BatchLogRecordProcessorConfig, ) @@ -25,6 +26,9 @@ 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, ) @@ -38,6 +42,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, @@ -48,6 +53,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 @@ -232,9 +239,40 @@ 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, + ) + + 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. @@ -244,21 +282,28 @@ 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: + log_record_limits = _create_log_record_limits( + config.limits, global_attribute_limits + ) + else: + log_record_limits = _create_log_record_limits( + LogRecordLimitsConfig(), 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) @@ -270,6 +315,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. @@ -279,7 +325,11 @@ 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-sdk/src/opentelemetry/sdk/_configuration/_sdk.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_sdk.py index efa26aa936..3d413f62d5 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_sdk.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_sdk.py @@ -23,7 +23,10 @@ from opentelemetry.sdk._configuration._tracer_provider import ( configure_tracer_provider, ) -from opentelemetry.sdk._configuration.models import OpenTelemetryConfiguration +from opentelemetry.sdk._configuration.models import ( + AttributeLimits, + OpenTelemetryConfiguration, +) _logger = logging.getLogger(__name__) @@ -57,8 +60,13 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None: ) return + global_attribute_limits: AttributeLimits | None = 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) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index f6c3a06c7f..70b00da24f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -13,6 +13,9 @@ load_entry_point, ) from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + AttributeLimits, +) from opentelemetry.sdk._configuration.models import ( ExperimentalComposableRuleBasedSampler as RuleBasedSamplerConfig, ) @@ -373,16 +376,30 @@ 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 + attribute_count_limit + if attribute_count_limit is not None else _DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT ), max_events=( @@ -405,13 +422,14 @@ 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, ) 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. @@ -422,6 +440,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. @@ -431,17 +451,14 @@ def create_tracer_provider( if config is not None and config.sampler is not None else _DEFAULT_SAMPLER ) - 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: + span_limits = _create_span_limits( + config.limits, global_attribute_limits + ) + else: + span_limits = _create_span_limits( + SpanLimitsConfig(), global_attribute_limits ) - ) provider = TracerProvider( resource=resource, @@ -459,6 +476,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. @@ -469,7 +487,11 @@ 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-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 74b759c328..5eafbae572 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -295,11 +295,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}), ) @@ -671,6 +673,7 @@ def __init__( *, logger_metrics: LoggerMetricsT, _logger_config: _LoggerConfig, + log_record_limits: LogRecordLimits | None = None, ): super().__init__( instrumentation_scope.name, @@ -683,6 +686,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 @@ -728,6 +732,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) @@ -750,6 +755,7 @@ def emit( record=log_record, resource=self._resource, instrumentation_scope=self._instrumentation_scope, + limits=self._log_record_limits, ) self._logger_metrics.emit_log() @@ -783,6 +789,7 @@ def __init__( *, meter_provider: MeterProvider | None = None, _logger_configurator: _LoggerConfiguratorT | None = None, + log_record_limits: LogRecordLimits | None = None, ): if resource is None: self._resource = Resource.create({}) @@ -802,6 +809,7 @@ def __init__( 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) @@ -829,6 +837,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( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py index 067fb3ded3..ad8f57840a 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py @@ -333,7 +333,7 @@ def __init__( out: typing.IO = sys.stdout, formatter: collections.abc.Callable[ [ReadableSpan], str - ] = lambda span: (span.to_json() + linesep), + ] = lambda span: span.to_json() + linesep, ): self.out = out self.formatter = formatter diff --git a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py index 1835880cf3..55a34e1a36 100644 --- a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py @@ -22,6 +22,10 @@ create_logger_provider, ) from opentelemetry.sdk._configuration.file._loader import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + AttributeLimits, + NameStringValuePair, +) from opentelemetry.sdk._configuration.models import ( BatchLogRecordProcessor as BatchLogRecordProcessorConfig, ) @@ -40,9 +44,6 @@ 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, ) @@ -467,29 +468,55 @@ 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_limits_from_config(self): config = LoggerProviderConfig( processors=[], - limits=LogRecordLimitsConfig(attribute_count_limit=64), + limits=LogRecordLimitsConfig( + attribute_count_limit=64, + attribute_value_length_limit=256, + ), ) - 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", + provider = create_logger_provider(config) + self.assertEqual(provider._log_record_limits.max_attributes, 64) + self.assertEqual(provider._log_record_limits.max_attribute_length, 256) + + 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) - @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() + 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__": diff --git a/opentelemetry-sdk/tests/_configuration/test_sdk.py b/opentelemetry-sdk/tests/_configuration/test_sdk.py index b223ad344c..94e66540b7 100644 --- a/opentelemetry-sdk/tests/_configuration/test_sdk.py +++ b/opentelemetry-sdk/tests/_configuration/test_sdk.py @@ -68,9 +68,11 @@ 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.sdk._configuration._sdk.configure_propagator") diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index abac735fe4..c39384b922 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -15,6 +15,7 @@ create_tracer_provider, ) from opentelemetry.sdk._configuration.file._loader import ConfigurationError +from opentelemetry.sdk._configuration.models import AttributeLimits from opentelemetry.sdk._configuration.models import ( BatchSpanProcessor as BatchSpanProcessorConfig, ) @@ -852,3 +853,50 @@ def test_absent_limits_do_not_read_env_vars(self): provider = self._create_with_limits(SpanLimitsConfig()) self.assertEqual(provider._span_limits.max_span_attributes, 128) self.assertEqual(provider._span_limits.max_events, 128) + + +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)