Skip to content

Commit 915165b

Browse files
davidgss04tammy-baylis-swiaabmass
authored
fix open-telemetry#4251: wrap BackgroundTasks in their own span (open-telemetry#4368)
* fix open-telemetry#4251: wrap BackgroundTasks in their own span Only the request/ASGI middleware was traced; background tasks were not. Background tasks ran after the response with no span, causing child spans to attach to a closed parent span. This change patches BackgroundTask.__call__ to wrap execution in a new span. Each background task now creates a child span of the request, ensuring a correct trace hierarchy. Add three regression tests for background task instrumentation. * Entry added to CHANGELOG.md linking to PR open-telemetry#4368 --------- Co-authored-by: Tammy Baylis <96076570+tammy-baylis-swi@users.noreply.github.com> Co-authored-by: Aaron Abbott <aaronabbott@google.com>
1 parent c41568f commit 915165b

3 files changed

Lines changed: 108 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9393
([#4302](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4302))
9494
- `opentelemetry-instrumentation-grpc`: Fix bidirectional streaming RPCs raising `AttributeError: 'generator' object has no attribute 'add_done_callback'`
9595
([#4259](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4259))
96+
- `opentelemetry-instrumentation-fastapi`: Fix `FastAPI` instrumentation to correctly trace `BackgroundTasks` by wrapping their execution in a dedicated span, ensuring proper parent-child relationships and accurate trace timing
97+
([#4368](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4368))
9698
- `opentelemetry-instrumentation-aiokafka`: fix `Unclosed AIOKafkaProducer` warning and `RuntimeWarning: coroutine was never awaited` in tests
9799
([#4384](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4384))
98100
- `opentelemetry-instrumentation-aiokafka`: Fix compatibility with aiokafka 0.13 by calling

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
179179

180180
import fastapi
181181
from starlette.applications import Starlette
182+
from starlette.background import BackgroundTask
182183
from starlette.middleware.errors import ServerErrorMiddleware
183184
from starlette.routing import Match, Route
184185
from starlette.types import ASGIApp, Receive, Scope, Send
@@ -388,6 +389,16 @@ async def __call__(
388389
app,
389390
)
390391

392+
if not hasattr(BackgroundTask, "_otel_original_call"):
393+
BackgroundTask._otel_original_call = BackgroundTask.__call__
394+
395+
async def traced_call(self):
396+
span_name = f"BackgroundTask {getattr(self.func, '__name__', self.func.__class__.__name__)}"
397+
with tracer.start_as_current_span(span_name):
398+
return await BackgroundTask._otel_original_call(self)
399+
400+
BackgroundTask.__call__ = traced_call
401+
391402
app._is_instrumented_by_opentelemetry = True
392403
if app not in _InstrumentedFastAPI._instrumented_fastapi_apps:
393404
_InstrumentedFastAPI._instrumented_fastapi_apps.add(app)
@@ -405,6 +416,11 @@ def uninstrument_app(app: fastapi.FastAPI):
405416
app.build_middleware_stack = original_build_middleware_stack
406417
del app._original_build_middleware_stack
407418
app.middleware_stack = app.build_middleware_stack()
419+
420+
if hasattr(BackgroundTask, "_otel_original_call"):
421+
BackgroundTask.__call__ = BackgroundTask._otel_original_call
422+
del BackgroundTask._otel_original_call
423+
408424
app._is_instrumented_by_opentelemetry = False
409425

410426
# Remove the app from the set of instrumented apps to avoid calling uninstrument twice

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414

1515
import fastapi
1616
import pytest
17+
from fastapi.background import BackgroundTasks
1718
from fastapi.middleware.asyncexitstack import AsyncExitStackMiddleware
1819
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
1920
from fastapi.responses import JSONResponse, PlainTextResponse
2021
from fastapi.routing import APIRoute
2122
from fastapi.testclient import TestClient
23+
from starlette.background import BackgroundTask
2224
from starlette.routing import Match
2325
from starlette.types import Receive, Scope, Send
2426

@@ -482,6 +484,51 @@ def test_basic_fastapi_call(self):
482484
for span in spans:
483485
self.assertIn("GET /foobar", span.name)
484486

487+
def test_background_task_span_parents_inner_spans(self):
488+
"""Regression test for #4251: spans created inside a FastAPI
489+
BackgroundTask must be children of a dedicated background-task span
490+
instead of the already-closed request span."""
491+
self.memory_exporter.clear()
492+
app = fastapi.FastAPI()
493+
self._instrumentor.instrument_app(app)
494+
tracer = self.tracer_provider.get_tracer(__name__)
495+
496+
async def background_notify():
497+
with tracer.start_as_current_span("inside-background-task"):
498+
pass
499+
500+
@app.post("/checkout")
501+
async def checkout(background_tasks: BackgroundTasks):
502+
background_tasks.add_task(background_notify)
503+
return {"status": "processing"}
504+
505+
with TestClient(app) as client:
506+
response = client.post("/checkout")
507+
self.assertEqual(200, response.status_code)
508+
spans = self.memory_exporter.get_finished_spans()
509+
request_span = next(
510+
span for span in spans if span.name == "POST /checkout"
511+
)
512+
background_span = next(
513+
span
514+
for span in spans
515+
if span.name == "BackgroundTask background_notify"
516+
)
517+
inner_span = next(
518+
span for span in spans if span.name == "inside-background-task"
519+
)
520+
self.assertIsNotNone(background_span.parent)
521+
self.assertEqual(
522+
background_span.parent.span_id,
523+
request_span.context.span_id,
524+
)
525+
self.assertIsNotNone(inner_span.parent)
526+
self.assertEqual(
527+
inner_span.parent.span_id,
528+
background_span.context.span_id,
529+
)
530+
otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)
531+
485532
def test_fastapi_route_attribute_added(self):
486533
"""Ensure that fastapi routes are used as the span name."""
487534
self._client.get("/user/123")
@@ -977,6 +1024,49 @@ def test_basic_post_request_metric_success_both_semconv(self):
9771024
if isinstance(point, NumberDataPoint):
9781025
self.assertEqual(point.value, 0)
9791026

1027+
def test_uninstrument_app_restores_background_task_call(self):
1028+
"""Regression test for #4251: uninstrumentation must restore the
1029+
original BackgroundTask.__call__ after FastAPI patches it."""
1030+
self.assertTrue(hasattr(BackgroundTask, "_otel_original_call"))
1031+
self._instrumentor.uninstrument_app(self._app)
1032+
self.assertFalse(hasattr(BackgroundTask, "_otel_original_call"))
1033+
1034+
def test_background_task_span_not_duplicated_on_double_instrument_app(
1035+
self,
1036+
):
1037+
"""Regression test for #4251: repeated instrument_app calls must not
1038+
wrap BackgroundTask.__call__ multiple times or duplicate spans."""
1039+
self.memory_exporter.clear()
1040+
app = fastapi.FastAPI()
1041+
self._instrumentor.instrument_app(app)
1042+
self._instrumentor.instrument_app(app)
1043+
tracer = self.tracer_provider.get_tracer(__name__)
1044+
1045+
async def background_notify():
1046+
with tracer.start_as_current_span("inside-background-task"):
1047+
pass
1048+
1049+
@app.post("/checkout")
1050+
async def checkout(background_tasks: BackgroundTasks):
1051+
background_tasks.add_task(background_notify)
1052+
return {"status": "processing"}
1053+
1054+
with TestClient(app) as client:
1055+
response = client.post("/checkout")
1056+
self.assertEqual(200, response.status_code)
1057+
spans = self.memory_exporter.get_finished_spans()
1058+
background_spans = [
1059+
span
1060+
for span in spans
1061+
if span.name == "BackgroundTask background_notify"
1062+
]
1063+
inner_spans = [
1064+
span for span in spans if span.name == "inside-background-task"
1065+
]
1066+
self.assertEqual(len(background_spans), 1)
1067+
self.assertEqual(len(inner_spans), 1)
1068+
otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)
1069+
9801070
def test_metric_uninstrument_app(self):
9811071
self._client.get("/foobar")
9821072
self._instrumentor.uninstrument_app(self._app)

0 commit comments

Comments
 (0)