Skip to content

Commit e955c20

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

File tree

4 files changed

+348
-3
lines changed

4 files changed

+348
-3
lines changed

Diff for: CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ 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 sub apps
44+
([#2477](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2477))
4245
- `opentelemetry-instrumentation-grpc` AioClientInterceptor should propagate with a Metadata object
4346
([#2363](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2363))
4447
- `opentelemetry-instrumentation-boto3sqs` Instrument Session and resource

Diff for: 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

Diff for: instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py

+154
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
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
1415

1516
import unittest
1617
from timeit import default_timer
@@ -310,9 +311,56 @@ def test_metric_uninstrument(self):
310311
if isinstance(point, NumberDataPoint):
311312
self.assertEqual(point.value, 0)
312313

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

317365
@app.get("/foobar")
318366
async def _():
@@ -330,6 +378,8 @@ async def _(param: str):
330378
async def _():
331379
return {"message": "ok"}
332380

381+
app.mount("/sub", app=sub_app)
382+
333383
return app
334384

335385

@@ -444,6 +494,58 @@ def tearDown(self):
444494
self._instrumentor.uninstrument()
445495
super().tearDown()
446496

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

448550
class TestAutoInstrumentationHooks(TestFastAPIManualInstrumentationHooks):
449551
"""
@@ -485,6 +587,58 @@ def tearDown(self):
485587
self._instrumentor.uninstrument()
486588
super().tearDown()
487589

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

489643
class TestAutoInstrumentationLogic(unittest.TestCase):
490644
def test_instrumentation(self):

0 commit comments

Comments
 (0)