Repository: open-telemetry/opentelemetry-python-contrib

Issue:
fastapi instrumentation: add possibility to exclude some routes
Hi,
Thank you for that awesome lib 😊

Jaegger is crumbling under my logs because I have a “/health” route that is called every second on more than 20 services. Is it possible to add the possibility of excluding routes?

Idea exemple :

```
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

@FastAPIInstrumentor.exclude()
@app.get("/health")
async def health():
    return {"status": "OK"}

```
Or
```
FastAPIInstrumentor.instrument_app(app, ["/health"])
```



Discussion:
This is a great catch. We have `exclude_lists` via EXCLUDE_LIST environment variables for a lot of instrumentations but `FastAPI` is not one of them =p.

Resolving patch:
diff --git a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py
--- a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py
+++ b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py
@@ -64,11 +64,7 @@ def get(
 def collect_request_attributes(scope):
     """Collects HTTP request attributes from the ASGI scope and returns a
     dictionary to be used as span creation attributes."""
-    server = scope.get("server") or ["0.0.0.0", 80]
-    port = server[1]
-    server_host = server[0] + (":" + str(port) if port != 80 else "")
-    full_path = scope.get("root_path", "") + scope.get("path", "")
-    http_url = scope.get("scheme", "http") + "://" + server_host + full_path
+    server_host, port, http_url = get_host_port_url_tuple(scope)
     query_string = scope.get("query_string")
     if query_string and http_url:
         if isinstance(query_string, bytes):
@@ -105,6 +101,17 @@ def collect_request_attributes(scope):
     return result
 
 
+def get_host_port_url_tuple(scope):
+    """Returns (host, port, full_url) tuple.
+    """
+    server = scope.get("server") or ["0.0.0.0", 80]
+    port = server[1]
+    server_host = server[0] + (":" + str(port) if port != 80 else "")
+    full_path = scope.get("root_path", "") + scope.get("path", "")
+    http_url = scope.get("scheme", "http") + "://" + server_host + full_path
+    return server_host, port, http_url
+
+
 def set_status_code(span, status_code):
     """Adds HTTP response attributes to span using the status_code argument."""
     if not span.is_recording():
@@ -152,12 +159,13 @@ class OpenTelemetryMiddleware:
             Optional: Defaults to get_default_span_details.
     """
 
-    def __init__(self, app, span_details_callback=None):
+    def __init__(self, app, excluded_urls=None, span_details_callback=None):
         self.app = guarantee_single_callable(app)
         self.tracer = trace.get_tracer(__name__, __version__)
         self.span_details_callback = (
             span_details_callback or get_default_span_details
         )
+        self.excluded_urls = excluded_urls
 
     async def __call__(self, scope, receive, send):
         """The ASGI application
@@ -170,6 +178,10 @@ async def __call__(self, scope, receive, send):
         if scope["type"] not in ("http", "websocket"):
             return await self.app(scope, receive, send)
 
+        _, _, url = get_host_port_url_tuple(scope)
+        if self.excluded_urls and self.excluded_urls.url_disabled(url):
+            return await self.app(scope, receive, send)
+
         token = context.attach(propagators.extract(carrier_getter, scope))
         span_name, additional_attributes = self.span_details_callback(scope)
 
diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py
--- a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py
+++ b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py
@@ -16,10 +16,13 @@
 import fastapi
 from starlette.routing import Match
 
+from opentelemetry.configuration import Configuration
 from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
 from opentelemetry.instrumentation.fastapi.version import __version__  # noqa
 from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
 
+_excluded_urls = Configuration()._excluded_urls("fastapi")
+
 
 class FastAPIInstrumentor(BaseInstrumentor):
     """An instrumentor for FastAPI
@@ -36,6 +39,7 @@ def instrument_app(app: fastapi.FastAPI):
         if not getattr(app, "is_instrumented_by_opentelemetry", False):
             app.add_middleware(
                 OpenTelemetryMiddleware,
+                excluded_urls=_excluded_urls,
                 span_details_callback=_get_route_details,
             )
             app.is_instrumented_by_opentelemetry = True
@@ -52,7 +56,9 @@ class _InstrumentedFastAPI(fastapi.FastAPI):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.add_middleware(
-            OpenTelemetryMiddleware, span_details_callback=_get_route_details
+            OpenTelemetryMiddleware,
+            excluded_urls=_excluded_urls,
+            span_details_callback=_get_route_details,
         )
 
 
diff --git a/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/__init__.py b/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/__init__.py
--- a/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/__init__.py
+++ b/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/__init__.py
@@ -16,10 +16,13 @@
 from starlette import applications
 from starlette.routing import Match
 
+from opentelemetry.configuration import Configuration
 from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
 from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
 from opentelemetry.instrumentation.starlette.version import __version__  # noqa
 
+_excluded_urls = Configuration()._excluded_urls("starlette")
+
 
 class StarletteInstrumentor(BaseInstrumentor):
     """An instrumentor for starlette
@@ -36,6 +39,7 @@ def instrument_app(app: applications.Starlette):
         if not getattr(app, "is_instrumented_by_opentelemetry", False):
             app.add_middleware(
                 OpenTelemetryMiddleware,
+                excluded_urls=_excluded_urls,
                 span_details_callback=_get_route_details,
             )
             app.is_instrumented_by_opentelemetry = True
@@ -52,7 +56,9 @@ class _InstrumentedStarlette(applications.Starlette):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.add_middleware(
-            OpenTelemetryMiddleware, span_details_callback=_get_route_details
+            OpenTelemetryMiddleware,
+            excluded_urls=_excluded_urls,
+            span_details_callback=_get_route_details,
         )
 
 


Test patch:
diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py
--- a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py
+++ b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py
@@ -13,11 +13,13 @@
 # limitations under the License.
 
 import unittest
+from unittest.mock import patch
 
 import fastapi
 from fastapi.testclient import TestClient
 
 import opentelemetry.instrumentation.fastapi as otel_fastapi
+from opentelemetry.configuration import Configuration
 from opentelemetry.test.test_base import TestBase
 
 
@@ -29,10 +31,26 @@ def _create_app(self):
 
     def setUp(self):
         super().setUp()
+        Configuration()._reset()
+        self.env_patch = patch.dict(
+            "os.environ",
+            {"OTEL_PYTHON_FASTAPI_EXCLUDED_URLS": "/exclude/123,healthzz"},
+        )
+        self.env_patch.start()
+        self.exclude_patch = patch(
+            "opentelemetry.instrumentation.fastapi._excluded_urls",
+            Configuration()._excluded_urls("fastapi"),
+        )
+        self.exclude_patch.start()
         self._instrumentor = otel_fastapi.FastAPIInstrumentor()
         self._app = self._create_app()
         self._client = TestClient(self._app)
 
+    def tearDown(self):
+        super().tearDown()
+        self.env_patch.stop()
+        self.exclude_patch.stop()
+
     def test_basic_fastapi_call(self):
         self._client.get("/foobar")
         spans = self.memory_exporter.get_finished_spans()
@@ -54,6 +72,15 @@ def test_fastapi_route_attribute_added(self):
         # the asgi instrumentation is successfully feeding though.
         self.assertEqual(spans[-1].attributes["http.flavor"], "1.1")
 
+    def test_fastapi_excluded_urls(self):
+        """Ensure that given fastapi routes are excluded."""
+        self._client.get("/exclude/123")
+        spans = self.memory_exporter.get_finished_spans()
+        self.assertEqual(len(spans), 0)
+        self._client.get("/healthzz")
+        spans = self.memory_exporter.get_finished_spans()
+        self.assertEqual(len(spans), 0)
+
     @staticmethod
     def _create_fastapi_app():
         app = fastapi.FastAPI()
@@ -66,6 +93,14 @@ async def _():
         async def _(username: str):
             return {"message": username}
 
+        @app.get("/exclude/{param}")
+        async def _(param: str):
+            return {"message": param}
+
+        @app.get("/healthzz")
+        async def health():
+            return {"message": "ok"}
+
         return app
 
 
diff --git a/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py b/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py
--- a/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py
+++ b/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 import unittest
+from unittest.mock import patch
 
 from starlette import applications
 from starlette.responses import PlainTextResponse
@@ -20,6 +21,7 @@
 from starlette.testclient import TestClient
 
 import opentelemetry.instrumentation.starlette as otel_starlette
+from opentelemetry.configuration import Configuration
 from opentelemetry.test.test_base import TestBase
 
 
@@ -31,10 +33,26 @@ def _create_app(self):
 
     def setUp(self):
         super().setUp()
+        Configuration()._reset()
+        self.env_patch = patch.dict(
+            "os.environ",
+            {"OTEL_PYTHON_STARLETTE_EXCLUDED_URLS": "/exclude/123,healthzz"},
+        )
+        self.env_patch.start()
+        self.exclude_patch = patch(
+            "opentelemetry.instrumentation.starlette._excluded_urls",
+            Configuration()._excluded_urls("starlette"),
+        )
+        self.exclude_patch.start()
         self._instrumentor = otel_starlette.StarletteInstrumentor()
         self._app = self._create_app()
         self._client = TestClient(self._app)
 
+    def tearDown(self):
+        super().tearDown()
+        self.env_patch.stop()
+        self.exclude_patch.stop()
+
     def test_basic_starlette_call(self):
         self._client.get("/foobar")
         spans = self.memory_exporter.get_finished_spans()
@@ -56,13 +74,26 @@ def test_starlette_route_attribute_added(self):
         # the asgi instrumentation is successfully feeding though.
         self.assertEqual(spans[-1].attributes["http.flavor"], "1.1")
 
+    def test_starlette_excluded_urls(self):
+        """Ensure that givem starlette routes are excluded."""
+        self._client.get("/healthzz")
+        spans = self.memory_exporter.get_finished_spans()
+        self.assertEqual(len(spans), 0)
+
     @staticmethod
     def _create_starlette_app():
         def home(_):
             return PlainTextResponse("hi")
 
+        def health(_):
+            return PlainTextResponse("ok")
+
         app = applications.Starlette(
-            routes=[Route("/foobar", home), Route("/user/{username}", home)]
+            routes=[
+                Route("/foobar", home),
+                Route("/user/{username}", home),
+                Route("/healthzz", health),
+            ]
         )
         return app