From 6ee7f18f33bd83df657fa0a3aec9b448e71e4781 Mon Sep 17 00:00:00 2001 From: Jared Wen Date: Tue, 27 Jan 2026 05:49:03 +0800 Subject: [PATCH] [Logging] add `--disable-access-log-for-endpoints` CLI option (#30011) Add a new CLI option --disable-access-log-for-endpoints to suppress uvicorn access logs for specified endpoints (e.g., /health, /metrics, /ping). This addresses the need to reduce log noise in production environments where health check endpoints are frequently polled by load balancers or monitoring systems, generating excessive log entries that obscure meaningful request logs. Fixes #29982 Signed-off-by: JaredforReal --- examples/others/logging_configuration.md | 31 ++ tests/test_access_log_filter.py | 371 +++++++++++++++++++++++ vllm/entrypoints/openai/api_server.py | 37 ++- vllm/entrypoints/openai/cli_args.py | 11 + vllm/logging_utils/__init__.py | 6 + vllm/logging_utils/access_log_filter.py | 144 +++++++++ 6 files changed, 598 insertions(+), 2 deletions(-) create mode 100644 tests/test_access_log_filter.py create mode 100644 vllm/logging_utils/access_log_filter.py diff --git a/examples/others/logging_configuration.md b/examples/others/logging_configuration.md index 7c8bdd199..dcc11c9ad 100644 --- a/examples/others/logging_configuration.md +++ b/examples/others/logging_configuration.md @@ -157,6 +157,37 @@ VLLM_CONFIGURE_LOGGING=0 \ vllm serve mistralai/Mistral-7B-v0.1 --max-model-len 2048 ``` +### Example 4: Disable access logs for health check endpoints + +In production environments, health check endpoints like `/health`, `/metrics`, +and `/ping` are frequently called by load balancers and monitoring systems, +generating a large volume of repetitive access logs. To reduce log noise while +keeping logs for other endpoints, use the `--disable-access-log-for-endpoints` +option. + +**Disable access logs for health and metrics endpoints:** + +```bash +vllm serve mistralai/Mistral-7B-v0.1 --max-model-len 2048 \ + --disable-access-log-for-endpoints /health,/metrics,/ping +``` + +**Common endpoints to consider filtering:** + +| Endpoint | Description | Typical Caller | +| ---------- | ---------------------- | ---------------------------------------------------- | +| `/health` | Health check | Kubernetes liveness/readiness probes, load balancers | +| `/metrics` | Prometheus metrics | Prometheus scraper (every 15-60s) | +| `/ping` | SageMaker health check | SageMaker infrastructure | +| `/load` | Server load metrics | Custom monitoring | + +**Notes:** + +- This option only affects uvicorn access logs, not vLLM application logs +- Specify multiple endpoints by separating them with commas (no spaces) +- The filter uses exact path matching, query parameters are ignored (e.g., `/health?verbose=true` matches `/health`) +- If you need to completely disable all access logs, use `--disable-uvicorn-access-log` instead + ## Additional resources - [`logging.config` Dictionary Schema Details](https://docs.python.org/3/library/logging.config.html#dictionary-schema-details) diff --git a/tests/test_access_log_filter.py b/tests/test_access_log_filter.py new file mode 100644 index 000000000..a28771ce5 --- /dev/null +++ b/tests/test_access_log_filter.py @@ -0,0 +1,371 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Tests for the UvicornAccessLogFilter class. +""" + +import logging + +from vllm.logging_utils.access_log_filter import ( + UvicornAccessLogFilter, + create_uvicorn_log_config, +) + + +class TestUvicornAccessLogFilter: + """Test cases for UvicornAccessLogFilter.""" + + def test_filter_allows_all_when_no_excluded_paths(self): + """Filter should allow all logs when no paths are excluded.""" + filter = UvicornAccessLogFilter(excluded_paths=[]) + + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "GET", "/v1/completions", "1.1", 200), + exc_info=None, + ) + + assert filter.filter(record) is True + + def test_filter_allows_all_when_excluded_paths_is_none(self): + """Filter should allow all logs when excluded_paths is None.""" + filter = UvicornAccessLogFilter(excluded_paths=None) + + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "GET", "/health", "1.1", 200), + exc_info=None, + ) + + assert filter.filter(record) is True + + def test_filter_excludes_health_endpoint(self): + """Filter should exclude /health endpoint when configured.""" + filter = UvicornAccessLogFilter(excluded_paths=["/health"]) + + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "GET", "/health", "1.1", 200), + exc_info=None, + ) + + assert filter.filter(record) is False + + def test_filter_excludes_metrics_endpoint(self): + """Filter should exclude /metrics endpoint when configured.""" + filter = UvicornAccessLogFilter(excluded_paths=["/metrics"]) + + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "GET", "/metrics", "1.1", 200), + exc_info=None, + ) + + assert filter.filter(record) is False + + def test_filter_allows_non_excluded_endpoints(self): + """Filter should allow endpoints not in the excluded list.""" + filter = UvicornAccessLogFilter(excluded_paths=["/health", "/metrics"]) + + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "POST", "/v1/completions", "1.1", 200), + exc_info=None, + ) + + assert filter.filter(record) is True + + def test_filter_excludes_multiple_endpoints(self): + """Filter should exclude multiple configured endpoints.""" + filter = UvicornAccessLogFilter(excluded_paths=["/health", "/metrics", "/ping"]) + + # Test /health + record_health = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "GET", "/health", "1.1", 200), + exc_info=None, + ) + assert filter.filter(record_health) is False + + # Test /metrics + record_metrics = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "GET", "/metrics", "1.1", 200), + exc_info=None, + ) + assert filter.filter(record_metrics) is False + + # Test /ping + record_ping = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "GET", "/ping", "1.1", 200), + exc_info=None, + ) + assert filter.filter(record_ping) is False + + def test_filter_with_query_parameters(self): + """Filter should exclude endpoints even with query parameters.""" + filter = UvicornAccessLogFilter(excluded_paths=["/health"]) + + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "GET", "/health?verbose=true", "1.1", 200), + exc_info=None, + ) + + assert filter.filter(record) is False + + def test_filter_different_http_methods(self): + """Filter should exclude endpoints regardless of HTTP method.""" + filter = UvicornAccessLogFilter(excluded_paths=["/ping"]) + + # Test GET + record_get = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "GET", "/ping", "1.1", 200), + exc_info=None, + ) + assert filter.filter(record_get) is False + + # Test POST + record_post = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "POST", "/ping", "1.1", 200), + exc_info=None, + ) + assert filter.filter(record_post) is False + + def test_filter_with_different_status_codes(self): + """Filter should exclude endpoints regardless of status code.""" + filter = UvicornAccessLogFilter(excluded_paths=["/health"]) + + for status_code in [200, 500, 503]: + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg='%s - "%s %s HTTP/%s" %d', + args=("127.0.0.1:12345", "GET", "/health", "1.1", status_code), + exc_info=None, + ) + assert filter.filter(record) is False + + +class TestCreateUvicornLogConfig: + """Test cases for create_uvicorn_log_config function.""" + + def test_creates_valid_config_structure(self): + """Config should have required logging configuration keys.""" + config = create_uvicorn_log_config(excluded_paths=["/health"]) + + assert "version" in config + assert config["version"] == 1 + assert "disable_existing_loggers" in config + assert "formatters" in config + assert "handlers" in config + assert "loggers" in config + assert "filters" in config + + def test_config_includes_access_log_filter(self): + """Config should include the access log filter.""" + config = create_uvicorn_log_config(excluded_paths=["/health", "/metrics"]) + + assert "access_log_filter" in config["filters"] + filter_config = config["filters"]["access_log_filter"] + assert filter_config["()"] == UvicornAccessLogFilter + assert filter_config["excluded_paths"] == ["/health", "/metrics"] + + def test_config_applies_filter_to_access_handler(self): + """Config should apply the filter to the access handler.""" + config = create_uvicorn_log_config(excluded_paths=["/health"]) + + assert "access" in config["handlers"] + assert "filters" in config["handlers"]["access"] + assert "access_log_filter" in config["handlers"]["access"]["filters"] + + def test_config_with_custom_log_level(self): + """Config should respect custom log level.""" + config = create_uvicorn_log_config( + excluded_paths=["/health"], log_level="debug" + ) + + assert config["loggers"]["uvicorn"]["level"] == "DEBUG" + assert config["loggers"]["uvicorn.access"]["level"] == "DEBUG" + assert config["loggers"]["uvicorn.error"]["level"] == "DEBUG" + + def test_config_with_empty_excluded_paths(self): + """Config should work with empty excluded paths.""" + config = create_uvicorn_log_config(excluded_paths=[]) + + assert config["filters"]["access_log_filter"]["excluded_paths"] == [] + + def test_config_with_none_excluded_paths(self): + """Config should work with None excluded paths.""" + config = create_uvicorn_log_config(excluded_paths=None) + + assert config["filters"]["access_log_filter"]["excluded_paths"] == [] + + +class TestIntegration: + """Integration tests for the access log filter.""" + + def test_filter_with_real_logger(self): + """Test filter works with a real Python logger simulating uvicorn.""" + # Create a logger with our filter (simulating uvicorn.access) + logger = logging.getLogger("uvicorn.access") + logger.setLevel(logging.INFO) + + # Clear any existing handlers + logger.handlers = [] + + # Create a custom handler that tracks messages + logged_messages: list[str] = [] + + class TrackingHandler(logging.Handler): + def emit(self, record): + logged_messages.append(record.getMessage()) + + handler = TrackingHandler() + handler.setLevel(logging.INFO) + filter = UvicornAccessLogFilter(excluded_paths=["/health", "/metrics"]) + handler.addFilter(filter) + logger.addHandler(handler) + + # Log using uvicorn's format with args tuple + # Format: '%s - "%s %s HTTP/%s" %d' + logger.info( + '%s - "%s %s HTTP/%s" %d', + "127.0.0.1:12345", + "GET", + "/health", + "1.1", + 200, + ) + logger.info( + '%s - "%s %s HTTP/%s" %d', + "127.0.0.1:12345", + "GET", + "/v1/completions", + "1.1", + 200, + ) + logger.info( + '%s - "%s %s HTTP/%s" %d', + "127.0.0.1:12345", + "GET", + "/metrics", + "1.1", + 200, + ) + logger.info( + '%s - "%s %s HTTP/%s" %d', + "127.0.0.1:12345", + "POST", + "/v1/chat/completions", + "1.1", + 200, + ) + + # Verify only non-excluded endpoints were logged + assert len(logged_messages) == 2 + assert "/v1/completions" in logged_messages[0] + assert "/v1/chat/completions" in logged_messages[1] + + def test_filter_allows_non_uvicorn_access_logs(self): + """Test filter allows logs from non-uvicorn.access loggers.""" + filter = UvicornAccessLogFilter(excluded_paths=["/health"]) + + # Log record from a different logger name + record = logging.LogRecord( + name="uvicorn.error", + level=logging.INFO, + pathname="", + lineno=0, + msg="Some error message about /health", + args=(), + exc_info=None, + ) + + # Should allow because it's not from uvicorn.access + assert filter.filter(record) is True + + def test_filter_handles_malformed_args(self): + """Test filter handles log records with unexpected args format.""" + filter = UvicornAccessLogFilter(excluded_paths=["/health"]) + + # Log record with insufficient args + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg="Some message", + args=("only", "two"), + exc_info=None, + ) + + # Should allow because args doesn't have expected format + assert filter.filter(record) is True + + def test_filter_handles_non_tuple_args(self): + """Test filter handles log records with non-tuple args.""" + filter = UvicornAccessLogFilter(excluded_paths=["/health"]) + + # Log record with None args + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg="Some message without args", + args=None, + exc_info=None, + ) + + # Should allow because args is None + assert filter.filter(record) is True diff --git a/vllm/entrypoints/openai/api_server.py b/vllm/entrypoints/openai/api_server.py index 6de41a9e7..ff00f1d29 100644 --- a/vllm/entrypoints/openai/api_server.py +++ b/vllm/entrypoints/openai/api_server.py @@ -264,6 +264,39 @@ def load_log_config(log_config_file: str | None) -> dict | None: return None +def get_uvicorn_log_config(args: Namespace) -> dict | None: + """ + Get the uvicorn log config based on the provided arguments. + + Priority: + 1. If log_config_file is specified, use it + 2. If disable_access_log_for_endpoints is specified, create a config with + the access log filter + 3. Otherwise, return None (use uvicorn defaults) + """ + # First, try to load from file if specified + log_config = load_log_config(args.log_config_file) + if log_config is not None: + return log_config + + # If endpoints to filter are specified, create a config with the filter + if args.disable_access_log_for_endpoints: + from vllm.logging_utils import create_uvicorn_log_config + + # Parse comma-separated string into list + excluded_paths = [ + p.strip() + for p in args.disable_access_log_for_endpoints.split(",") + if p.strip() + ] + return create_uvicorn_log_config( + excluded_paths=excluded_paths, + log_level=args.uvicorn_log_level, + ) + + return None + + class AuthenticationMiddleware: """ Pure ASGI middleware that authenticates each request by checking @@ -930,8 +963,8 @@ async def run_server_worker( if args.reasoning_parser_plugin and len(args.reasoning_parser_plugin) > 3: ReasoningParserManager.import_reasoning_parser(args.reasoning_parser_plugin) - # Load logging config for uvicorn if specified - log_config = load_log_config(args.log_config_file) + # Get uvicorn log config (from file or with endpoint filter) + log_config = get_uvicorn_log_config(args) if log_config is not None: uvicorn_kwargs["log_config"] = log_config diff --git a/vllm/entrypoints/openai/cli_args.py b/vllm/entrypoints/openai/cli_args.py index 0644e91a7..808c2a908 100644 --- a/vllm/entrypoints/openai/cli_args.py +++ b/vllm/entrypoints/openai/cli_args.py @@ -85,6 +85,12 @@ class FrontendArgs: """Log level for uvicorn.""" disable_uvicorn_access_log: bool = False """Disable uvicorn access log.""" + disable_access_log_for_endpoints: str | None = None + """Comma-separated list of endpoint paths to exclude from uvicorn access + logs. This is useful to reduce log noise from high-frequency endpoints + like health checks. Example: "/health,/metrics,/ping". + When set, access logs for requests to these paths will be suppressed + while keeping logs for other endpoints.""" allow_credentials: bool = False """Allow credentials.""" allowed_origins: list[str] = field(default_factory=lambda: ["*"]) @@ -244,6 +250,11 @@ class FrontendArgs: del frontend_kwargs["middleware"]["nargs"] frontend_kwargs["middleware"]["default"] = [] + # Special case: disable_access_log_for_endpoints is a single + # comma-separated string, not a list + if "nargs" in frontend_kwargs["disable_access_log_for_endpoints"]: + del frontend_kwargs["disable_access_log_for_endpoints"]["nargs"] + # Special case: Tool call parser shows built-in options. valid_tool_parsers = list(ToolParserManager.list_registered()) parsers_str = ",".join(valid_tool_parsers) diff --git a/vllm/logging_utils/__init__.py b/vllm/logging_utils/__init__.py index 8d3354df2..94dee07ed 100644 --- a/vllm/logging_utils/__init__.py +++ b/vllm/logging_utils/__init__.py @@ -1,6 +1,10 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from vllm.logging_utils.access_log_filter import ( + UvicornAccessLogFilter, + create_uvicorn_log_config, +) from vllm.logging_utils.formatter import ColoredFormatter, NewLineFormatter from vllm.logging_utils.lazy import lazy from vllm.logging_utils.log_time import logtime @@ -8,6 +12,8 @@ from vllm.logging_utils.log_time import logtime __all__ = [ "NewLineFormatter", "ColoredFormatter", + "UvicornAccessLogFilter", + "create_uvicorn_log_config", "lazy", "logtime", ] diff --git a/vllm/logging_utils/access_log_filter.py b/vllm/logging_utils/access_log_filter.py new file mode 100644 index 000000000..5501bd5bc --- /dev/null +++ b/vllm/logging_utils/access_log_filter.py @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Access log filter for uvicorn to exclude specific endpoints from logging. + +This module provides a logging filter that can be used to suppress access logs +for specific endpoints (e.g., /health, /metrics) to reduce log noise in +production environments. +""" + +import logging +from urllib.parse import urlparse + + +class UvicornAccessLogFilter(logging.Filter): + """ + A logging filter that excludes access logs for specified endpoint paths. + + This filter is designed to work with uvicorn's access logger. It checks + the log record's arguments for the request path and filters out records + matching the excluded paths. + + Uvicorn access log format: + '%s - "%s %s HTTP/%s" %d' + (client_addr, method, path, http_version, status_code) + + Example: + 127.0.0.1:12345 - "GET /health HTTP/1.1" 200 + + Args: + excluded_paths: A list of URL paths to exclude from logging. + Paths are matched exactly. + Example: ["/health", "/metrics"] + """ + + def __init__(self, excluded_paths: list[str] | None = None): + super().__init__() + self.excluded_paths = set(excluded_paths or []) + + def filter(self, record: logging.LogRecord) -> bool: + """ + Determine if the log record should be logged. + + Args: + record: The log record to evaluate. + + Returns: + True if the record should be logged, False otherwise. + """ + if not self.excluded_paths: + return True + + # This filter is specific to uvicorn's access logs. + if record.name != "uvicorn.access": + return True + + # The path is the 3rd argument in the log record's args tuple. + # See uvicorn's access logging implementation for details. + log_args = record.args + if isinstance(log_args, tuple) and len(log_args) >= 3: + path_with_query = log_args[2] + # Get path component without query string. + if isinstance(path_with_query, str): + path = urlparse(path_with_query).path + if path in self.excluded_paths: + return False + + return True + + +def create_uvicorn_log_config( + excluded_paths: list[str] | None = None, + log_level: str = "info", +) -> dict: + """ + Create a uvicorn logging configuration with access log filtering. + + This function generates a logging configuration dictionary that can be + passed to uvicorn's `log_config` parameter. It sets up the access log + filter to exclude specified paths. + + Args: + excluded_paths: List of URL paths to exclude from access logs. + log_level: The log level for uvicorn loggers. + + Returns: + A dictionary containing the logging configuration. + + Example: + >>> config = create_uvicorn_log_config(["/health", "/metrics"]) + >>> uvicorn.run(app, log_config=config) + """ + config = { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "access_log_filter": { + "()": UvicornAccessLogFilter, + "excluded_paths": excluded_paths or [], + }, + }, + "formatters": { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "fmt": "%(levelprefix)s %(message)s", + "use_colors": None, + }, + "access": { + "()": "uvicorn.logging.AccessFormatter", + "fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s', # noqa: E501 + }, + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr", + }, + "access": { + "formatter": "access", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "filters": ["access_log_filter"], + }, + }, + "loggers": { + "uvicorn": { + "handlers": ["default"], + "level": log_level.upper(), + "propagate": False, + }, + "uvicorn.error": { + "level": log_level.upper(), + "handlers": ["default"], + "propagate": False, + }, + "uvicorn.access": { + "handlers": ["access"], + "level": log_level.upper(), + "propagate": False, + }, + }, + } + return config