From 73107d704be7d0de6a326a0a3aa4a3d2e1839f4f Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Wed, 13 May 2026 23:10:48 +0530 Subject: [PATCH] feat(server): allow hosts to own logging setup --- server/src/agent_control_server/config.py | 5 ++++ .../src/agent_control_server/logging_utils.py | 5 ++++ server/src/agent_control_server/main.py | 11 +++++-- server/tests/test_logging_utils.py | 21 ++++++++++++++ server/tests/test_main_lifespan.py | 29 +++++++++++++++++++ 5 files changed, 68 insertions(+), 3 deletions(-) diff --git a/server/src/agent_control_server/config.py b/server/src/agent_control_server/config.py index 9a1e754d..4ff4cd9c 100644 --- a/server/src/agent_control_server/config.py +++ b/server/src/agent_control_server/config.py @@ -244,6 +244,11 @@ class LoggingSettings(BaseSettings): model_config = SettingsConfigDict(**_COMMON_SETTINGS_CONFIG, env_prefix="AGENT_CONTROL_LOG_") + configure: bool = _env_alias_field( + True, + "AGENT_CONTROL_CONFIGURE_LOGGING", + "AGENT_CONTROL_LOG_CONFIGURE", + ) level: str | None = None json_logs: bool = _env_alias_field(False, "AGENT_CONTROL_LOG_JSON") diff --git a/server/src/agent_control_server/logging_utils.py b/server/src/agent_control_server/logging_utils.py index 7c97744b..0302580d 100644 --- a/server/src/agent_control_server/logging_utils.py +++ b/server/src/agent_control_server/logging_utils.py @@ -66,6 +66,11 @@ def _parse_json(json_flag: bool | None) -> bool: return LoggingSettings().json_logs +def should_configure_logging() -> bool: + """Return whether Agent Control should install its own logging handlers.""" + return LoggingSettings().configure + + def configure_logging( *, level: str | int | None = None, diff --git a/server/src/agent_control_server/main.py b/server/src/agent_control_server/main.py index 76416e04..eee9fa9c 100644 --- a/server/src/agent_control_server/main.py +++ b/server/src/agent_control_server/main.py @@ -37,7 +37,7 @@ http_exception_handler, validation_exception_handler, ) -from .logging_utils import configure_logging, get_uvicorn_log_level_name +from .logging_utils import configure_logging, get_uvicorn_log_level_name, should_configure_logging from .observability.ingest import DirectEventIngestor from .observability.sinks import ( EventStoreControlEventSink, @@ -88,6 +88,11 @@ def add_prometheus_metrics(app: FastAPI, metrics_prefix: str) -> None: app.add_route(METRICS_PATH, handle_metrics) +def _configure_process_logging() -> None: + if should_configure_logging(): + configure_logging(default_level=_default_log_level()) + + async def _shutdown_observability_sink(sink: object) -> None: """Flush and close a custom async sink when it exposes lifecycle hooks.""" flush = getattr(sink, "flush", None) @@ -120,7 +125,7 @@ async def _shutdown_observability_sink(sink: object) -> None: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Lifespan context manager for FastAPI app startup and shutdown.""" # Startup: Configure logging - configure_logging(default_level=_default_log_level()) + _configure_process_logging() # Install the request-auth provider selected by environment variables. from .auth_framework.config import configure_auth_from_env @@ -231,7 +236,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: ) # Configure logging -configure_logging(default_level=_default_log_level()) +_configure_process_logging() @app.middleware("http") diff --git a/server/tests/test_logging_utils.py b/server/tests/test_logging_utils.py index 750cd23d..ebe11c39 100644 --- a/server/tests/test_logging_utils.py +++ b/server/tests/test_logging_utils.py @@ -8,6 +8,7 @@ configure_logging, get_log_level_name, get_uvicorn_log_level_name, + should_configure_logging, ) @@ -77,6 +78,26 @@ def test_parse_json_treats_blank_env_value_as_false(monkeypatch) -> None: assert parsed is False +def test_should_configure_logging_defaults_to_true() -> None: + assert should_configure_logging() is True + + +def test_should_configure_logging_reads_host_override(monkeypatch) -> None: + # Given: the embedding host opts out of Agent Control's logging setup + monkeypatch.setenv("AGENT_CONTROL_CONFIGURE_LOGGING", "false") + + # When/then: Agent Control should leave logging handlers to the host + assert should_configure_logging() is False + + +def test_should_configure_logging_supports_log_prefixed_alias(monkeypatch) -> None: + # Given: the logging-prefixed alias is used + monkeypatch.setenv("AGENT_CONTROL_LOG_CONFIGURE", "false") + + # When/then: the alias disables Agent Control logging setup + assert should_configure_logging() is False + + def test_get_log_level_name_falls_back_to_default_for_invalid_env(monkeypatch) -> None: # Given: AGENT_CONTROL_LOG_LEVEL is present but invalid monkeypatch.setenv("AGENT_CONTROL_LOG_LEVEL", "not-a-level") diff --git a/server/tests/test_main_lifespan.py b/server/tests/test_main_lifespan.py index 5a557743..512291de 100644 --- a/server/tests/test_main_lifespan.py +++ b/server/tests/test_main_lifespan.py @@ -156,6 +156,35 @@ def test_lifespan_skips_observability_when_disabled(monkeypatch) -> None: assert not hasattr(app.state, "event_ingestor") +def test_configure_process_logging_respects_host_override(monkeypatch) -> None: + calls = {"count": 0} + + def fake_configure_logging(*, default_level: str) -> None: + calls["count"] += 1 + + monkeypatch.setattr(main_module, "should_configure_logging", lambda: False) + monkeypatch.setattr(main_module, "configure_logging", fake_configure_logging) + + main_module._configure_process_logging() + + assert calls["count"] == 0 + + +def test_configure_process_logging_uses_default_level(monkeypatch) -> None: + calls = {} + + def fake_configure_logging(*, default_level: str) -> None: + calls["default_level"] = default_level + + monkeypatch.setattr(main_module, "should_configure_logging", lambda: True) + monkeypatch.setattr(main_module, "configure_logging", fake_configure_logging) + monkeypatch.setattr(settings, "debug", True) + + main_module._configure_process_logging() + + assert calls == {"default_level": "DEBUG"} + + def test_custom_openapi_replaces_jsonvalue(monkeypatch) -> None: # Given: a custom openapi generator that includes JSONValue def fake_get_openapi(*, title, version, description, routes):