From eba3b9e18d797dfc42b9cc8610781fa941755731 Mon Sep 17 00:00:00 2001 From: He Wang Date: Thu, 30 Apr 2026 22:27:14 +0800 Subject: [PATCH] fix(config): unify log_level from config.yaml across Gateway and debug entry points (#2601) Centralize log level parsing in `logging_level_from_config()` and application in `apply_logging_level()` within `deerflow.config.app_config`. - Gateway lifespan applies configured log level on startup - `debug.py` uses shared helpers instead of local duplicates - `apply_logging_level()` targets only `deerflow`/`app` logger hierarchies so third-party library verbosity is not affected; root handler levels are only lowered (never raised) to allow configured loggers through without suppressing third-party output; root logger level is not modified - Config field description updated to clarify scope - Tests save/restore global logging state to avoid test pollution Co-authored-by: Claude Opus 4.7 --- backend/app/gateway/app.py | 4 +- backend/debug.py | 40 ++++---- .../harness/deerflow/config/app_config.py | 26 +++++- .../tests/test_logging_level_from_config.py | 91 +++++++++++++++++++ 4 files changed, 135 insertions(+), 26 deletions(-) create mode 100644 backend/tests/test_logging_level_from_config.py diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 5842557d2..2a506df2b 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -29,11 +29,12 @@ from app.gateway.routers import ( uploads, ) from deerflow.config import app_config as deerflow_app_config +from deerflow.config.app_config import apply_logging_level AppConfig = deerflow_app_config.AppConfig get_app_config = deerflow_app_config.get_app_config -# Configure logging +# Default logging; lifespan overrides from config.yaml log_level. logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", @@ -164,6 +165,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # Load config and check necessary environment variables at startup try: app.state.config = get_app_config() + apply_logging_level(app.state.config.log_level) logger.info("Configuration loaded successfully") except Exception as e: error_msg = f"Failed to load configuration during gateway startup: {e}" diff --git a/backend/debug.py b/backend/debug.py index 3e0694cef..341c676ed 100644 --- a/backend/debug.py +++ b/backend/debug.py @@ -34,50 +34,42 @@ _LOG_FMT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" _LOG_DATEFMT = "%Y-%m-%d %H:%M:%S" -def _logging_level_from_config(name: str) -> int: - """Map ``config.yaml`` ``log_level`` string to a ``logging`` level constant.""" - mapping = logging.getLevelNamesMapping() - return mapping.get((name or "info").strip().upper(), logging.INFO) +def _setup_logging(log_level: int = logging.INFO) -> None: + """Route logs to ``debug.log`` using *log_level* for the initial root/file setup. + This configures the root logger and the ``debug.log`` file handler so logs do + not print on the interactive console. It is idempotent: any pre-existing + handlers on the root logger (e.g. installed by ``logging.basicConfig`` in + transitively imported modules) are removed so the debug session output only + lands in ``debug.log``. -def _setup_logging(log_level: str) -> None: - """Send application logs to ``debug.log`` at *log_level*; do not print them on the console. - - Idempotent: any pre-existing handlers on the root logger (e.g. installed by - ``logging.basicConfig`` in transitively imported modules) are removed so the - debug session output only lands in ``debug.log``. + Note: later config-driven logging adjustments may change named logger + verbosity without raising the root logger or file-handler thresholds set + here, so the eventual contents of ``debug.log`` may not be filtered solely by + this function's ``log_level`` argument. """ - level = _logging_level_from_config(log_level) root = logging.root for h in list(root.handlers): root.removeHandler(h) h.close() - root.setLevel(level) + root.setLevel(log_level) file_handler = logging.FileHandler("debug.log", mode="a", encoding="utf-8") - file_handler.setLevel(level) + file_handler.setLevel(log_level) file_handler.setFormatter(logging.Formatter(_LOG_FMT, datefmt=_LOG_DATEFMT)) root.addHandler(file_handler) -def _update_logging_level(log_level: str) -> None: - """Update the root logger and existing handlers to *log_level*.""" - level = _logging_level_from_config(log_level) - root = logging.root - root.setLevel(level) - for handler in root.handlers: - handler.setLevel(level) - - async def main(): # Install file logging first so warnings emitted while loading config do not # leak onto the interactive terminal via Python's lastResort handler. - _setup_logging("info") + _setup_logging() from deerflow.config import get_app_config + from deerflow.config.app_config import apply_logging_level app_config = get_app_config() - _update_logging_level(app_config.log_level) + apply_logging_level(app_config.log_level) # Delay the rest of the deerflow imports until *after* logging is installed # so that any import-time side effects (e.g. deerflow.agents starts a diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index df6b82708..b31d396a5 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -53,10 +53,34 @@ def _default_config_candidates() -> tuple[Path, ...]: return (backend_dir / "config.yaml", repo_root / "config.yaml") +def logging_level_from_config(name: str | None) -> int: + """Map ``config.yaml`` ``log_level`` string to a :mod:`logging` level constant.""" + mapping = logging.getLevelNamesMapping() + return mapping.get((name or "info").strip().upper(), logging.INFO) + + +def apply_logging_level(name: str | None) -> None: + """Resolve *name* to a logging level and apply it to the ``deerflow``/``app`` logger hierarchies. + + Only the ``deerflow`` and ``app`` logger levels are changed so that + third-party library verbosity (e.g. uvicorn, sqlalchemy) is not + affected. Root handler levels are lowered (never raised) so that + messages from the configured loggers can propagate through without + being filtered, while preserving handler thresholds that may be + intentionally restrictive for third-party log output. + """ + level = logging_level_from_config(name) + for logger_name in ("deerflow", "app"): + logging.getLogger(logger_name).setLevel(level) + for handler in logging.root.handlers: + if level < handler.level: + handler.setLevel(level) + + class AppConfig(BaseModel): """Config for the DeerFlow application""" - log_level: str = Field(default="info", description="Logging level for deerflow modules (debug/info/warning/error)") + log_level: str = Field(default="info", description="Logging level for deerflow and app modules (debug/info/warning/error); third-party libraries are not affected") token_usage: TokenUsageConfig = Field(default_factory=TokenUsageConfig, description="Token usage tracking configuration") models: list[ModelConfig] = Field(default_factory=list, description="Available models") sandbox: SandboxConfig = Field(description="Sandbox configuration") diff --git a/backend/tests/test_logging_level_from_config.py b/backend/tests/test_logging_level_from_config.py new file mode 100644 index 000000000..c78c0c427 --- /dev/null +++ b/backend/tests/test_logging_level_from_config.py @@ -0,0 +1,91 @@ +"""Tests for ``logging_level_from_config`` and ``apply_logging_level`` (``config.yaml`` ``log_level`` mapping).""" + +import logging + +import pytest + +from deerflow.config.app_config import apply_logging_level, logging_level_from_config + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("debug", logging.DEBUG), + ("INFO", logging.INFO), + ("warning", logging.WARNING), + ("error", logging.ERROR), + ("critical", logging.CRITICAL), + (" Debug ", logging.DEBUG), + (None, logging.INFO), + ("", logging.INFO), + ], +) +def test_logging_level_from_config_known_and_defaults(name: str | None, expected: int) -> None: + assert logging_level_from_config(name) == expected + + +def test_logging_level_from_config_unknown_falls_back_to_info() -> None: + assert logging_level_from_config("not-a-real-level-name") == logging.INFO + + +class TestApplyLoggingLevel: + """Tests for ``apply_logging_level`` — verifies deerflow/app logger and handler levels.""" + + def setup_method(self) -> None: + root = logging.root + self._original_root_level = root.level + self._original_root_handlers = list(root.handlers) + self._original_handler_levels = {handler: handler.level for handler in self._original_root_handlers} + self._original_deerflow_level = logging.getLogger("deerflow").level + self._original_app_level = logging.getLogger("app").level + + def teardown_method(self) -> None: + root = logging.root + current_handlers = list(root.handlers) + + for handler in current_handlers: + if handler not in self._original_root_handlers: + root.removeHandler(handler) + handler.close() + + for handler in list(root.handlers): + root.removeHandler(handler) + + for handler in self._original_root_handlers: + handler.setLevel(self._original_handler_levels[handler]) + root.addHandler(handler) + + root.setLevel(self._original_root_level) + logging.getLogger("deerflow").setLevel(self._original_deerflow_level) + logging.getLogger("app").setLevel(self._original_app_level) + + def test_sets_deerflow_app_logger_levels(self) -> None: + apply_logging_level("debug") + assert logging.getLogger("deerflow").level == logging.DEBUG + assert logging.getLogger("app").level == logging.DEBUG + + def test_lowers_handler_level(self) -> None: + handler = logging.StreamHandler() + handler.setLevel(logging.WARNING) + logging.root.addHandler(handler) + apply_logging_level("debug") + assert handler.level == logging.DEBUG + + def test_does_not_raise_handler_level(self) -> None: + handler = logging.StreamHandler() + handler.setLevel(logging.WARNING) + logging.root.addHandler(handler) + apply_logging_level("error") + assert handler.level == logging.WARNING + + def test_does_not_modify_root_logger(self) -> None: + logging.root.setLevel(logging.WARNING) + apply_logging_level("debug") + assert logging.root.level == logging.WARNING + apply_logging_level("error") + assert logging.root.level == logging.WARNING + + def test_defaults_to_info(self) -> None: + apply_logging_level(None) + assert logging.getLogger("deerflow").level == logging.INFO + assert logging.getLogger("app").level == logging.INFO