diff --git a/.changelog/42.added b/.changelog/42.added new file mode 100644 index 0000000000..77b14407a9 --- /dev/null +++ b/.changelog/42.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 5f40c5c8c7..ed7264da06 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 3d1f69dc49..0164e332cb 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")