Repository: stephenhillier/starlette_exporter

Issue:
Feature request: ignoring paths not mapped in the app (`filter_unhandled_paths`)
Hey! I would like to add a feature to filter out paths that are not mapped by the application. Currently, a malicious actor could DoS the underlying Prometheus DB by generating multiple requests that will result in a 404.

My suggestion for the parameter name is `filter_unhandled_paths` -- this will make the `starlette_exporter` API-level compatible with `starlette-prometheus`.

Discussion:

Patch:
diff --git a/starlette_exporter/middleware.py b/starlette_exporter/middleware.py
index 4b04cd6..efd8cf5 100644
--- a/starlette_exporter/middleware.py
+++ b/starlette_exporter/middleware.py
@@ -1,6 +1,7 @@
 """ Middleware for exporting Prometheus metrics using Starlette """
 import time
 from logging import getLogger
+from typing import List, Optional
 
 from prometheus_client import Counter, Histogram
 from starlette.requests import Request
@@ -18,12 +19,14 @@ class PrometheusMiddleware:
 
     def __init__(
         self, app: ASGIApp, group_paths: bool = False, app_name: str = "starlette",
-        prefix="starlette", buckets=None
+        prefix: str = "starlette", buckets: Optional[List[str]] = None,
+        filter_unhandled_paths: bool = False,
     ):
         self.app = app
         self.group_paths = group_paths
         self.app_name = app_name
         self.prefix = prefix
+        self.filter_unhandled_paths = filter_unhandled_paths
         self.kwargs = {}
         if buckets is not None:
             self.kwargs['buckets'] = buckets
@@ -77,37 +80,18 @@ class PrometheusMiddleware:
         try:
             await self.app(scope, receive, wrapped_send)
         finally:
-            # group_paths enables returning the original router path (with url param names)
-            # for example, when using this option, requests to /api/product/1 and /api/product/3
-            # will both be grouped under /api/product/{product_id}. See the README for more info.
-            if (
-                self.group_paths
-                and request.scope.get('endpoint', None)
-                and request.scope.get('router', None)
-            ):
-                try:
-                    # try to match the request scope's handler function against one of handlers in the app's router.
-                    # if a match is found, return the path used to mount the handler (e.g. api/product/{product_id}).
-                    path = [
-                        route
-                        for route in request.scope['router'].routes
-                        if (
-                            hasattr(route, 'endpoint')
-                            and route.endpoint == request.scope['endpoint']
-                        )
-                        # for endpoints handled by another app, like fastapi.staticfiles.StaticFiles,
-                        # check if the request endpoint matches a mounted app.
-                        or (
-                            hasattr(route, 'app')
-                            and route.app == request.scope['endpoint']
-                        )
-                    ][0].path
-                except IndexError:
-                    # no route matched.
-                    # this can happen for routes that don't have an endpoint function.
-                    pass
-                except Exception as e:
-                    logger.error(e)
+            if self.filter_unhandled_paths or self.group_paths:
+                grouped_path = self._get_router_path(request)
+
+                # filter_unhandled_paths removes any requests without mapped endpoint from the metrics.
+                if self.filter_unhandled_paths and grouped_path is None:
+                    return
+
+                # group_paths enables returning the original router path (with url param names)
+                # for example, when using this option, requests to /api/product/1 and /api/product/3
+                # will both be grouped under /api/product/{product_id}. See the README for more info.
+                if self.group_paths and grouped_path is not None:
+                    path = grouped_path
 
             end = time.perf_counter()
 
@@ -115,3 +99,27 @@ class PrometheusMiddleware:
 
             self.request_count.labels(*labels).inc()
             self.request_time.labels(*labels).observe(end - begin)
+
+    @staticmethod
+    def _get_router_path(request: Request) -> Optional[str]:
+        """Returns the original router path (with url param names) for given request."""
+        try:
+            if not (request.scope.get('endpoint') and request.scope.get('router')):
+                return None
+
+            for route in request.scope['router'].routes:
+                if ((
+                        hasattr(route, 'endpoint')
+                        and route.endpoint == request.scope['endpoint']
+                    )
+                    # for endpoints handled by another app, like fastapi.staticfiles.StaticFiles,
+                    # check if the request endpoint matches a mounted app.
+                    or (
+                        hasattr(route, 'app')
+                        and route.app == request.scope['endpoint']
+                    )):
+                    return route.path
+        except:
+            logger.exception("Failed to fetch router path.")
+        
+        return None


Test patch:
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index 776b8ab..6f1faa6 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -147,6 +147,14 @@ class TestMiddleware:
             in metrics
         )
 
+    def test_filter_unhandled_paths(self, testapp):
+        """ test that app_name label is populated correctly """
+        client = TestClient(testapp(filter_unhandled_paths=True))
+
+        client.get('/this_path_does_not_exist')
+        metrics = client.get('/metrics').content.decode()
+        assert 'this_path_does_not_exist' not in metrics
+
     def test_prefix(self, testapp):
         """ test that metric prefixes work """
         client = TestClient(testapp(prefix="myapp"))