Skip to content

Commit 086213c

Browse files
committed
feat(instrumentation/asgi): add target to metrics
This PR adds the target information for metrics reported by instrumentation/asgi. Unfortunately, there's no ASGI standard to reliably get this information, and I was only able to get it for FastAPI. I also tried to get the info with Sanic and Starlette (encode/starlette#685), but there's nothing in the scope allowing to recreate the route. Besides the included unit tests, the logic was tested using the following app: ```python import io import fastapi app = fastapi.FastAPI() def dump_scope(scope): b = io.StringIO() print(scope, file=b) return b.getvalue() @app.get("/test/{id}") def test(id: str, req: fastapi.Request): print(req.scope) return {"target": _collect_target_attribute(req.scope), "scope": dump_scope(req.scope)} sub_app = fastapi.FastAPI() @sub_app.get("/test/{id}") def sub_test(id: str, req: fastapi.Request): print(req.scope) return {"target": _collect_target_attribute(req.scope), "scope": dump_scope(req.scope)} app.mount("/sub", sub_app) ``` Partially fixes #1116 Note to reviewers: I tried to touch as less as possible, so that we don;t require a refactor before this change. However, we could consider changing `collect_request_attributes` so that it returns both a trace attributes and a metrics attributes. Wihout that change we cannot add the `HTTP_TARGET` attribute to the list of metric atttributes, because it will be present but with high cardinality.
1 parent 4c23823 commit 086213c

File tree

2 files changed

+95
-0
lines changed

2 files changed

+95
-0
lines changed

instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py

+34
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,34 @@ def get_default_span_details(scope: dict) -> Tuple[str, dict]:
365365
return span_name, {}
366366

367367

368+
def _collect_target_attribute(
369+
scope: typing.Dict[str, typing.Any]
370+
) -> typing.Optional[str]:
371+
"""
372+
Returns the target path as defined by the Semantic Conventions.
373+
374+
This value is suitable to use in metrics as it should replace concrete
375+
values with a parameterized name. Example: /api/users/{user_id}
376+
377+
Refer to the specification
378+
https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/semantic_conventions/http-metrics.md#parameterized-attributes
379+
380+
Note: this function requires specific code for each framework, as there's no
381+
standard attribute to use.
382+
"""
383+
# FastAPI
384+
root_path = scope.get("root_path", "")
385+
386+
route = scope.get("route")
387+
if not route:
388+
return None
389+
path_format = getattr(route, "path_format", None)
390+
if path_format:
391+
return f"{root_path}{path_format}"
392+
393+
return None
394+
395+
368396
class OpenTelemetryMiddleware:
369397
"""The ASGI application middleware.
370398
@@ -448,6 +476,12 @@ async def __call__(self, scope, receive, send):
448476
attributes
449477
)
450478
duration_attrs = _parse_duration_attrs(attributes)
479+
480+
target = _collect_target_attribute(scope)
481+
if target:
482+
active_requests_count_attrs[SpanAttributes.HTTP_TARGET] = target
483+
duration_attrs[SpanAttributes.HTTP_TARGET] = target
484+
451485
if scope["type"] == "http":
452486
self.active_requests_counter.add(1, active_requests_count_attrs)
453487
try:

instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py

+61
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,37 @@ def test_basic_metric_success(self):
612612
)
613613
self.assertEqual(point.value, 0)
614614

615+
def test_metric_target_attribute(self):
616+
expected_target = "/api/user/{id}"
617+
618+
class TestRoute:
619+
path_format = expected_target
620+
621+
self.scope["route"] = TestRoute()
622+
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
623+
self.seed_app(app)
624+
self.send_default_request()
625+
626+
metrics_list = self.memory_metrics_reader.get_metrics_data()
627+
assertions = 0
628+
for resource_metric in metrics_list.resource_metrics:
629+
for scope_metrics in resource_metric.scope_metrics:
630+
for metric in scope_metrics.metrics:
631+
for point in metric.data.data_points:
632+
if isinstance(point, HistogramDataPoint):
633+
self.assertEqual(
634+
point.attributes["http.target"],
635+
expected_target,
636+
)
637+
assertions += 1
638+
elif isinstance(point, NumberDataPoint):
639+
self.assertEqual(
640+
point.attributes["http.target"],
641+
expected_target,
642+
)
643+
assertions += 1
644+
self.assertEqual(assertions, 2)
645+
615646
def test_no_metric_for_websockets(self):
616647
self.scope = {
617648
"type": "websocket",
@@ -705,6 +736,36 @@ def test_credential_removal(self):
705736
attrs[SpanAttributes.HTTP_URL], "http://httpbin.org/status/200"
706737
)
707738

739+
def test_collect_target_attribute_missing(self):
740+
self.assertIsNone(otel_asgi._collect_target_attribute(self.scope))
741+
742+
def test_collect_target_attribute_fastapi(self):
743+
class TestRoute:
744+
path_format = "/api/users/{user_id}"
745+
746+
self.scope["route"] = TestRoute()
747+
self.assertEqual(
748+
otel_asgi._collect_target_attribute(self.scope),
749+
"/api/users/{user_id}",
750+
)
751+
752+
def test_collect_target_attribute_fastapi_mounted(self):
753+
class TestRoute:
754+
path_format = "/users/{user_id}"
755+
756+
self.scope["route"] = TestRoute()
757+
self.scope["root_path"] = "/api/v2"
758+
self.assertEqual(
759+
otel_asgi._collect_target_attribute(self.scope),
760+
"/api/v2/users/{user_id}",
761+
)
762+
763+
def test_collect_target_attribute_fastapi_starlette_invalid(self):
764+
self.scope["route"] = object()
765+
self.assertIsNone(
766+
otel_asgi._collect_target_attribute(self.scope), None
767+
)
768+
708769

709770
class TestWrappedApplication(AsgiTestBase):
710771
def test_mark_span_internal_in_presence_of_span_from_other_framework(self):

0 commit comments

Comments
 (0)