diff --git a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py index 21e8a189b5..fab9bbafd9 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py @@ -365,6 +365,34 @@ 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") + if not route: + return None + path_format = getattr(route, "path_format", None) + if path_format: + return f"{root_path}{path_format}" + + return None + + class OpenTelemetryMiddleware: """The ASGI application middleware. @@ -448,6 +476,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: diff --git a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py index e6b75d7125..04e211bbd3 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py @@ -612,6 +612,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", @@ -705,6 +736,36 @@ 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), None + ) + class TestWrappedApplication(AsgiTestBase): def test_mark_span_internal_in_presence_of_span_from_other_framework(self):