diff --git a/fastapi_startkit/src/fastapi_startkit/logging/drivers/LogSyslogDriver.py b/fastapi_startkit/src/fastapi_startkit/logging/drivers/LogSyslogDriver.py index 6b24e602..70f44a44 100644 --- a/fastapi_startkit/src/fastapi_startkit/logging/drivers/LogSyslogDriver.py +++ b/fastapi_startkit/src/fastapi_startkit/logging/drivers/LogSyslogDriver.py @@ -5,14 +5,27 @@ class LogSyslogDriver(BaseDriver): def __init__(self, *args, path: str | tuple[str, int], **kwargs): - self.log = logging.getLogger("root") - - handler = logging.handlers.SysLogHandler(address=path) - - formatter = logging.Formatter("{} - %(levelname)s - %(message)s".format(self.get_time().to_datetime_string())) - handler.setFormatter(formatter) - + # A dedicated, non-propagating logger per address keeps records away from the root + # logger (and its LoggingHandler bridge, which would feed them back into Logger). + self.log = logging.getLogger(f"fastapi_startkit.logging.syslog.{path}") + self.log.propagate = False + self.handler = self._syslog_handler(path) + self.handler.setFormatter( + logging.Formatter("{} - %(levelname)s - %(message)s".format(self.get_time().to_datetime_string())) + ) + + def _syslog_handler(self, address: str | tuple[str, int]) -> logging.Handler: + # Drivers are rebuilt per channel instance; reuse the handler so each address holds one socket. + if self.log.handlers: + return self.log.handlers[0] + + handler = logging.handlers.SysLogHandler(address=address) self.log.addHandler(handler) + return handler + + def close(self): + self.log.removeHandler(self.handler) + self.handler.close() def emergency(self, message): self.log.setLevel(logging.CRITICAL) diff --git a/fastapi_startkit/tests/logging/test_syslog_driver.py b/fastapi_startkit/tests/logging/test_syslog_driver.py new file mode 100644 index 00000000..dbf771e4 --- /dev/null +++ b/fastapi_startkit/tests/logging/test_syslog_driver.py @@ -0,0 +1,101 @@ +import logging +import unittest +import uuid +from unittest.mock import patch + +from fastapi_startkit.logging.drivers.LogSyslogDriver import LogSyslogDriver +from fastapi_startkit.logging.handler import LoggingHandler + +SYSLOG_HANDLER = "fastapi_startkit.logging.drivers.LogSyslogDriver.logging.handlers.SysLogHandler" + + +class RecordingHandler(logging.Handler): + def __init__(self, address=None): + super().__init__() + self.address = address + self.records: list[logging.LogRecord] = [] + self.closed = False + + def emit(self, record): + self.records.append(record) + + def close(self): + self.closed = True + super().close() + + +class SyslogDriverRootIsolationTest(unittest.TestCase): + def setUp(self): + self.address = f"/tmp/syslog-{uuid.uuid4().hex}" + self.drivers: list[LogSyslogDriver] = [] + patcher = patch(SYSLOG_HANDLER, side_effect=RecordingHandler) + self.handler_cls = patcher.start() + self.addCleanup(patcher.stop) + + def tearDown(self): + for driver in self.drivers: + driver.close() + + def _driver(self, address=None): + driver = LogSyslogDriver(path=address or self.address) + self.drivers.append(driver) + return driver + + def test_repeated_construction_leaves_root_handlers_and_level_untouched(self): + root = logging.getLogger() + handlers_before = list(root.handlers) + level_before = root.level + + for _ in range(25): + self._driver() + + self.assertEqual(root.handlers, handlers_before) + self.assertEqual(root.level, level_before) + + def test_repeated_construction_reuses_one_handler(self): + first = self._driver() + for _ in range(24): + driver = self._driver() + self.assertIs(driver.handler, first.handler) + + self.handler_cls.assert_called_once_with(address=self.address) + self.assertEqual(first.log.handlers, [first.handler]) + self.assertFalse(first.log.propagate) + + def test_distinct_addresses_get_distinct_handlers(self): + first = self._driver() + second = self._driver(("localhost", 514)) + + self.assertIsNot(first.handler, second.handler) + self.handler_cls.assert_called_with(address=("localhost", 514)) + + def test_records_reach_syslog_without_looping_through_the_bridge(self): + root = logging.getLogger() + bridge = LoggingHandler() + root.addHandler(bridge) + try: + driver = self._driver() + with patch.object(LoggingHandler, "emit") as bridge_emit: + for level in LogSyslogDriver.levels: + getattr(driver, level)(f"{level}-message") + + bridge_emit.assert_not_called() + handler = driver.handler + assert isinstance(handler, RecordingHandler) + self.assertEqual( + [record.getMessage() for record in handler.records], + [f"{level}-message" for level in LogSyslogDriver.levels], + ) + finally: + root.removeHandler(bridge) + + def test_close_detaches_and_closes_the_handler(self): + driver = self._driver() + handler = driver.handler + assert isinstance(handler, RecordingHandler) + + driver.close() + self.drivers.remove(driver) + + self.assertEqual(driver.log.handlers, []) + self.assertTrue(handler.closed)