From d28d51daaad2192aa6b00bf616896f5a9829fc6e Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Sat, 25 Jul 2026 12:37:39 +0000 Subject: [PATCH 1/3] feat: suppress uvicorn access logs for /health and /ready probes Install a logging filter on the uvicorn.access logger from LoggingService.initialize() that drops access-log records for GET /health and GET /ready. Kubernetes and other orchestrators poll these endpoints on short intervals, generating high-volume, low-value log noise. All other endpoints and loggers are unaffected, and the filter never raises: any failure while formatting the message falls through to allow. Closes #5880 Signed-off-by: Jonathan Springer --- docs/docs/manage/logging.md | 3 + mcpgateway/services/logging_service.py | 81 +++++++++++++++ .../services/test_logging_service.py | 98 +++++++++++++++++++ 3 files changed, 182 insertions(+) diff --git a/docs/docs/manage/logging.md b/docs/docs/manage/logging.md index 8d74ffa38d..736410dfdd 100644 --- a/docs/docs/manage/logging.md +++ b/docs/docs/manage/logging.md @@ -31,6 +31,9 @@ ContextForge uses dual-format logging: } ``` +!!! note "Health Check Log Suppression" + Access logs for `/health` and `/ready` endpoints are automatically suppressed to reduce noise from Kubernetes health probes and readiness checks. This filtering happens at the logging layer and cannot be disabled. Other endpoints are logged normally. + ### Text Format (Console Output) ``` 2025-01-09 17:30:15,123 - mcpgateway.gateway_service - INFO - Registered gateway: peer-gateway-1 diff --git a/mcpgateway/services/logging_service.py b/mcpgateway/services/logging_service.py index dbab6ad8df..c7129eca00 100644 --- a/mcpgateway/services/logging_service.py +++ b/mcpgateway/services/logging_service.py @@ -374,6 +374,9 @@ async def initialize(self) -> None: # Redact sensitive query parameters from httpx/httpcore log messages self._install_httpx_url_sanitize_filter() + # Suppress high-volume health check and readiness probe logs + self._install_uvicorn_health_check_filter() + async def shutdown(self) -> None: """Shutdown logging service. @@ -481,6 +484,84 @@ def filter(self, record: logging.LogRecord) -> bool: # noqa: D401 target_logger = logging.getLogger("mcp.server.streamable_http") target_logger.addFilter(_SuppressClosedResourceErrorFilter()) + @staticmethod + def _install_uvicorn_health_check_filter() -> None: + """Install a filter to suppress health check and readiness probe logs from uvicorn.access. + + Kubernetes and other orchestrators frequently poll /health and /ready endpoints, + generating high-volume, low-value access logs. This filter suppresses those logs + to reduce noise while preserving logs for other endpoints. + + The filter checks the formatted message string for /health and /ready patterns. + + Examples: + >>> import asyncio, logging + >>> service = LoggingService() + >>> asyncio.run(service.initialize()) + >>> filt = [f for f in logging.getLogger('uvicorn.access').filters + ... if f.__class__.__name__ == '_UvicornHealthCheckFilter'][0] + >>> rec = logging.LogRecord( + ... name='uvicorn.access', level=logging.INFO, pathname='', lineno=0, + ... msg='10.254.16.2:53664 - "GET /ready HTTP/1.1" 200', + ... args=(), + ... exc_info=None + ... ) + >>> filt.filter(rec) + False + >>> rec2 = logging.LogRecord( + ... name='uvicorn.access', level=logging.INFO, pathname='', lineno=0, + ... msg='10.254.16.2:53664 - "GET /health HTTP/1.1" 200', + ... args=(), + ... exc_info=None + ... ) + >>> filt.filter(rec2) + False + >>> rec3 = logging.LogRecord( + ... name='uvicorn.access', level=logging.INFO, pathname='', lineno=0, + ... msg='10.254.20.2:34444 - "GET /api/tools HTTP/1.1" 200', + ... args=(), + ... exc_info=None + ... ) + >>> filt.filter(rec3) + True + >>> asyncio.run(service.shutdown()) + + """ + + class _UvicornHealthCheckFilter(logging.Filter): + """Filter to suppress health check and readiness probe logs from uvicorn.access. + + Checks the formatted log message for /health and /ready endpoint patterns. + """ + + def filter(self, record: logging.LogRecord) -> bool: # noqa: D401 + """Filter log records to suppress health/ready endpoint access logs. + + Args: + record: The log record to evaluate. + + Returns: + True to allow the record through, False to suppress it + + """ + # Only apply to uvicorn.access logger + if not record.name.startswith("uvicorn.access"): + return True + + try: + # Check the formatted message for health/ready endpoints + if hasattr(record, "getMessage"): + msg = record.getMessage() + # Match patterns like: 'GET /health HTTP' or '"GET /ready HTTP"' + if "GET /health" in msg or "GET /ready" in msg: + return False + except Exception: + pass # nosec B110 - Never break logging due to filter failure + return True + + uvicorn_access_logger = logging.getLogger("uvicorn.access") + uvicorn_access_logger.addFilter(_UvicornHealthCheckFilter()) + @staticmethod def _install_httpx_url_sanitize_filter() -> None: """Install a filter to redact sensitive query parameters from httpx/httpcore log messages. diff --git a/tests/unit/mcpgateway/services/test_logging_service.py b/tests/unit/mcpgateway/services/test_logging_service.py index 09fac3bf3a..655abac8e5 100644 --- a/tests/unit/mcpgateway/services/test_logging_service.py +++ b/tests/unit/mcpgateway/services/test_logging_service.py @@ -227,6 +227,104 @@ async def test_subscribe_cleanup_removes_queue_on_cancel(): assert len(service._subscribers) == 0 +# --------------------------------------------------------------------------- +# uvicorn health check suppression filter +# --------------------------------------------------------------------------- + + +class TestUvicornHealthCheckFilter: + """Tests for the uvicorn.access health check suppression filter.""" + + @staticmethod + def _get_filter(): + """Install the filter and return it.""" + LoggingService._install_uvicorn_health_check_filter() + uvicorn_logger = logging.getLogger("uvicorn.access") + return [f for f in uvicorn_logger.filters if f.__class__.__name__ == "_UvicornHealthCheckFilter"][-1] + + def test_suppresses_ready_endpoint(self): + """Test that /ready endpoint logs are suppressed.""" + filt = self._get_filter() + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='10.254.20.2:34442 - "GET /ready HTTP/1.1" 200', + args=(), + exc_info=None, + ) + result = filt.filter(record) + assert result is False + + def test_suppresses_health_endpoint(self): + """Test that /health endpoint logs are suppressed.""" + filt = self._get_filter() + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='10.254.20.2:34444 - "GET /health HTTP/1.1" 200', + args=(), + exc_info=None, + ) + result = filt.filter(record) + assert result is False + + def test_allows_other_endpoints(self): + """Test that other endpoints are not suppressed.""" + filt = self._get_filter() + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='10.254.20.2:34446 - "GET /api/tools HTTP/1.1" 200', + args=(), + exc_info=None, + ) + result = filt.filter(record) + assert result is True + + def test_allows_non_uvicorn_access_loggers(self): + """Test that non-uvicorn.access loggers are not affected.""" + filt = self._get_filter() + record = logging.LogRecord( + name="uvicorn.error", + level=logging.INFO, + pathname="", + lineno=0, + msg='10.254.20.2:34442 - "GET /ready HTTP/1.1" 200', + args=(), + exc_info=None, + ) + result = filt.filter(record) + assert result is True + + def test_handles_missing_args_gracefully(self): + """Test that filter handles records without health/ready patterns.""" + filt = self._get_filter() + record = logging.LogRecord(name="uvicorn.access", level=logging.INFO, pathname="", lineno=0, msg="Some message without health or ready", args=(), exc_info=None) + result = filt.filter(record) + assert result is True + + def test_handles_malformed_messages_gracefully(self): + """Test that filter handles malformed messages gracefully.""" + filt = self._get_filter() + record = logging.LogRecord(name="uvicorn.access", level=logging.INFO, pathname="", lineno=0, msg="GET /api/users", args=(), exc_info=None) + result = filt.filter(record) + assert result is True + + def test_filter_survives_exception(self): + """Test that filter survives exceptions during processing.""" + filt = self._get_filter() + record = logging.LogRecord(name="uvicorn.access", level=logging.INFO, pathname="", lineno=0, msg="test", args=None, exc_info=None) + # Should not raise even with None args + result = filt.filter(record) + assert result is True + + # --------------------------------------------------------------------------- # httpx URL sanitize filter # --------------------------------------------------------------------------- From 8f7bad0e4230e334c369b08a21602e2b1499b04d Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Sat, 25 Jul 2026 16:44:11 +0100 Subject: [PATCH 2/3] chore: re-trigger CI after Actions incident Signed-off-by: Jonathan Springer From 309020ac9e0ec4c6fc8329983382f0c1fa30a200 Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Sat, 25 Jul 2026 17:02:10 +0100 Subject: [PATCH 3/3] test: cover exception path in UvicornHealthCheckFilter for diff-cover compliance The diff-cover tool reported 88% coverage (below 93% threshold) on the health-check log suppression changes, specifically the except Exception block. This adds a test that patches getMessage() to raise an exception to ensure the filter handles it gracefully. Signed-off-by: Jonathan Springer --- .../services/test_logging_service.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/mcpgateway/services/test_logging_service.py b/tests/unit/mcpgateway/services/test_logging_service.py index 655abac8e5..f40f995c0f 100644 --- a/tests/unit/mcpgateway/services/test_logging_service.py +++ b/tests/unit/mcpgateway/services/test_logging_service.py @@ -302,6 +302,24 @@ def test_allows_non_uvicorn_access_loggers(self): result = filt.filter(record) assert result is True + def test_handles_getmessage_exception(self): + """Test that filter handles getMessage() exceptions gracefully.""" + filt = self._get_filter() + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg="test", + args=(), + exc_info=None, + ) + # Simulate getMessage raising an exception + with patch.object(record, "getMessage", side_effect=RuntimeError("mocked error")): + result = filt.filter(record) + # Should return True (allow the log) rather than crashing + assert result is True + def test_handles_missing_args_gracefully(self): """Test that filter handles records without health/ready patterns.""" filt = self._get_filter()