diff --git a/app.py b/app.py index da511384d..a1856da65 100644 --- a/app.py +++ b/app.py @@ -88,6 +88,7 @@ from core.log_safety import ( ScopedDiagnosticsFilter, application_log_settings, configure_uvicorn_log_levels, + uvicorn_log_config, ) _root_logger = logging.getLogger() @@ -1298,4 +1299,10 @@ if __name__ == "__main__": bind_host = os.getenv("APP_BIND", "127.0.0.1") bind_port = int(os.getenv("APP_PORT", "7000")) - uvicorn.run(app, host=bind_host, port=bind_port, log_level=_application_log_level) + uvicorn.run( + app, + host=bind_host, + port=bind_port, + log_level=_application_log_level, + log_config=uvicorn_log_config(_application_log_level), + ) diff --git a/core/log_safety.py b/core/log_safety.py index b0bab625d..4b86b9091 100644 --- a/core/log_safety.py +++ b/core/log_safety.py @@ -9,13 +9,18 @@ also doubles as a sanitizer barrier for CodeQL's clear-text-logging query. from __future__ import annotations +from copy import deepcopy import logging - from urllib.parse import urlparse, urlunparse CAPABILITY_DIAGNOSTICS_LOGGER = "src.model_capability_readers" -UVICORN_LOGGER_NAMES = ("uvicorn", "uvicorn.error", "uvicorn.access") +UVICORN_LOGGER_NAMES = ( + "uvicorn", + "uvicorn.error", + "uvicorn.access", + "uvicorn.asgi", +) _LOG_LEVELS = { "DEBUG": logging.DEBUG, @@ -53,6 +58,18 @@ def configure_uvicorn_log_levels(application_level: int) -> None: logging.getLogger(logger_name).setLevel(application_level) +def uvicorn_log_config(application_level: int) -> dict: + """Return a Uvicorn config that preserves the mapped level on direct runs.""" + + from uvicorn.config import LOGGING_CONFIG + + config = deepcopy(LOGGING_CONFIG) + loggers = config.setdefault("loggers", {}) + for logger_name in UVICORN_LOGGER_NAMES: + loggers.setdefault(logger_name, {})["level"] = application_level + return config + + class ScopedDiagnosticsFilter(logging.Filter): """Allow normal application records plus one explicitly scoped DEBUG log.""" diff --git a/launcher.py b/launcher.py index ba158444f..2bbcc2f92 100644 --- a/launcher.py +++ b/launcher.py @@ -128,9 +128,13 @@ if __name__ == "__main__": import uvicorn # Import the FastAPI app from app.py from app import app + from core.log_safety import application_log_settings, uvicorn_log_config bind_host = os.getenv("APP_BIND", "127.0.0.1") bind_port = int(os.getenv("APP_PORT", "7000")) + application_log_level, _ = application_log_settings( + os.getenv("LOG_LEVEL", "INFO") + ) url = f"http://{bind_host}:{bind_port}" if getattr(sys, 'frozen', False): @@ -139,4 +143,10 @@ if __name__ == "__main__": # Start system tray manager thread threading.Thread(target=setup_system_tray, args=(url,), daemon=True).start() - uvicorn.run(app, host=bind_host, port=bind_port, log_level="info") + uvicorn.run( + app, + host=bind_host, + port=bind_port, + log_level=application_log_level, + log_config=uvicorn_log_config(application_log_level), + ) diff --git a/tests/test_log_safety.py b/tests/test_log_safety.py index 47f5611ab..584afe75a 100644 --- a/tests/test_log_safety.py +++ b/tests/test_log_safety.py @@ -1,13 +1,18 @@ import logging +import subprocess +import sys +from textwrap import dedent import pytest from core.log_safety import ( CAPABILITY_DIAGNOSTICS_LOGGER, ScopedDiagnosticsFilter, + UVICORN_LOGGER_NAMES, application_log_settings, configure_uvicorn_log_levels, redact_url, + uvicorn_log_config, ) @@ -67,7 +72,7 @@ def test_application_log_settings_scope_debug_and_fail_closed( def test_configure_uvicorn_log_levels_clamps_non_propagating_loggers(): - logger_names = ("uvicorn", "uvicorn.error", "uvicorn.access") + logger_names = UVICORN_LOGGER_NAMES previous_levels = { name: logging.getLogger(name).level for name in logger_names } @@ -85,6 +90,61 @@ def test_configure_uvicorn_log_levels_clamps_non_propagating_loggers(): logging.getLogger(name).setLevel(level) +def test_uvicorn_log_config_sets_all_named_loggers_without_mutating_default(): + from uvicorn.config import LOGGING_CONFIG + + configured = uvicorn_log_config(logging.ERROR) + + assert all( + configured["loggers"][name]["level"] == logging.ERROR + for name in UVICORN_LOGGER_NAMES + ) + assert LOGGING_CONFIG["loggers"]["uvicorn"]["level"] == "INFO" + + +def test_uvicorn_levels_hold_across_external_and_direct_config_order(): + result = subprocess.run( + [ + sys.executable, + "-c", + dedent( + """ + import logging + import uvicorn + + from core.log_safety import ( + UVICORN_LOGGER_NAMES, + configure_uvicorn_log_levels, + uvicorn_log_config, + ) + + uvicorn.Config("app:app", log_level="debug") + configure_uvicorn_log_levels(logging.INFO) + assert all( + logging.getLogger(name).level == logging.INFO + for name in UVICORN_LOGGER_NAMES + ) + + uvicorn.Config( + "app:app", + log_level=logging.ERROR, + log_config=uvicorn_log_config(logging.ERROR), + ) + assert all( + logging.getLogger(name).level == logging.ERROR + for name in UVICORN_LOGGER_NAMES + ) + """ + ), + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + def _record(name: str, level: int) -> logging.LogRecord: return logging.LogRecord(name, level, __file__, 1, "message", (), None) diff --git a/tests/test_model_capability_diagnostics.py b/tests/test_model_capability_diagnostics.py index e6b2c7487..efa086775 100644 --- a/tests/test_model_capability_diagnostics.py +++ b/tests/test_model_capability_diagnostics.py @@ -57,7 +57,9 @@ def test_fallback_debug_log_is_explicit_and_has_no_capability_claims(caplog): def test_web_app_logging_uses_existing_log_level_environment_toggle(): - source = (Path(__file__).resolve().parents[1] / "app.py").read_text(encoding="utf-8") + root = Path(__file__).resolve().parents[1] + source = (root / "app.py").read_text(encoding="utf-8") + launcher_source = (root / "launcher.py").read_text(encoding="utf-8") assert 'os.getenv("LOG_LEVEL", "INFO")' in source assert "application_log_settings(_log_level_name)" in source @@ -66,3 +68,7 @@ def test_web_app_logging_uses_existing_log_level_environment_toggle(): assert "_console_h.addFilter(_diagnostics_filter)" in source assert "_file_h.addFilter(_diagnostics_filter)" in source assert "log_level=_application_log_level" in source + assert "log_config=uvicorn_log_config(_application_log_level)" in source + assert "application_log_settings(" in launcher_source + assert "log_level=application_log_level" in launcher_source + assert "log_config=uvicorn_log_config(application_log_level)" in launcher_source