From 88b7449a7058bb15e3a5cd6493d59729a36abceb Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 22 Jul 2026 01:30:43 -0500 Subject: [PATCH 1/2] Add programmatic LogRecordLimits to LoggerProvider LoggerProvider.__init__ gains a `limits` argument accepting a LogRecordLimits, mirroring how TracerProvider accepts SpanLimits. The limits are stored on the provider, threaded through each Logger, and applied when building ReadWriteLogRecord instances so attribute count and value-length limits are enforced on emitted log records. Previously LogRecordLimits could only be configured via environment variables; there was no way to set them programmatically, so _from_api_log_record always used the default limits. Default behavior (no limits argument) is unchanged. --- .changelog/0000.added | 2 + .../sdk/_logs/_internal/__init__.py | 21 +++-- .../tests/logs/test_log_limits.py | 77 ++++++++++++++++++- 3 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 .changelog/0000.added diff --git a/.changelog/0000.added b/.changelog/0000.added new file mode 100644 index 00000000000..77b14407a92 --- /dev/null +++ b/.changelog/0000.added @@ -0,0 +1,2 @@ +`LoggerProvider` now accepts a `limits` argument (a `LogRecordLimits`) that bounds the +number and value length of log-record attributes, mirroring `SpanLimits` on `TracerProvider` diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 5f40c5c8c70..ed7264da06c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -327,12 +327,16 @@ 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, - ) + kwargs = { + "log_record": record, + "resource": resource, + "instrumentation_scope": instrumentation_scope, + } + if limits is not None: + kwargs["limits"] = limits + return cls(**kwargs) class LogRecordProcessor(abc.ABC): @@ -703,6 +707,7 @@ def __init__( *, logger_metrics: LoggerMetricsT, _logger_config: _LoggerConfig, + limits: LogRecordLimits | None = None, ): super().__init__( instrumentation_scope.name, @@ -715,6 +720,7 @@ def __init__( self._instrumentation_scope = instrumentation_scope self._logger_metrics = logger_metrics self._logger_config = _logger_config + self._limits = limits or LogRecordLimits() def _is_enabled(self) -> bool: return self._logger_config.is_enabled @@ -763,6 +769,7 @@ def emit( record=record, resource=self._resource, instrumentation_scope=self._instrumentation_scope, + limits=self._limits, ) else: _set_log_record_exception_attributes(record.log_record) @@ -785,6 +792,7 @@ def emit( record=log_record, resource=self._resource, instrumentation_scope=self._instrumentation_scope, + limits=self._limits, ) self._logger_metrics.emit_log() @@ -816,6 +824,7 @@ def __init__( | ConcurrentMultiLogRecordProcessor | None = None, *, + limits: LogRecordLimits | None = None, meter_provider: MeterProvider | None = None, _logger_configurator: _LoggerConfiguratorT | None = None, ): @@ -823,6 +832,7 @@ def __init__( self._resource = Resource.create({}) else: self._resource = resource + self._limits = limits or LogRecordLimits() self._multi_log_record_processor = ( multi_log_record_processor or SynchronousMultiLogRecordProcessor() ) @@ -884,6 +894,7 @@ def _get_logger_no_cache( scope, logger_metrics=self._logger_metrics, _logger_config=self._apply_logger_configurator(scope), + limits=self._limits, ) def _get_logger_cached( diff --git a/opentelemetry-sdk/tests/logs/test_log_limits.py b/opentelemetry-sdk/tests/logs/test_log_limits.py index 3d1f69dc498..0164e332cbb 100644 --- a/opentelemetry-sdk/tests/logs/test_log_limits.py +++ b/opentelemetry-sdk/tests/logs/test_log_limits.py @@ -4,10 +4,16 @@ import unittest from unittest.mock import patch -from opentelemetry.sdk._logs import LogRecordLimits +from opentelemetry._logs import LogRecord as APILogRecord +from opentelemetry._logs import SeverityNumber +from opentelemetry.sdk._logs import LoggerProvider, LogRecordLimits from opentelemetry.sdk._logs._internal import ( _DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT, ) +from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, +) from opentelemetry.sdk.environment_variables import ( OTEL_ATTRIBUTE_COUNT_LIMIT, OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT, @@ -140,3 +146,72 @@ def test_invalid_env_vars_raise(self): str(error.exception), f"Unexpected error message for {env_var}={bad_value}", ) + + +class TestLoggerProviderLimits(unittest.TestCase): + @staticmethod + def _emit_and_get_record(provider, attributes): + exporter = InMemoryLogRecordExporter() + provider.add_log_record_processor(SimpleLogRecordProcessor(exporter)) + logger = provider.get_logger("test_logger_provider_limits") + logger.emit( + APILogRecord( + body="body", + severity_number=SeverityNumber.WARN, + attributes=attributes, + ) + ) + finished = exporter.get_finished_logs() + assert len(finished) == 1 + return finished[0] + + def test_default_provider_uses_default_attribute_count_limit(self): + provider = LoggerProvider() + attributes = { + f"key_{index}": index + for index in range(_DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT + 5) + } + + record = self._emit_and_get_record(provider, attributes) + + self.assertEqual( + len(record.log_record.attributes), + _DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT, + ) + self.assertEqual(record.dropped_attributes, 5) + + def test_programmatic_limits_bound_attribute_count(self): + provider = LoggerProvider( + limits=LogRecordLimits(max_log_record_attributes=2) + ) + attributes = {"a": 1, "b": 2, "c": 3, "d": 4} + + record = self._emit_and_get_record(provider, attributes) + + self.assertEqual(len(record.log_record.attributes), 2) + self.assertEqual(record.dropped_attributes, 2) + + def test_programmatic_limits_bound_attribute_value_length(self): + provider = LoggerProvider( + limits=LogRecordLimits(max_log_record_attribute_length=3) + ) + attributes = {"key": "abcdefgh"} + + record = self._emit_and_get_record(provider, attributes) + + self.assertEqual(record.log_record.attributes["key"], "abc") + + def test_programmatic_limits_via_global_fallbacks(self): + provider = LoggerProvider( + limits=LogRecordLimits(max_attributes=1, max_attribute_length=2) + ) + attributes = {"first": "abcd", "second": "efgh"} + + record = self._emit_and_get_record(provider, attributes) + + # max_attributes / max_attribute_length act as global fallbacks for the + # log-record-specific limits when the latter are unset. + self.assertEqual(len(record.log_record.attributes), 1) + # BoundedAttributes evicts the oldest key when full, so "second" + # survives and its value is truncated to the length limit. + self.assertEqual(record.log_record.attributes["second"], "ef") From c172750f47390ccae78d2f5a4888d85a14e7745f Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 22 Jul 2026 08:38:05 -0500 Subject: [PATCH 2/2] Rename changelog fragment to match PR number --- .changelog/{0000.added => 42.added} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changelog/{0000.added => 42.added} (100%) diff --git a/.changelog/0000.added b/.changelog/42.added similarity index 100% rename from .changelog/0000.added rename to .changelog/42.added