Skip to content

Commit 5fa222f

Browse files
artemziborevemdnetoxrmx
authored
fix: fastapi memory leak only (#3688)
* fix(fastapi-instrumentation): properly remove app from instrumented list to avoid memory leaks * fix(fastapi-instrumentation): fix tests and finalize memory leak fix * docs(changelog): add FastAPI uninstrument memory leak fix with PR references (#3683) * test(fastapi): add GC-based app collection test; refactor tracking to WeakSet and safe discard * docs(changelog): reference FastAPI memory leak fix PR (#3688) * refactor(fastapi): drop __del__ as WeakSet handles cleanup * chore(fastapi): formatting after ruff auto-fix * chore(test-fastapi): ruff import order * Update comment to clarify purpose of removing app from WeakSet * fix: codereview comments * Apply suggestions from code review --------- Co-authored-by: Emídio Neto <[email protected]> Co-authored-by: Riccardo Magliocchetti <[email protected]>
1 parent 3567a03 commit 5fa222f

File tree

3 files changed

+27
-6
lines changed

3 files changed

+27
-6
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Fixed
1515

16+
- `opentelemetry-instrumentation-fastapi`: Fix memory leak in `uninstrument_app()` by properly removing apps from the tracking set
17+
([#3688](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3688)
1618
- `opentelemetry-instrumentation-tornado` Fix server (request) duration metric calculation
1719
([#3679](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3679))
1820
- `opentelemetry-instrumentation`: Avoid calls to `context.detach` with `None` token.

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
186186
import logging
187187
import types
188188
from typing import Collection, Literal
189+
from weakref import WeakSet as _WeakSet
189190

190191
import fastapi
191192
from starlette.applications import Starlette
@@ -358,6 +359,11 @@ def uninstrument_app(app: fastapi.FastAPI):
358359
app.middleware_stack = app.build_middleware_stack()
359360
app._is_instrumented_by_opentelemetry = False
360361

362+
# Remove the app from the set of instrumented apps to avoid calling uninstrument twice
363+
# if the instrumentation is later disabled or such
364+
# Use discard to avoid KeyError if already GC'ed
365+
_InstrumentedFastAPI._instrumented_fastapi_apps.discard(app)
366+
361367
def instrumentation_dependencies(self) -> Collection[str]:
362368
return _instruments
363369

@@ -388,7 +394,11 @@ def _instrument(self, **kwargs):
388394
fastapi.FastAPI = _InstrumentedFastAPI
389395

390396
def _uninstrument(self, **kwargs):
391-
for instance in _InstrumentedFastAPI._instrumented_fastapi_apps:
397+
# Create a copy of the set to avoid RuntimeError during iteration
398+
instances_to_uninstrument = list(
399+
_InstrumentedFastAPI._instrumented_fastapi_apps
400+
)
401+
for instance in instances_to_uninstrument:
392402
self.uninstrument_app(instance)
393403
_InstrumentedFastAPI._instrumented_fastapi_apps.clear()
394404
fastapi.FastAPI = self._original_fastapi
@@ -406,7 +416,8 @@ class _InstrumentedFastAPI(fastapi.FastAPI):
406416
_http_capture_headers_sanitize_fields: list[str] | None = None
407417
_exclude_spans: list[Literal["receive", "send"]] | None = None
408418

409-
_instrumented_fastapi_apps = set()
419+
# Track instrumented app instances using weak references to avoid GC leaks
420+
_instrumented_fastapi_apps = _WeakSet()
410421
_sem_conv_opt_in_mode = _StabilityMode.DEFAULT
411422

412423
def __init__(self, *args, **kwargs):
@@ -426,10 +437,6 @@ def __init__(self, *args, **kwargs):
426437
)
427438
_InstrumentedFastAPI._instrumented_fastapi_apps.add(self)
428439

429-
def __del__(self):
430-
if self in _InstrumentedFastAPI._instrumented_fastapi_apps:
431-
_InstrumentedFastAPI._instrumented_fastapi_apps.remove(self)
432-
433440

434441
def _get_route_details(scope):
435442
"""

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414

1515
# pylint: disable=too-many-lines
1616

17+
import gc as _gc
1718
import unittest
19+
import weakref as _weakref
1820
from contextlib import ExitStack
1921
from timeit import default_timer
2022
from unittest.mock import Mock, call, patch
@@ -1400,6 +1402,16 @@ def test_mark_span_internal_in_presence_of_span_from_other_framework(self):
14001402
)
14011403

14021404

1405+
class TestFastAPIGarbageCollection(unittest.TestCase):
1406+
def test_fastapi_app_is_collected_after_instrument(self):
1407+
app = fastapi.FastAPI()
1408+
otel_fastapi.FastAPIInstrumentor().instrument_app(app)
1409+
app_ref = _weakref.ref(app)
1410+
del app
1411+
_gc.collect()
1412+
self.assertIsNone(app_ref())
1413+
1414+
14031415
@patch.dict(
14041416
"os.environ",
14051417
{

0 commit comments

Comments
 (0)