Skip to content

Commit 29567ed

Browse files
committed
fix(opentelemetry-instrumentation-asgi): Correct http.target attribute generation even with sub apps (fixes open-telemetry#2476)
- modify the instrumentation logic - add unittests on starlette instrumentation - add unittests on fastapi instrumentation
1 parent 3291f38 commit 29567ed

File tree

4 files changed

+346
-3
lines changed

4 files changed

+346
-3
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939

4040
### Fixed
4141

42+
- `opentelemetry-instrumentation-asgi` Fix generation of `http.target` and `http.url` attributes for ASGI apps
43+
using subapps (todo: reference pull request)
4244
- `opentelemetry-instrumentation-grpc` AioClientInterceptor should propagate with a Metadata object
4345
([#2363](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2363))
4446
- `opentelemetry-instrumentation-boto3sqs` Instrument Session and resource

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

+13-1
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,19 @@ def get_host_port_url_tuple(scope):
370370
server = scope.get("server") or ["0.0.0.0", 80]
371371
port = server[1]
372372
server_host = server[0] + (":" + str(port) if str(port) != "80" else "")
373-
full_path = scope.get("root_path", "") + scope.get("path", "")
373+
# To get the correct virtual url path within the hosting application (e.g also in a subapplication scenario)
374+
# we have to remove the root_path from the path
375+
# see:
376+
# - https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope (see: root_path and path)
377+
# - https://asgi.readthedocs.io/en/latest/specs/www.html#wsgi-compatibility (see: PATH_INFO)
378+
# PATH_INFO can be derived by stripping root_path from path
379+
# -> that means that the path should contain the root_path already, so prefixing it again is not necessary
380+
# - https://wsgi.readthedocs.io/en/latest/definitions.html#envvar-PATH_INFO
381+
#
382+
# From investigation it seems (that at least for fastapi), the path is already correctly set. That means
383+
# that root_path is already included in the path, so we can use it directly for full path.
384+
# old way: full_path = scope.get("root_path", "") + scope.get("path", "")
385+
full_path = scope.get("path", "")
374386
http_url = scope.get("scheme", "http") + "://" + server_host + full_path
375387
return server_host, port, http_url
376388

instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py

+153
Original file line numberDiff line numberDiff line change
@@ -310,9 +310,56 @@ def test_metric_uninstrument(self):
310310
if isinstance(point, NumberDataPoint):
311311
self.assertEqual(point.value, 0)
312312

313+
def test_sub_app_fastapi_call(self):
314+
"""
315+
This test is to ensure that a span in case of a sub app targetted contains the correct server url
316+
317+
As this test case covers manual instrumentation, we won't see any additional spans for the sub app.
318+
In this case all generated spans might suffice the requirements for the attributes already
319+
(as the testcase is not setting a root_path for the outer app here)
320+
"""
321+
322+
self._client.get("/sub/home")
323+
spans = self.memory_exporter.get_finished_spans()
324+
self.assertEqual(len(spans), 3)
325+
for span in spans:
326+
# As we are only looking to the "outer" app, we would see only the "GET /sub" spans
327+
self.assertIn("GET /sub", span.name)
328+
329+
# We now want to specifically test all spans including the
330+
# - HTTP_TARGET
331+
# - HTTP_URL
332+
# attributes to be populated with the expected values
333+
spans_with_http_attributes = [
334+
span
335+
for span in spans
336+
if (
337+
SpanAttributes.HTTP_URL in span.attributes
338+
or SpanAttributes.HTTP_TARGET in span.attributes
339+
)
340+
]
341+
342+
# We expect only one span to have the HTTP attributes set (the SERVER span from the app itself)
343+
# the sub app is not instrumented with manual instrumentation tests.
344+
self.assertEqual(1, len(spans_with_http_attributes))
345+
346+
for span in spans_with_http_attributes:
347+
self.assertEqual(
348+
"/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
349+
)
350+
self.assertEqual(
351+
"https://testserver:443/sub/home",
352+
span.attributes[SpanAttributes.HTTP_URL],
353+
)
354+
313355
@staticmethod
314356
def _create_fastapi_app():
315357
app = fastapi.FastAPI()
358+
sub_app = fastapi.FastAPI()
359+
360+
@sub_app.get("/home")
361+
async def _():
362+
return {"message": "sub hi"}
316363

317364
@app.get("/foobar")
318365
async def _():
@@ -330,6 +377,8 @@ async def _(param: str):
330377
async def _():
331378
return {"message": "ok"}
332379

380+
app.mount("/sub", app=sub_app)
381+
333382
return app
334383

335384

@@ -444,6 +493,58 @@ def tearDown(self):
444493
self._instrumentor.uninstrument()
445494
super().tearDown()
446495

496+
def test_sub_app_fastapi_call(self):
497+
"""
498+
!!! Attention: we need to override this testcase for the auto-instrumented variant
499+
The reason is, that with auto instrumentation, the sub app is instrumented as well
500+
and therefore we would see the spans for the sub app as well
501+
502+
This test is to ensure that a span in case of a sub app targeted contains the correct server url
503+
"""
504+
505+
self._client.get("/sub/home")
506+
spans = self.memory_exporter.get_finished_spans()
507+
self.assertEqual(len(spans), 6)
508+
509+
for span in spans:
510+
# As we are only looking to the "outer" app, we would see only the "GET /sub" spans
511+
# -> the outer app is not aware of the sub_apps internal routes
512+
sub_in = "GET /sub" in span.name
513+
# The sub app spans are named GET /home as from the sub app perspective the request targets /home
514+
# -> the sub app is technically not aware of the /sub prefix
515+
home_in = "GET /home" in span.name
516+
517+
# We expect the spans to be either from the outer app or the sub app
518+
self.assertTrue(
519+
sub_in or home_in,
520+
f"Span {span.name} does not have /sub or /home in its name",
521+
)
522+
523+
# We now want to specifically test all spans including the
524+
# - HTTP_TARGET
525+
# - HTTP_URL
526+
# attributes to be populated with the expected values
527+
spans_with_http_attributes = [
528+
span
529+
for span in spans
530+
if (
531+
SpanAttributes.HTTP_URL in span.attributes
532+
or SpanAttributes.HTTP_TARGET in span.attributes
533+
)
534+
]
535+
536+
# We now expect spans with attributes from both the app and its sub app
537+
self.assertEqual(2, len(spans_with_http_attributes))
538+
539+
for span in spans_with_http_attributes:
540+
self.assertEqual(
541+
"/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
542+
)
543+
self.assertEqual(
544+
"https://testserver:443/sub/home",
545+
span.attributes[SpanAttributes.HTTP_URL],
546+
)
547+
447548

448549
class TestAutoInstrumentationHooks(TestFastAPIManualInstrumentationHooks):
449550
"""
@@ -485,6 +586,58 @@ def tearDown(self):
485586
self._instrumentor.uninstrument()
486587
super().tearDown()
487588

589+
def test_sub_app_fastapi_call(self):
590+
"""
591+
!!! Attention: we need to override this testcase for the auto-instrumented variant
592+
The reason is, that with auto instrumentation, the sub app is instrumented as well
593+
and therefore we would see the spans for the sub app as well
594+
595+
This test is to ensure that a span in case of a sub app targeted contains the correct server url
596+
"""
597+
598+
self._client.get("/sub/home")
599+
spans = self.memory_exporter.get_finished_spans()
600+
self.assertEqual(len(spans), 6)
601+
602+
for span in spans:
603+
# As we are only looking to the "outer" app, we would see only the "GET /sub" spans
604+
# -> the outer app is not aware of the sub_apps internal routes
605+
sub_in = "GET /sub" in span.name
606+
# The sub app spans are named GET /home as from the sub app perspective the request targets /home
607+
# -> the sub app is technically not aware of the /sub prefix
608+
home_in = "GET /home" in span.name
609+
610+
# We expect the spans to be either from the outer app or the sub app
611+
self.assertTrue(
612+
sub_in or home_in,
613+
f"Span {span.name} does not have /sub or /home in its name",
614+
)
615+
616+
# We now want to specifically test all spans including the
617+
# - HTTP_TARGET
618+
# - HTTP_URL
619+
# attributes to be populated with the expected values
620+
spans_with_http_attributes = [
621+
span
622+
for span in spans
623+
if (
624+
SpanAttributes.HTTP_URL in span.attributes
625+
or SpanAttributes.HTTP_TARGET in span.attributes
626+
)
627+
]
628+
629+
# We now expect spans with attributes from both the app and its sub app
630+
self.assertEqual(2, len(spans_with_http_attributes))
631+
632+
for span in spans_with_http_attributes:
633+
self.assertEqual(
634+
"/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
635+
)
636+
self.assertEqual(
637+
"https://testserver:443/sub/home",
638+
span.attributes[SpanAttributes.HTTP_URL],
639+
)
640+
488641

489642
class TestAutoInstrumentationLogic(unittest.TestCase):
490643
def test_instrumentation(self):

0 commit comments

Comments
 (0)