Repository: open-telemetry/opentelemetry-python-contrib

Issue:
Fix HTTP instrumentation not being suppressed
# Description

Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.

This change aims to fix the issue outlined in #930 where urllib3 downstream spans are not being suppressed with a requests upstream, causing an extra span to be created. 

The root cause of this bug lies in the create_key() method in opentelemetry context which adds a unique `uuid` to the end of each key created. Since a new `_SUPPRESS_HTTP_INSTRUMENTATION` key is being created in the instrumentation for each library, this results in a different key being created by each instrumentation instead of the creation of a single key that suppresses HTTP instrumentation so proceeding libraries know not to instrument further. 

This fix involves creating the `_SUPPRESS_HTTP_INSTRUMENTATION` once in opentelemetry context to avoid the bug that causes a new suppress http instrumentation key to be created by each instrumentation. By having only 1 key that is accessible to all instrumentations, its true value can be passed on from one instrumentation to the next without being set to None every time the instrumentation of a new library begins. This does not conflict with the [context spec](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.1.0/specification/context/context.md#context) since the `_SUPPRESS_HTTP_INSTRUMENTATION_KEY` is meant to be shared, and creating a unique version of it in each library instrumentation is not the correct implementation of this concept. 

Fixes #930 

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)

# How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration

- [x] Tox unit tests for each affected library

# Does This PR Require a Core Repo Change?

- [x] Yes, please refer to [PR # 2729](https://github.com/open-telemetry/opentelemetry-python/pull/2729) in the core repo

# Checklist:

See [contributing.md](https://github.com/open-telemetry/opentelemetry-python-contrib/blob/main/CONTRIBUTING.md) for styleguide, changelog guidelines, and more.

- [x] Followed the style guidelines of this project
- [x] Changelogs have been updated
- [x] Unit tests have been added 



Discussion:
<a href="https://easycla.lfx.linuxfoundation.org/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-signed.svg" alt="CLA Signed" align="left" height="28" width="328" ></a><br/><br />The committers listed above are authorized under a signed CLA.<ul><li>:white_check_mark: login: carolabadeer / name: Carol Abadeer  (5905853c953598b6eb92cfbfe3f1034bf126f633, 6558b1e2e366d6e4cbc3ac56d29627ee9366a89c, 7d35f17f93055a3ec036eb08b6d7259e44521f00)</li></ul>

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
@@ -366,6 +366,32 @@ def get_default_span_details(scope: dict) -> Tuple[str, dict]:
     return span_name, {}
 
 
+def _collect_target_attribute(
+    scope: typing.Dict[str, typing.Any]
+) -> typing.Optional[str]:
+    """
+    Returns the target path as defined by the Semantic Conventions.
+
+    This value is suitable to use in metrics as it should replace concrete
+    values with a parameterized name. Example: /api/users/{user_id}
+
+    Refer to the specification
+    https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/semantic_conventions/http-metrics.md#parameterized-attributes
+
+    Note: this function requires specific code for each framework, as there's no
+    standard attribute to use.
+    """
+    # FastAPI
+    root_path = scope.get("root_path", "")
+
+    route = scope.get("route")
+    path_format = getattr(route, "path_format", None)
+    if path_format:
+        return f"{root_path}{path_format}"
+
+    return None
+
+
 class OpenTelemetryMiddleware:
     """The ASGI application middleware.
 
@@ -387,6 +413,7 @@ class OpenTelemetryMiddleware:
             the current globally configured one is used.
     """
 
+    # pylint: disable=too-many-branches
     def __init__(
         self,
         app,
@@ -454,6 +481,12 @@ async def __call__(self, scope, receive, send):
             attributes
         )
         duration_attrs = _parse_duration_attrs(attributes)
+
+        target = _collect_target_attribute(scope)
+        if target:
+            active_requests_count_attrs[SpanAttributes.HTTP_TARGET] = target
+            duration_attrs[SpanAttributes.HTTP_TARGET] = target
+
         if scope["type"] == "http":
             self.active_requests_counter.add(1, active_requests_count_attrs)
         try:
@@ -496,6 +529,8 @@ async def __call__(self, scope, receive, send):
             if token:
                 context.detach(token)
 
+    # pylint: enable=too-many-branches
+
     def _get_otel_receive(self, server_span_name, scope, receive):
         @wraps(receive)
         async def otel_receive():


Test patch:
diff --git a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py
--- a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py
+++ b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py
@@ -12,6 +12,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+# pylint: disable=too-many-lines
+
 import sys
 import unittest
 from timeit import default_timer
@@ -626,6 +628,37 @@ def test_basic_metric_success(self):
                             )
                             self.assertEqual(point.value, 0)
 
+    def test_metric_target_attribute(self):
+        expected_target = "/api/user/{id}"
+
+        class TestRoute:
+            path_format = expected_target
+
+        self.scope["route"] = TestRoute()
+        app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
+        self.seed_app(app)
+        self.send_default_request()
+
+        metrics_list = self.memory_metrics_reader.get_metrics_data()
+        assertions = 0
+        for resource_metric in metrics_list.resource_metrics:
+            for scope_metrics in resource_metric.scope_metrics:
+                for metric in scope_metrics.metrics:
+                    for point in metric.data.data_points:
+                        if isinstance(point, HistogramDataPoint):
+                            self.assertEqual(
+                                point.attributes["http.target"],
+                                expected_target,
+                            )
+                            assertions += 1
+                        elif isinstance(point, NumberDataPoint):
+                            self.assertEqual(
+                                point.attributes["http.target"],
+                                expected_target,
+                            )
+                            assertions += 1
+        self.assertEqual(assertions, 2)
+
     def test_no_metric_for_websockets(self):
         self.scope = {
             "type": "websocket",
@@ -719,6 +752,37 @@ def test_credential_removal(self):
             attrs[SpanAttributes.HTTP_URL], "http://httpbin.org/status/200"
         )
 
+    def test_collect_target_attribute_missing(self):
+        self.assertIsNone(otel_asgi._collect_target_attribute(self.scope))
+
+    def test_collect_target_attribute_fastapi(self):
+        class TestRoute:
+            path_format = "/api/users/{user_id}"
+
+        self.scope["route"] = TestRoute()
+        self.assertEqual(
+            otel_asgi._collect_target_attribute(self.scope),
+            "/api/users/{user_id}",
+        )
+
+    def test_collect_target_attribute_fastapi_mounted(self):
+        class TestRoute:
+            path_format = "/users/{user_id}"
+
+        self.scope["route"] = TestRoute()
+        self.scope["root_path"] = "/api/v2"
+        self.assertEqual(
+            otel_asgi._collect_target_attribute(self.scope),
+            "/api/v2/users/{user_id}",
+        )
+
+    def test_collect_target_attribute_fastapi_starlette_invalid(self):
+        self.scope["route"] = object()
+        self.assertIsNone(
+            otel_asgi._collect_target_attribute(self.scope),
+            "HTTP_TARGET values is not None",
+        )
+
 
 class TestWrappedApplication(AsgiTestBase):
     def test_mark_span_internal_in_presence_of_span_from_other_framework(self):