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