From 20892331d65a1a86303f93aa00ea4555ad11dfbc Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 22 Jul 2026 00:58:02 -0500 Subject: [PATCH 1/2] Add public Logger.enabled operation to logs API and SDK Implements the Logs API "Enabled" operation. Adds a public enabled(context, severity_number, event_name) -> bool method to the API Logger (default False, matching the no-op logger) and to the SDK Logger, which delegates to processor-level enabled hooks and honors the logger configuration. Adds an optional enabled hook to LogRecordProcessor and the multi-processors so processors can report disabled cheaply. --- .changelog/0000.added | 2 + .../opentelemetry/_logs/_internal/__init__.py | 38 +++++++++ .../tests/logs/test_logger_enabled.py | 49 +++++++++++ .../sdk/_logs/_internal/__init__.py | 80 ++++++++++++++++++ opentelemetry-sdk/tests/logs/test_logs.py | 84 +++++++++++++++++++ 5 files changed, 253 insertions(+) create mode 100644 .changelog/0000.added create mode 100644 opentelemetry-api/tests/logs/test_logger_enabled.py diff --git a/.changelog/0000.added b/.changelog/0000.added new file mode 100644 index 00000000000..5bbc286d370 --- /dev/null +++ b/.changelog/0000.added @@ -0,0 +1,2 @@ +Logs: add public `Logger.enabled(context, severity_number, event_name)` to the +API and SDK, delegating to processor-level `enabled` hooks diff --git a/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py b/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py index 2319a461c9b..5da8d81cfda 100644 --- a/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py +++ b/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py @@ -175,6 +175,32 @@ def emit( ) -> None: """Emits a :class:`LogRecord` representing a log to the processing pipeline.""" + def enabled( + self, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + event_name: str | None = None, + ) -> bool: + """Returns whether this `Logger` is enabled for the given arguments. + + This is an optional performance optimization that lets callers avoid + the cost of constructing a :class:`LogRecord` when it would not be + processed. A returned value of ``True`` means the `Logger` is enabled + for the provided arguments; a returned value of ``False`` means the + `Logger` is disabled for them. + + The parameters mirror the spec's ``EnabledParameters``: + + Args: + context: The context associated with the log record. + severity_number: The severity number of the log record. + event_name: The event name of the log record. + + The default implementation returns ``False``, matching the behavior of + the no-op API where no log records are ever emitted. + """ + return False + class NoOpLogger(Logger): """The default Logger used when no Logger implementation is available. @@ -300,6 +326,18 @@ def emit( exception=exception, ) + def enabled( + self, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + event_name: str | None = None, + ) -> bool: + return self._logger.enabled( + context=context, + severity_number=severity_number, + event_name=event_name, + ) + class LoggerProvider(ABC): """ diff --git a/opentelemetry-api/tests/logs/test_logger_enabled.py b/opentelemetry-api/tests/logs/test_logger_enabled.py new file mode 100644 index 00000000000..a659ecf0ff7 --- /dev/null +++ b/opentelemetry-api/tests/logs/test_logger_enabled.py @@ -0,0 +1,49 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access +import unittest +from unittest.mock import Mock + +import opentelemetry._logs._internal as logs_internal +from opentelemetry._logs import NoOpLogger +from opentelemetry._logs.severity import SeverityNumber + + +class TestLoggerEnabled(unittest.TestCase): + def test_noop_logger_enabled_returns_false_by_default(self): + logger = NoOpLogger("noop-test") + self.assertFalse(logger.enabled()) + + def test_noop_logger_enabled_accepts_parameters(self): + logger = NoOpLogger("noop-test") + self.assertFalse( + logger.enabled( + context=None, + severity_number=SeverityNumber.INFO, + event_name="my.event", + ) + ) + + def test_proxy_logger_delegates_enabled_to_real_logger(self): + logger = logs_internal.ProxyLogger("proxy-test") + real_logger = Mock() + real_logger.enabled.return_value = True + logger._real_logger = real_logger + + result = logger.enabled( + severity_number=SeverityNumber.WARN, event_name="warn.event" + ) + + self.assertTrue(result) + real_logger.enabled.assert_called_once_with( + context=None, + severity_number=SeverityNumber.WARN, + event_name="warn.event", + ) + + def test_proxy_logger_enabled_falls_back_to_noop(self): + # No real logger and no provider set: proxy uses its no-op logger, + # whose enabled() returns False. + logger = logs_internal.ProxyLogger("proxy-test") + self.assertFalse(logger.enabled()) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 5f40c5c8c70..1d50f2850a7 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -396,6 +396,29 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: False if the timeout is exceeded, True otherwise. """ + def enabled( + self, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + event_name: str | None = None, + ) -> bool: + """Returns whether this processor would process a log record with the + given arguments. + + This is an optional performance-optimization hook used by + :meth:`Logger.enabled`. Processors that can cheaply determine that a + log record will be dropped (for example, a severity-based filter) may + override this to return ``False``. The default implementation returns + ``True`` because a processor cannot generally determine ahead of time + whether a record will be processed. + + Args: + context: The context associated with the log record. + severity_number: The severity number of the log record. + event_name: The event name of the log record. + """ + return True + # Temporary fix until https://github.com/PyCQA/pylint/issues/4098 is resolved # pylint:disable=no-member @@ -424,6 +447,23 @@ def on_emit(self, log_record: ReadWriteLogRecord) -> None: for lp in self._log_record_processors: lp.on_emit(log_record) + def enabled( + self, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + event_name: str | None = None, + ) -> bool: + """Returns True if any registered processor is enabled for the given + arguments. Returns False when no processors are registered.""" + for lp in self._log_record_processors: + if lp.enabled( + context=context, + severity_number=severity_number, + event_name=event_name, + ): + return True + return False + def shutdown(self) -> None: """Shutdown the log processors one by one""" for lp in self._log_record_processors: @@ -498,6 +538,23 @@ def _submit_and_wait( def on_emit(self, log_record: ReadWriteLogRecord) -> None: self._submit_and_wait(lambda lp: lp.on_emit, log_record) + def enabled( + self, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + event_name: str | None = None, + ) -> bool: + """Returns True if any registered processor is enabled for the given + arguments. Returns False when no processors are registered.""" + for lp in self._log_record_processors: + if lp.enabled( + context=context, + severity_number=severity_number, + event_name=event_name, + ): + return True + return False + def shutdown(self) -> None: self._submit_and_wait(lambda lp: lp.shutdown) @@ -719,6 +776,29 @@ def __init__( def _is_enabled(self) -> bool: return self._logger_config.is_enabled + def enabled( + self, + context: Context | None = None, + severity_number: SeverityNumber | None = None, + event_name: str | None = None, + ) -> bool: + """Returns whether this `Logger` is enabled for the given arguments. + + The `Logger` is enabled when it has not been disabled by the logger + configuration and at least one registered log record processor is + enabled for the provided arguments. Processors that do not override + their :meth:`LogRecordProcessor.enabled` hook report enabled by + default, so this returns ``True`` whenever at least one processor is + registered and the logger is not disabled. + """ + if not self._is_enabled(): + return False + return self._multi_log_record_processor.enabled( + context=context, + severity_number=severity_number, + event_name=event_name, + ) + def _set_logger_config(self, logger_config: _LoggerConfig) -> None: self._logger_config = logger_config diff --git a/opentelemetry-sdk/tests/logs/test_logs.py b/opentelemetry-sdk/tests/logs/test_logs.py index 8100a684411..aa2311ac76c 100644 --- a/opentelemetry-sdk/tests/logs/test_logs.py +++ b/opentelemetry-sdk/tests/logs/test_logs.py @@ -21,6 +21,8 @@ ReadWriteLogRecord, ) from opentelemetry.sdk._logs._internal import ( + ConcurrentMultiLogRecordProcessor, + LogRecordProcessor, NoOpLogger, SynchronousMultiLogRecordProcessor, _disable_logger_configurator, @@ -551,3 +553,85 @@ def test_emit_readwrite_logrecord_uses_exception(self): self.assertEqual( attributes[exception_attributes.EXCEPTION_TYPE], "RuntimeError" ) + + +class _RecordingProcessor(LogRecordProcessor): + def __init__(self, enabled: bool = True): + self._enabled = enabled + + def on_emit(self, log_record) -> None: + pass + + def shutdown(self) -> None: + pass + + def force_flush(self, timeout_millis: int = 30000) -> bool: + return True + + def enabled( + self, context=None, severity_number=None, event_name=None + ) -> bool: + return self._enabled + + +class TestLoggerEnabled(unittest.TestCase): + def test_enabled_false_when_no_processors(self): + provider = LoggerProvider() + logger = provider.get_logger("test") + self.assertFalse(logger.enabled()) + + def test_enabled_true_with_default_processor(self): + provider = LoggerProvider() + provider.add_log_record_processor(_RecordingProcessor(enabled=True)) + logger = provider.get_logger("test") + self.assertTrue(logger.enabled()) + + def test_enabled_false_when_processor_reports_disabled(self): + provider = LoggerProvider() + provider.add_log_record_processor(_RecordingProcessor(enabled=False)) + logger = provider.get_logger("test") + self.assertFalse(logger.enabled()) + + def test_enabled_true_when_any_processor_enabled(self): + provider = LoggerProvider() + provider.add_log_record_processor(_RecordingProcessor(enabled=False)) + provider.add_log_record_processor(_RecordingProcessor(enabled=True)) + logger = provider.get_logger("test") + self.assertTrue(logger.enabled()) + + def test_enabled_false_when_logger_config_disabled(self): + provider = LoggerProvider( + _logger_configurator=_disable_logger_configurator + ) + provider.add_log_record_processor(_RecordingProcessor(enabled=True)) + logger = provider.get_logger("test") + self.assertFalse(logger.enabled()) + + def test_enabled_forwards_parameters_to_processor(self): + provider = LoggerProvider() + processor = Mock(spec=LogRecordProcessor) + processor.enabled.return_value = True + provider.add_log_record_processor(processor) + logger = provider.get_logger("test") + + result = logger.enabled( + severity_number=SeverityNumber.ERROR, event_name="err.event" + ) + + self.assertTrue(result) + processor.enabled.assert_called_once_with( + context=None, + severity_number=SeverityNumber.ERROR, + event_name="err.event", + ) + + def test_default_processor_enabled_hook_returns_true(self): + self.assertTrue(_RecordingProcessor().enabled()) + + def test_concurrent_multi_processor_enabled(self): + multi = ConcurrentMultiLogRecordProcessor() + self.assertFalse(multi.enabled()) + multi.add_log_record_processor(_RecordingProcessor(enabled=False)) + self.assertFalse(multi.enabled()) + multi.add_log_record_processor(_RecordingProcessor(enabled=True)) + self.assertTrue(multi.enabled()) From fafd546fc7bce7f121202be8995083466ded1d65 Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 22 Jul 2026 08:37:48 -0500 Subject: [PATCH 2/2] Rename changelog fragment to match PR number --- .changelog/{0000.added => 26.added} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changelog/{0000.added => 26.added} (100%) diff --git a/.changelog/0000.added b/.changelog/26.added similarity index 100% rename from .changelog/0000.added rename to .changelog/26.added