From d384b59c7238b0663e7f49e84f860536701346f9 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Wed, 18 Mar 2026 14:48:21 +0100 Subject: [PATCH] feat(logging): Separate ignore lists for events/breadcrumbs and Sentry Logs Split _IGNORED_LOGGERS into two independent sets so that framework loggers silenced for events/breadcrumbs (e.g. django.server) can still be captured as Sentry Logs. - _IGNORED_LOGGERS controls EventHandler and BreadcrumbHandler - _IGNORED_LOGGERS_SENTRY_LOGS controls SentryLogsHandler - Add ignore_logger_for_sentry_logs() and unignore_logger*() helpers - Move _can_record into each handler class with its own ignore set - Split _handle_record into separate event and sentry-logs paths Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + sentry_sdk/integrations/logging.py | 94 +++++++++++++++++++--- tests/integrations/logging/test_logging.py | 55 ++++++++++++- 3 files changed, 134 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 4e35d43fb5..d71a0e3145 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ sentry-python-serverless*.zip .eggs venv .venv +tox.venv .vscode/tags .pytest_cache .hypothesis diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py index 42029c5a7a..ca13afc1e9 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -54,10 +54,18 @@ # # Note: Ignoring by logger name here is better than mucking with thread-locals. # We do not necessarily know whether thread-locals work 100% correctly in the user's environment. +# +# Events/breadcrumbs and Sentry Logs have separate ignore lists so that +# framework loggers silenced for events (e.g. django.server) can still be +# captured as Sentry Logs. _IGNORED_LOGGERS = set( ["sentry_sdk.errors", "urllib3.connectionpool", "urllib3.connection"] ) +_IGNORED_LOGGERS_SENTRY_LOGS = set( + ["sentry_sdk.errors", "urllib3.connectionpool", "urllib3.connection"] +) + def ignore_logger( name: str, @@ -67,11 +75,47 @@ def ignore_logger( use this to prevent their actions being recorded as breadcrumbs. Exposed to users as a way to quiet spammy loggers. + This does **not** affect Sentry Logs — use + :py:func:`ignore_logger_for_sentry_logs` for that. + :param name: The name of the logger to ignore (same string you would pass to ``logging.getLogger``). """ _IGNORED_LOGGERS.add(name) +def ignore_logger_for_sentry_logs( + name: str, +) -> None: + """This disables recording as Sentry Logs calls to a logger of a + specific name. + + :param name: The name of the logger to ignore (same string you would pass to ``logging.getLogger``). + """ + _IGNORED_LOGGERS_SENTRY_LOGS.add(name) + + +def unignore_logger( + name: str, +) -> None: + """Reverts a previous :py:func:`ignore_logger` call, re-enabling + recording of breadcrumbs and events for the named logger. + + :param name: The name of the logger to unignore. + """ + _IGNORED_LOGGERS.discard(name) + + +def unignore_logger_for_sentry_logs( + name: str, +) -> None: + """Reverts a previous :py:func:`ignore_logger_for_sentry_logs` call, + re-enabling recording of Sentry Logs for the named logger. + + :param name: The name of the logger to unignore. + """ + _IGNORED_LOGGERS_SENTRY_LOGS.discard(name) + + class LoggingIntegration(Integration): identifier = "logging" @@ -104,6 +148,7 @@ def _handle_record(self, record: "LogRecord") -> None: ): self._breadcrumb_handler.handle(record) + def _handle_sentry_logs_record(self, record: "LogRecord") -> None: if ( self._sentry_logs_handler is not None and record.levelno >= self._sentry_logs_handler.level @@ -118,6 +163,7 @@ def sentry_patched_callhandlers(self: "Any", record: "LogRecord") -> "Any": # keeping a local reference because the # global might be discarded on shutdown ignored_loggers = _IGNORED_LOGGERS + ignored_loggers_sentry_logs = _IGNORED_LOGGERS_SENTRY_LOGS try: return old_callhandlers(self, record) @@ -126,15 +172,25 @@ def sentry_patched_callhandlers(self: "Any", record: "LogRecord") -> "Any": # the integration. Otherwise we have a high chance of getting # into a recursion error when the integration is resolved # (this also is slower). - if ( - ignored_loggers is not None - and record.name.strip() not in ignored_loggers - ): + name = record.name.strip() + + handle_events = ( + ignored_loggers is not None and name not in ignored_loggers + ) + handle_sentry_logs = ( + ignored_loggers_sentry_logs is not None + and name not in ignored_loggers_sentry_logs + ) + + if handle_events or handle_sentry_logs: integration = sentry_sdk.get_client().get_integration( LoggingIntegration ) if integration is not None: - integration._handle_record(record) + if handle_events: + integration._handle_record(record) + if handle_sentry_logs: + integration._handle_sentry_logs_record(record) logging.Logger.callHandlers = sentry_patched_callhandlers # type: ignore @@ -170,13 +226,6 @@ class _BaseHandler(logging.Handler): ) ) - def _can_record(self, record: "LogRecord") -> bool: - """Prevents ignored loggers from recording""" - for logger in _IGNORED_LOGGERS: - if fnmatch(record.name.strip(), logger): - return False - return True - def _logging_to_event_level(self, record: "LogRecord") -> str: return LOGGING_TO_EVENT_LEVEL.get( record.levelno, record.levelname.lower() if record.levelname else "" @@ -198,6 +247,13 @@ class EventHandler(_BaseHandler): Note that you do not have to use this class if the logging integration is enabled, which it is by default. """ + def _can_record(self, record: "LogRecord") -> bool: + """Prevents ignored loggers from recording""" + for logger in _IGNORED_LOGGERS: + if fnmatch(record.name.strip(), logger): + return False + return True + def emit(self, record: "LogRecord") -> "Any": with capture_internal_exceptions(): self.format(record) @@ -290,6 +346,13 @@ class BreadcrumbHandler(_BaseHandler): Note that you do not have to use this class if the logging integration is enabled, which it is by default. """ + def _can_record(self, record: "LogRecord") -> bool: + """Prevents ignored loggers from recording""" + for logger in _IGNORED_LOGGERS: + if fnmatch(record.name.strip(), logger): + return False + return True + def emit(self, record: "LogRecord") -> "Any": with capture_internal_exceptions(): self.format(record) @@ -321,6 +384,13 @@ class SentryLogsHandler(_BaseHandler): Note that you do not have to use this class if the logging integration is enabled, which it is by default. """ + def _can_record(self, record: "LogRecord") -> bool: + """Prevents ignored loggers from recording""" + for logger in _IGNORED_LOGGERS_SENTRY_LOGS: + if fnmatch(record.name.strip(), logger): + return False + return True + def emit(self, record: "LogRecord") -> "Any": with capture_internal_exceptions(): self.format(record) diff --git a/tests/integrations/logging/test_logging.py b/tests/integrations/logging/test_logging.py index 7b144f4b55..5e384bd3db 100644 --- a/tests/integrations/logging/test_logging.py +++ b/tests/integrations/logging/test_logging.py @@ -5,7 +5,13 @@ from sentry_sdk import get_client from sentry_sdk.consts import VERSION -from sentry_sdk.integrations.logging import LoggingIntegration, ignore_logger +from sentry_sdk.integrations.logging import ( + LoggingIntegration, + ignore_logger, + ignore_logger_for_sentry_logs, + unignore_logger, + unignore_logger_for_sentry_logs, +) from tests.test_logs import envelopes_to_logs other_logger = logging.getLogger("testfoo") @@ -222,34 +228,37 @@ def test_logging_captured_warnings(sentry_init, capture_events, recwarn): assert str(recwarn[0].message) == "third" -def test_ignore_logger(sentry_init, capture_events): +def test_ignore_logger(sentry_init, capture_events, request): sentry_init(integrations=[LoggingIntegration()], default_integrations=False) events = capture_events() ignore_logger("testfoo") + request.addfinalizer(lambda: unignore_logger("testfoo")) other_logger.error("hi") assert not events -def test_ignore_logger_whitespace_padding(sentry_init, capture_events): +def test_ignore_logger_whitespace_padding(sentry_init, capture_events, request): """Here we test insensitivity to whitespace padding of ignored loggers""" sentry_init(integrations=[LoggingIntegration()], default_integrations=False) events = capture_events() ignore_logger("testfoo") + request.addfinalizer(lambda: unignore_logger("testfoo")) padded_logger = logging.getLogger(" testfoo ") padded_logger.error("hi") assert not events -def test_ignore_logger_wildcard(sentry_init, capture_events): +def test_ignore_logger_wildcard(sentry_init, capture_events, request): sentry_init(integrations=[LoggingIntegration()], default_integrations=False) events = capture_events() ignore_logger("testfoo.*") + request.addfinalizer(lambda: unignore_logger("testfoo.*")) nested_logger = logging.getLogger("testfoo.submodule") @@ -262,6 +271,44 @@ def test_ignore_logger_wildcard(sentry_init, capture_events): assert event["logentry"]["formatted"] == "hi" +def test_ignore_logger_does_not_affect_sentry_logs( + sentry_init, capture_envelopes, request +): + """ignore_logger should suppress events/breadcrumbs but not Sentry Logs.""" + sentry_init(enable_logs=True) + envelopes = capture_envelopes() + + ignore_logger("testfoo") + request.addfinalizer(lambda: unignore_logger("testfoo")) + + other_logger.error("hi") + get_client().flush() + + logs = envelopes_to_logs(envelopes) + assert len(logs) == 1 + assert logs[0]["body"] == "hi" + + +def test_ignore_logger_for_sentry_logs(sentry_init, capture_envelopes, request): + """ignore_logger_for_sentry_logs should suppress Sentry Logs but not events.""" + sentry_init(enable_logs=True) + envelopes = capture_envelopes() + + ignore_logger_for_sentry_logs("testfoo") + request.addfinalizer(lambda: unignore_logger_for_sentry_logs("testfoo")) + + other_logger.error("hi") + get_client().flush() + + # Event should still be captured + event_envelopes = [e for e in envelopes if e.items[0].type == "event"] + assert len(event_envelopes) == 1 + + # But no Sentry Logs + logs = envelopes_to_logs(envelopes) + assert len(logs) == 0 + + def test_logging_dictionary_interpolation(sentry_init, capture_events): """Here we test an entire dictionary being interpolated into the log message.""" sentry_init(integrations=[LoggingIntegration()], default_integrations=False)