Skip to content

Commit eab5ee3

Browse files
authored
feat(debugger): instrument FastAPI route handlers via route-table patching (#780)
## Problem Dynamic Instrumentation (DI) instruments a function by rebinding the module-level name (`setattr(module, name, wrapper)`). That reaches callers who look the function up by name at call time, but **not** web-framework route handlers: when `@app.get(...)` runs at import time, FastAPI captures a direct reference to the handler in its route table (`APIRoute.endpoint`, and the dispatched `APIRoute.dependant.call`). The module-level rebind never reaches those references, so requests routed by FastAPI call the original unwrapped handler and **no snapshot is produced**. DI already solves this for Flask by patching `app.view_functions`, but had no FastAPI equivalent. ## Fix Add `_patch_fastapi_routes` / `_patch_single_fastapi_app`, dispatched from the existing `_patch_framework_references` (so both install **and** restore are symmetric — `restore_function` reuses the same path with swapped args). For each `APIRoute` whose handler matches the target (identity match first, then a name+`__module__` fallback, mirroring the Flask precedent), rebind **both** `route.endpoint` and `route.dependant.call` (the attribute the dispatcher actually invokes). The already-built `Dependant` stays valid because the DI wrapper preserves the handler signature via `functools.wraps`, so no rebuild is needed. Per-route errors are isolated so patching can never break the application (SAFETY). Works for both **sync** route handlers (run by FastAPI in a threadpool) and **async** handlers (event loop) — DI's existing sync/async wrapper selection composes with the route patch. ## Tests - **Unit tests** (`test_function_wrapper_fastapi.py`) mirroring the Flask coverage: identity match, name+module fallback, no-op when FastAPI absent / no app / not-a-route, and integration via `_replace_function_in_module` and `restore_function`. - **New `di-fastapi` contract-test app + suite** mirroring the Flask DI suite (function-level, probe, line-level, hit-limit, coexistence, capture-limits) plus async-function tests and **sync + async route-handler tests** that assert a snapshot is produced. - Added `wait_for_method_snapshots(method_name, ...)` to the DI contract base to wait for a specific method's snapshot (avoids a batch-flush race where another function's snapshot arrives first). ## Verification - Distro unit tests: 18/18 wrapper tests (9 FastAPI + 9 Flask) + 92 other wrapper tests pass; pylint 10.00/10; black clean. - FastAPI DI contract suite: 29 passed. - Flask DI contract suite (rebuilt on the patched wheel): 24 passed — no regression on the shared dispatch path.
1 parent a6dc44a commit eab5ee3

9 files changed

Lines changed: 1683 additions & 1 deletion

File tree

.codespellrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[codespell]
22
# skipping auto generated folders
33
skip = ./.tox,./.mypy_cache,./target,*/LICENSE,./venv,*/sql_dialect_keywords.json,*/3rd.txt
4-
ignore-words-list = afterall,assertIn,crate,SEH
4+
ignore-words-list = afterall,assertIn,crate,SEH,dependant

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/debugger/_function_wrapper.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,7 @@ def _patch_framework_references(module, original_func: Callable, new_func: Calla
796796
797797
Currently supports:
798798
- Flask: patches app.view_functions entries that reference the original function
799+
- FastAPI: patches APIRoute.endpoint / APIRoute.dependant.call on app.router.routes
799800
800801
Args:
801802
module: The module object containing the function
@@ -805,6 +806,8 @@ def _patch_framework_references(module, original_func: Callable, new_func: Calla
805806
try:
806807
# Flask: patch view_functions on any Flask app instances in the module
807808
FunctionWrapper._patch_flask_view_functions(module, original_func, new_func)
809+
# FastAPI: patch route table entries on any FastAPI app instances in the module
810+
FunctionWrapper._patch_fastapi_routes(module, original_func, new_func)
808811
except Exception as exc:
809812
# Never let framework patching failures break instrumentation
810813
logger.debug("Framework reference patching encountered an error: %s", exc)
@@ -900,6 +903,131 @@ def _matches(vf):
900903
view_functions[ep] = new_func
901904
logger.debug("Patched Flask view_functions[%s] by name+module match", ep)
902905

906+
@staticmethod
907+
def _patch_fastapi_routes(module, original_func: Callable, new_func: Callable) -> None:
908+
"""
909+
Patch FastAPI route table entries that reference the original function.
910+
911+
FastAPI's @app.get()/@app.post() decorators capture a direct reference to the
912+
route handler at import time, storing it on each APIRoute as ``endpoint`` and
913+
(via the pre-built Dependant) ``dependant.call``. The per-request dispatch path
914+
invokes ``dependant.call``. Replacing the module-level name via setattr does NOT
915+
update these references, so FastAPI keeps calling the original unwrapped handler.
916+
This method finds and patches those references on any FastAPI app in the module.
917+
918+
Args:
919+
module: The module object that may contain FastAPI app instances
920+
original_func: The original function to find in the route table
921+
new_func: The new wrapper function to replace it with
922+
"""
923+
try:
924+
try:
925+
from fastapi import FastAPI # pylint: disable=import-outside-toplevel
926+
except ImportError:
927+
return # FastAPI not installed, nothing to patch
928+
929+
func_name = getattr(original_func, "__name__", "unknown")
930+
func_module = getattr(original_func, "__module__", None)
931+
932+
# Scan module attributes for FastAPI app instances
933+
for attr_name in dir(module):
934+
try:
935+
attr = getattr(module, attr_name, None)
936+
if attr is None or not isinstance(attr, FastAPI):
937+
continue
938+
FunctionWrapper._patch_single_fastapi_app(
939+
attr, attr_name, original_func, new_func, func_name, func_module
940+
)
941+
except Exception as exc:
942+
logger.warning("Error checking module attribute '%s' for FastAPI app: %s", attr_name, exc)
943+
continue
944+
945+
except Exception as exc:
946+
logger.warning("Error patching FastAPI routes: %s", exc)
947+
948+
@staticmethod
949+
def _patch_single_fastapi_app( # pylint: disable=too-many-locals
950+
fastapi_app, app_name, original_func, new_func, func_name, func_module=None
951+
):
952+
"""Patch route handler references in a single FastAPI app instance.
953+
954+
Rebinds both ``route.endpoint`` and ``route.dependant.call`` (the attribute the
955+
dispatcher actually invokes). The pre-built Dependant remains valid because the
956+
DI wrapper preserves the handler signature via functools.wraps, so no rebuild is
957+
needed. ``app.include_router`` flattens sub-routers into ``app.router.routes``,
958+
so iterating that list covers all routes.
959+
"""
960+
router = getattr(fastapi_app, "router", None)
961+
routes = getattr(router, "routes", None)
962+
if not isinstance(routes, list):
963+
logger.debug("FastAPI app '%s' has no router.routes list", app_name)
964+
return
965+
966+
def _rebind(route) -> None:
967+
route.endpoint = new_func
968+
dependant = getattr(route, "dependant", None)
969+
if dependant is not None and hasattr(dependant, "call"):
970+
dependant.call = new_func
971+
972+
def _is_identity_match(route) -> bool:
973+
if getattr(route, "endpoint", None) is original_func:
974+
return True
975+
dependant = getattr(route, "dependant", None)
976+
return dependant is not None and getattr(dependant, "call", None) is original_func
977+
978+
# Identity match first. Guard each route independently so one bad route
979+
# cannot abort patching of the others.
980+
patched_count = 0
981+
for route in routes:
982+
try:
983+
if getattr(route, "endpoint", None) is None:
984+
continue # not an APIRoute (e.g. Mount/WebSocketRoute)
985+
if _is_identity_match(route):
986+
_rebind(route)
987+
patched_count += 1
988+
logger.debug("Patched FastAPI route '%s' to use DI wrapper", getattr(route, "path", "?"))
989+
except Exception as exc:
990+
logger.warning("Error patching FastAPI route '%s': %s", getattr(route, "path", "?"), exc)
991+
continue
992+
993+
if patched_count > 0:
994+
logger.debug(
995+
"Patched %d FastAPI route(s) for function '%s' on app '%s'",
996+
patched_count,
997+
func_name,
998+
app_name,
999+
)
1000+
return
1001+
1002+
# Identity check failed -- try name+module-based matching. functools.wraps
1003+
# (used by DI's own wrapper and by OTel instrumentation) preserves __module__,
1004+
# so requiring both name and module avoids patching a same-named handler from a
1005+
# different module. If the original has no __module__, fall back to name-only.
1006+
def _matches(endpoint):
1007+
if getattr(endpoint, "__name__", None) != func_name:
1008+
return False
1009+
if func_module is None:
1010+
return True
1011+
return getattr(endpoint, "__module__", None) == func_module
1012+
1013+
matching_routes = [r for r in routes if getattr(r, "endpoint", None) is not None and _matches(r.endpoint)]
1014+
if matching_routes:
1015+
logger.debug(
1016+
"FastAPI app '%s' has %d route(s) with name '%s' (module '%s') but identity "
1017+
"mismatch. Patching by name+module.",
1018+
app_name,
1019+
len(matching_routes),
1020+
func_name,
1021+
func_module,
1022+
)
1023+
for route in matching_routes:
1024+
try:
1025+
_rebind(route)
1026+
logger.debug("Patched FastAPI route '%s' by name+module match", getattr(route, "path", "?"))
1027+
except Exception as exc:
1028+
logger.warning("Error patching FastAPI route '%s': %s", getattr(route, "path", "?"), exc)
1029+
continue
1030+
9031031
@staticmethod
9041032
def _get_qualified_name(original_func: Callable) -> str:
9051033
"""Return a stable qualified name for functions and methods."""

0 commit comments

Comments
 (0)