Repository: SeldonIO/MLServer

Issue:
Allow configuring metrics endpoint name
Currently, the metrics endpoint is hardcoded to `/metrics`. It would be useful to support configuring this value, so that it can be matched by what Prometheus is expecting upstream.

For example, in Seldon Core this would something like `/prometheus` by default.


Discussion:

Resolving patch:
diff --git a/mlserver/grpc/server.py b/mlserver/grpc/server.py
--- a/mlserver/grpc/server.py
+++ b/mlserver/grpc/server.py
@@ -32,11 +32,14 @@ def _create_server(self):
             self._model_repository_handlers
         )
 
-        logging_interceptor = LoggingInterceptor()
-        prom_interceptor = PromServerInterceptor()
+        interceptors = [LoggingInterceptor()]
+
+        if self._settings.metrics_endpoint:
+            interceptors.append(PromServerInterceptor())
+
         self._server = aio.server(
             ThreadPoolExecutor(max_workers=DefaultGrpcWorkers),
-            interceptors=(prom_interceptor, logging_interceptor),
+            interceptors=tuple(interceptors),
             options=self._get_options(),
         )
 
diff --git a/mlserver/rest/app.py b/mlserver/rest/app.py
--- a/mlserver/rest/app.py
+++ b/mlserver/rest/app.py
@@ -114,13 +114,18 @@ def create_app(
             max_age=settings.cors_settings.max_age,
         )
 
-    app.add_middleware(
-        PrometheusMiddleware,
-        app_name="mlserver",
-        prefix="rest_server",
-        # TODO: Should we also exclude model's health endpoints?
-        skip_paths=["/metrics", "/v2/health/live", "/v2/health/ready"],
-    )
-    app.add_route("/metrics", handle_metrics)
+    if settings.metrics_endpoint:
+        app.add_middleware(
+            PrometheusMiddleware,
+            app_name="mlserver",
+            prefix="rest_server",
+            # TODO: Should we also exclude model's health endpoints?
+            skip_paths=[
+                settings.metrics_endpoint,
+                "/v2/health/live",
+                "/v2/health/ready",
+            ],
+        )
+        app.add_route(settings.metrics_endpoint, handle_metrics)
 
     return app
diff --git a/mlserver/settings.py b/mlserver/settings.py
--- a/mlserver/settings.py
+++ b/mlserver/settings.py
@@ -76,6 +76,13 @@ class Config:
     # CORS settings
     cors_settings: Optional[CORSSettings] = None
 
+    # Metrics settings
+    """
+    Endpoint used to expose Prometheus metrics. Alternatively, can be set to
+    `None` to disable it
+    """
+    metrics_endpoint: Optional[str] = "/metrics"
+
 
 class ModelParameters(BaseSettings):
     """


Test patch:
diff --git a/tests/metrics/conftest.py b/tests/metrics/conftest.py
--- a/tests/metrics/conftest.py
+++ b/tests/metrics/conftest.py
@@ -69,4 +69,8 @@ async def mlserver(
 @pytest.fixture
 async def metrics_client(mlserver: MLServer, settings: Settings):
     http_server = f"{settings.host}:{settings.http_port}"
-    return MetricsClient(http_server)
+    client = MetricsClient(http_server, metrics_endpoint=settings.metrics_endpoint)
+
+    yield client
+
+    await client.close()
diff --git a/tests/metrics/test_endpoint.py b/tests/metrics/test_endpoint.py
--- a/tests/metrics/test_endpoint.py
+++ b/tests/metrics/test_endpoint.py
@@ -1,14 +1,35 @@
+import pytest
+
+from pytest_cases import parametrize_with_cases, fixture
+from mlserver import Settings
+from aiohttp.client_exceptions import ClientResponseError
+
 from .utils import MetricsClient
 
 
+@fixture
+@parametrize_with_cases("metrics_endpoint")
+def settings(settings: Settings, metrics_endpoint: str) -> Settings:
+    settings.metrics_endpoint = metrics_endpoint
+    return settings
+
+
 async def test_metrics(metrics_client: MetricsClient):
     await metrics_client.wait_until_ready()
-    metrics = await metrics_client.metrics()
 
-    assert metrics is not None
+    if metrics_client._metrics_endpoint is None:
+        # Assert metrics are disabled
+        metrics_client._metrics_endpoint = "/metrics"
+        with pytest.raises(ClientResponseError) as err:
+            await metrics_client.metrics()
+            assert err.status_code == 404
+    else:
+        # Otherwise, assert all metrics are present
+        metrics = await metrics_client.metrics()
+        assert metrics is not None
 
-    expected_prefixes = ("python_", "process_", "rest_server_", "grpc_server_")
-    metrics_list = list(iter(metrics))
-    assert len(metrics_list) > 0
-    for metric in metrics_list:
-        assert metric.name.startswith(expected_prefixes)
+        expected_prefixes = ("python_", "process_", "rest_server_", "grpc_server_")
+        metrics_list = list(iter(metrics))
+        assert len(metrics_list) > 0
+        for metric in metrics_list:
+            assert metric.name.startswith(expected_prefixes)
diff --git a/tests/metrics/test_endpoint_cases.py b/tests/metrics/test_endpoint_cases.py
new file mode 100644
--- /dev/null
+++ b/tests/metrics/test_endpoint_cases.py
@@ -0,0 +1,13 @@
+from typing import Optional
+
+
+def case_metrics() -> Optional[str]:
+    return "/metrics"
+
+
+def case_prometheus() -> Optional[str]:
+    return "/prometheus"
+
+
+def case_disabled() -> Optional[str]:
+    return None
diff --git a/tests/metrics/utils.py b/tests/metrics/utils.py
--- a/tests/metrics/utils.py
+++ b/tests/metrics/utils.py
@@ -7,8 +7,12 @@
 
 
 class MetricsClient(RESTClient):
+    def __init__(self, *args, metrics_endpoint: str = "/metrics", **kwargs):
+        super().__init__(*args, **kwargs)
+        self._metrics_endpoint = metrics_endpoint
+
     async def metrics(self) -> List[Metric]:
-        endpoint = f"http://{self._http_server}/metrics"
+        endpoint = f"http://{self._http_server}{self._metrics_endpoint}"
         response = await self._session.get(endpoint)
 
         raw_metrics = await response.text()