Skip to content

Commit 4fb9fc3

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 529178d commit 4fb9fc3

File tree

4 files changed

+349
-3
lines changed

4 files changed

+349
-3
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8282

8383
- `opentelemetry-instrumentation-dbapi` Fix compatibility with Psycopg3 to extract libpq build version
8484
([#2500](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2500))
85+
- `opentelemetry-instrumentation-asgi` Fix generation of `http.target` and `http.url` attributes for ASGI apps
86+
using sub apps
87+
([#2477](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2477))
8588
- `opentelemetry-instrumentation-grpc` AioClientInterceptor should propagate with a Metadata object
8689
([#2363](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2363))
8790
- `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
@@ -367,7 +367,19 @@ def get_host_port_url_tuple(scope):
367367
server = scope.get("server") or ["0.0.0.0", 80]
368368
port = server[1]
369369
server_host = server[0] + (":" + str(port) if str(port) != "80" else "")
370-
full_path = scope.get("root_path", "") + scope.get("path", "")
370+
# To get the correct virtual url path within the hosting application (e.g also in a subapplication scenario)
371+
# we have to remove the root_path from the path
372+
# see:
373+
# - https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope (see: root_path and path)
374+
# - https://asgi.readthedocs.io/en/latest/specs/www.html#wsgi-compatibility (see: PATH_INFO)
375+
# PATH_INFO can be derived by stripping root_path from path
376+
# -> that means that the path should contain the root_path already, so prefixing it again is not necessary
377+
# - https://wsgi.readthedocs.io/en/latest/definitions.html#envvar-PATH_INFO
378+
#
379+
# From investigation it seems (that at least for fastapi), the path is already correctly set. That means
380+
# that root_path is already included in the path, so we can use it directly for full path.
381+
# old way: full_path = scope.get("root_path", "") + scope.get("path", "")
382+
full_path = scope.get("path", "")
371383
http_url = scope.get("scheme", "http") + "://" + server_host + full_path
372384
return server_host, port, http_url
373385

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

+155
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
# pylint: disable=too-many-lines
15+
1416
import unittest
1517
from collections.abc import Mapping
1618
from timeit import default_timer
@@ -319,9 +321,56 @@ def test_metric_uninstrument(self):
319321
if isinstance(point, NumberDataPoint):
320322
self.assertEqual(point.value, 0)
321323

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

326375
@app.get("/foobar")
327376
async def _():
@@ -339,6 +388,8 @@ async def _(param: str):
339388
async def _():
340389
return {"message": "ok"}
341390

391+
app.mount("/sub", app=sub_app)
392+
342393
return app
343394

344395

@@ -453,6 +504,58 @@ def tearDown(self):
453504
self._instrumentor.uninstrument()
454505
super().tearDown()
455506

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

457560
class TestAutoInstrumentationHooks(TestFastAPIManualInstrumentationHooks):
458561
"""
@@ -494,6 +597,58 @@ def tearDown(self):
494597
self._instrumentor.uninstrument()
495598
super().tearDown()
496599

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

498653
class TestAutoInstrumentationLogic(unittest.TestCase):
499654
def test_instrumentation(self):

0 commit comments

Comments
 (0)