|
25 | 25 |
|
26 | 26 | import fastapi |
27 | 27 | import pytest |
| 28 | +from fastapi.background import BackgroundTasks |
28 | 29 | from fastapi.middleware.asyncexitstack import AsyncExitStackMiddleware |
29 | 30 | from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware |
30 | 31 | from fastapi.responses import JSONResponse, PlainTextResponse |
31 | 32 | from fastapi.routing import APIRoute |
32 | 33 | from fastapi.testclient import TestClient |
| 34 | +from starlette.background import BackgroundTask |
33 | 35 | from starlette.routing import Match |
34 | 36 | from starlette.types import Receive, Scope, Send |
35 | 37 |
|
@@ -493,6 +495,51 @@ def test_basic_fastapi_call(self): |
493 | 495 | for span in spans: |
494 | 496 | self.assertIn("GET /foobar", span.name) |
495 | 497 |
|
| 498 | + def test_background_task_span_parents_inner_spans(self): |
| 499 | + """Regression test for #4251: spans created inside a FastAPI |
| 500 | + BackgroundTask must be children of a dedicated background-task span |
| 501 | + instead of the already-closed request span.""" |
| 502 | + self.memory_exporter.clear() |
| 503 | + app = fastapi.FastAPI() |
| 504 | + self._instrumentor.instrument_app(app) |
| 505 | + tracer = self.tracer_provider.get_tracer(__name__) |
| 506 | + |
| 507 | + async def background_notify(): |
| 508 | + with tracer.start_as_current_span("inside-background-task"): |
| 509 | + pass |
| 510 | + |
| 511 | + @app.post("/checkout") |
| 512 | + async def checkout(background_tasks: BackgroundTasks): |
| 513 | + background_tasks.add_task(background_notify) |
| 514 | + return {"status": "processing"} |
| 515 | + |
| 516 | + with TestClient(app) as client: |
| 517 | + response = client.post("/checkout") |
| 518 | + self.assertEqual(200, response.status_code) |
| 519 | + spans = self.memory_exporter.get_finished_spans() |
| 520 | + request_span = next( |
| 521 | + span for span in spans if span.name == "POST /checkout" |
| 522 | + ) |
| 523 | + background_span = next( |
| 524 | + span |
| 525 | + for span in spans |
| 526 | + if span.name == "BackgroundTask background_notify" |
| 527 | + ) |
| 528 | + inner_span = next( |
| 529 | + span for span in spans if span.name == "inside-background-task" |
| 530 | + ) |
| 531 | + self.assertIsNotNone(background_span.parent) |
| 532 | + self.assertEqual( |
| 533 | + background_span.parent.span_id, |
| 534 | + request_span.context.span_id, |
| 535 | + ) |
| 536 | + self.assertIsNotNone(inner_span.parent) |
| 537 | + self.assertEqual( |
| 538 | + inner_span.parent.span_id, |
| 539 | + background_span.context.span_id, |
| 540 | + ) |
| 541 | + otel_fastapi.FastAPIInstrumentor().uninstrument_app(app) |
| 542 | + |
496 | 543 | def test_fastapi_route_attribute_added(self): |
497 | 544 | """Ensure that fastapi routes are used as the span name.""" |
498 | 545 | self._client.get("/user/123") |
@@ -988,6 +1035,49 @@ def test_basic_post_request_metric_success_both_semconv(self): |
988 | 1035 | if isinstance(point, NumberDataPoint): |
989 | 1036 | self.assertEqual(point.value, 0) |
990 | 1037 |
|
| 1038 | + def test_uninstrument_app_restores_background_task_call(self): |
| 1039 | + """Regression test for #4251: uninstrumentation must restore the |
| 1040 | + original BackgroundTask.__call__ after FastAPI patches it.""" |
| 1041 | + self.assertTrue(hasattr(BackgroundTask, "_otel_original_call")) |
| 1042 | + self._instrumentor.uninstrument_app(self._app) |
| 1043 | + self.assertFalse(hasattr(BackgroundTask, "_otel_original_call")) |
| 1044 | + |
| 1045 | + def test_background_task_span_not_duplicated_on_double_instrument_app( |
| 1046 | + self, |
| 1047 | + ): |
| 1048 | + """Regression test for #4251: repeated instrument_app calls must not |
| 1049 | + wrap BackgroundTask.__call__ multiple times or duplicate spans.""" |
| 1050 | + self.memory_exporter.clear() |
| 1051 | + app = fastapi.FastAPI() |
| 1052 | + self._instrumentor.instrument_app(app) |
| 1053 | + self._instrumentor.instrument_app(app) |
| 1054 | + tracer = self.tracer_provider.get_tracer(__name__) |
| 1055 | + |
| 1056 | + async def background_notify(): |
| 1057 | + with tracer.start_as_current_span("inside-background-task"): |
| 1058 | + pass |
| 1059 | + |
| 1060 | + @app.post("/checkout") |
| 1061 | + async def checkout(background_tasks: BackgroundTasks): |
| 1062 | + background_tasks.add_task(background_notify) |
| 1063 | + return {"status": "processing"} |
| 1064 | + |
| 1065 | + with TestClient(app) as client: |
| 1066 | + response = client.post("/checkout") |
| 1067 | + self.assertEqual(200, response.status_code) |
| 1068 | + spans = self.memory_exporter.get_finished_spans() |
| 1069 | + background_spans = [ |
| 1070 | + span |
| 1071 | + for span in spans |
| 1072 | + if span.name == "BackgroundTask background_notify" |
| 1073 | + ] |
| 1074 | + inner_spans = [ |
| 1075 | + span for span in spans if span.name == "inside-background-task" |
| 1076 | + ] |
| 1077 | + self.assertEqual(len(background_spans), 1) |
| 1078 | + self.assertEqual(len(inner_spans), 1) |
| 1079 | + otel_fastapi.FastAPIInstrumentor().uninstrument_app(app) |
| 1080 | + |
991 | 1081 | def test_metric_uninstrument_app(self): |
992 | 1082 | self._client.get("/foobar") |
993 | 1083 | self._instrumentor.uninstrument_app(self._app) |
|
0 commit comments