diff --git a/litellm/_logging.py b/litellm/_logging.py index 5f3c483869d..6a579d91c53 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -77,6 +77,50 @@ class SecretRedactionFilter(logging.Filter): _secret_filter = SecretRedactionFilter() +def _parse_disabled_access_log_paths() -> frozenset: + """ + Parse LITELLM_DISABLE_ACCESS_LOG_PATHS env var into a frozenset of paths. + + Comma-separated list of exact request paths whose access-log lines should + be dropped (e.g. health checks, root probes, metrics scrapes that flood + logs). Empty/unset disables filtering. + """ + raw = os.getenv("LITELLM_DISABLE_ACCESS_LOG_PATHS", "") + return frozenset(p.strip() for p in raw.split(",") if p.strip()) + + +_DISABLED_ACCESS_LOG_PATHS = _parse_disabled_access_log_paths() + + +class HealthCheckAccessLogFilter(logging.Filter): + """ + Drops uvicorn.access records for request paths in + LITELLM_DISABLE_ACCESS_LOG_PATHS. Uvicorn passes args as + (client_addr, method, full_path, http_version, status). Path is matched + against the portion before any query string. + """ + + def filter(self, record: logging.LogRecord) -> bool: + if not _DISABLED_ACCESS_LOG_PATHS: + return True + try: + args = record.args + if not isinstance(args, tuple) or len(args) < 3: + return True + full_path = args[2] + if not isinstance(full_path, str): + return True + path = full_path.split("?", 1)[0] + if path in _DISABLED_ACCESS_LOG_PATHS: + return False + except Exception: + return True + return True + + +_healthcheck_filter = HealthCheckAccessLogFilter() + + json_logs = bool(os.getenv("JSON_LOGS", False)) # Create a handler for the logger (you may need to adapt this based on your needs) log_level = os.getenv("LITELLM_LOG", "DEBUG") @@ -272,6 +316,11 @@ def _suppress_loggers(): apscheduler_scheduler_logger = logging.getLogger("apscheduler.scheduler") apscheduler_scheduler_logger.setLevel(logging.WARNING) + # Drop access-log lines for paths in LITELLM_DISABLE_ACCESS_LOG_PATHS + # (covers the non-JSON path; JSON path wires the filter via log_config). + if _DISABLED_ACCESS_LOG_PATHS: + logging.getLogger("uvicorn.access").addFilter(_healthcheck_filter) + # Call the suppression function _suppress_loggers() @@ -330,7 +379,7 @@ def _get_uvicorn_json_log_config(): # Use the module-level log_level variable for consistency uvicorn_log_level = log_level.upper() - log_config = { + log_config: Dict[str, Any] = { "version": 1, "disable_existing_loggers": False, "formatters": { @@ -344,6 +393,17 @@ def _get_uvicorn_json_log_config(): "()": json_formatter_class, }, }, + **( + { + "filters": { + "healthcheck": { + "()": "litellm._logging.HealthCheckAccessLogFilter", + }, + }, + } + if _DISABLED_ACCESS_LOG_PATHS + else {} + ), "handlers": { "default": { "formatter": "json", @@ -354,6 +414,7 @@ def _get_uvicorn_json_log_config(): "formatter": "access", "class": "logging.StreamHandler", "stream": "ext://sys.stdout", + **({"filters": ["healthcheck"]} if _DISABLED_ACCESS_LOG_PATHS else {}), }, }, "loggers": { diff --git a/tests/test_litellm/test_logging.py b/tests/test_litellm/test_logging.py index fbed044445b..4c9fd11dfd3 100644 --- a/tests/test_litellm/test_logging.py +++ b/tests/test_litellm/test_logging.py @@ -13,8 +13,10 @@ import logging import sys import litellm +from litellm import _logging as litellm_logging from litellm._logging import ( ALL_LOGGERS, + HealthCheckAccessLogFilter, JsonFormatter, _initialize_loggers_with_handler, _turn_on_json, @@ -328,3 +330,77 @@ async def test_cache_hit_includes_custom_llm_provider(): # Clean up litellm.callbacks = original_callbacks litellm.cache = None + + +def _make_uvicorn_access_record( + full_path: str, status: int = 200 +) -> logging.LogRecord: + # Mirror uvicorn's AccessFormatter args: + # (client_addr, method, full_path, http_version, status_code) + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("10.0.0.1:12345", "GET", full_path, "1.1", status), + exc_info=None, + ) + return record + + +def test_healthcheck_filter_passes_when_env_unset(monkeypatch): + monkeypatch.delenv("LITELLM_DISABLE_ACCESS_LOG_PATHS", raising=False) + monkeypatch.setattr( + litellm_logging, "_DISABLED_ACCESS_LOG_PATHS", frozenset() + ) + f = HealthCheckAccessLogFilter() + assert f.filter(_make_uvicorn_access_record("/health/liveliness")) is True + assert f.filter(_make_uvicorn_access_record("/")) is True + + +def test_healthcheck_filter_drops_configured_paths(monkeypatch): + paths = frozenset({"/", "/health/liveliness", "/health/readiness", "/metrics/"}) + monkeypatch.setattr(litellm_logging, "_DISABLED_ACCESS_LOG_PATHS", paths) + f = HealthCheckAccessLogFilter() + + # All four configured paths must be dropped + assert f.filter(_make_uvicorn_access_record("/")) is False + assert f.filter(_make_uvicorn_access_record("/health/liveliness")) is False + assert f.filter(_make_uvicorn_access_record("/health/readiness")) is False + assert f.filter(_make_uvicorn_access_record("/metrics/")) is False + + # Real traffic must still pass + assert f.filter(_make_uvicorn_access_record("/v1/chat/completions")) is True + assert f.filter(_make_uvicorn_access_record("/health")) is True # not in list + + +def test_healthcheck_filter_strips_query_string(monkeypatch): + monkeypatch.setattr( + litellm_logging, + "_DISABLED_ACCESS_LOG_PATHS", + frozenset({"/health/readiness"}), + ) + f = HealthCheckAccessLogFilter() + assert f.filter(_make_uvicorn_access_record("/health/readiness?x=1")) is False + + +def test_healthcheck_filter_robust_to_malformed_args(monkeypatch): + monkeypatch.setattr( + litellm_logging, "_DISABLED_ACCESS_LOG_PATHS", frozenset({"/"}) + ) + f = HealthCheckAccessLogFilter() + # No args + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg="raw msg", + args=None, + exc_info=None, + ) + assert f.filter(record) is True + # Args too short + record.args = ("10.0.0.1:12345", "GET") + assert f.filter(record) is True