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 <noreply@anthropic.com>
This commit is contained in:
He Wang 2026-04-30 22:27:14 +08:00 committed by GitHub
parent c0da278269
commit eba3b9e18d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 135 additions and 26 deletions

View File

@ -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}"

View File

@ -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

View File

@ -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")

View File

@ -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