diff --git a/src/debug_toolbar/fastapi/middleware.py b/src/debug_toolbar/fastapi/middleware.py index 7ade888..d0b0ac6 100644 --- a/src/debug_toolbar/fastapi/middleware.py +++ b/src/debug_toolbar/fastapi/middleware.py @@ -23,6 +23,81 @@ _dependency_cache_stats: ContextVar[dict[str, int] | None] = ContextVar("dependency_cache_stats", default=None) +def _dependant_cache_key(fastapi_dependency_utils: Any, dependant: Any) -> Any: + """Get a dependant's cache key across FastAPI versions. + + FastAPI historically exposed this as a ``Dependant.cache_key`` attribute + (instance attribute on old releases, ``cached_property`` on newer ones) and + later moved it to a module-level ``_get_cache_key()`` helper. Try both. + """ + cache_key = getattr(dependant, "cache_key", None) + if cache_key is not None: + return cache_key + get_cache_key = getattr(fastapi_dependency_utils, "_get_cache_key", None) + if get_cache_key is not None: + return get_cache_key(dependant=dependant) + return None + + +def _record_dependency_node(fastapi_dependency_utils: Any, dependant: Any, depth: int, **kwargs: Any) -> None: + """Record one resolved sub-dependency, if tracking is active for this request.""" + if depth <= 1 or dependant.call is None: + return + if _dependency_tracking.get() is None: + return + dependency_cache = kwargs.get("dependency_cache") or {} + cache_key = _dependant_cache_key(fastapi_dependency_utils, dependant) + already_cached = bool(getattr(dependant, "use_cache", True)) and cache_key in dependency_cache + track_dependency(dependant.call, cached=already_cached, duration_ms=None) + + +def _install_dependency_instrumentation() -> None: + """Monkeypatch FastAPI's ``solve_dependencies`` to record each resolved dependency. + + FastAPI resolves ``Depends()`` callables internally with no public hook for + observing resolutions. This wraps ``solve_dependencies`` (both where it is + defined and where ``fastapi.routing`` imported it) to record each sub-dependency + node's call and cache status as the dependency tree is solved. Durations are + not measured: FastAPI invokes most dependency kinds from different internal + call sites across versions, and resolving a sub-dependant's own params (which + is all this hook can time) is not the same as running the dependency itself. + + The original ``solve_dependencies`` call always runs and its result or + exception is returned/raised untouched; any error in our own bookkeeping is + swallowed so a request never fails because of the toolbar. + """ + from fastapi.dependencies import utils as fastapi_dependency_utils + + from fastapi import routing as fastapi_routing + + if fastapi_dependency_utils.solve_dependencies.__name__ == "instrumented_solve_dependencies": + return + + original_solve_dependencies = fastapi_dependency_utils.solve_dependencies + + async def instrumented_solve_dependencies(*, request: Any, dependant: Any, **kwargs: Any) -> Any: + depth_key = "_debug_toolbar_dependency_depth" + depth = None + try: + depth = request.scope.get(depth_key, 0) + 1 + request.scope[depth_key] = depth + except Exception: + logger.debug("Dependency tracking depth bookkeeping failed", exc_info=True) + + try: + return await original_solve_dependencies(request=request, dependant=dependant, **kwargs) + finally: + try: + if depth is not None: + request.scope[depth_key] = depth - 1 + _record_dependency_node(fastapi_dependency_utils, dependant, depth, **kwargs) + except Exception: + logger.debug("Dependency tracking bookkeeping failed", exc_info=True) + + fastapi_dependency_utils.solve_dependencies = instrumented_solve_dependencies # type: ignore[invalid-assignment] + fastapi_routing.solve_dependencies = instrumented_solve_dependencies # type: ignore[invalid-assignment] + + def get_dependency_tracking() -> list[dict[str, Any]]: """Get the current dependency tracking list.""" tracking = _dependency_tracking.get() @@ -46,7 +121,7 @@ def record_dependency_resolution( dependency_type: str, *, cached: bool, - duration_ms: float, + duration_ms: float | None, module: str | None = None, cache_key: str | None = None, params: dict[str, Any] | None = None, @@ -57,7 +132,7 @@ def record_dependency_resolution( name: Name of the dependency function/class. dependency_type: Type of dependency (function, class, generator). cached: Whether the result came from cache. - duration_ms: Resolution time in milliseconds. + duration_ms: Resolution time in milliseconds, or ``None`` if unknown. module: Module where the dependency is defined. cache_key: Cache key used by FastAPI. params: Parameters passed to the dependency. @@ -83,6 +158,13 @@ def record_dependency_resolution( else: stats["misses"] += 1 + context = get_request_context() + if context is not None: + context.metadata.setdefault( + "dependencies", + {"resolved": tracking, "tree": {}, "cache_stats": stats}, + ) + class DebugToolbarMiddleware(StarletteMiddleware): """FastAPI-specific middleware for the debug toolbar. @@ -107,6 +189,9 @@ def __init__( super().__init__(app, config, toolbar) self.fastapi_config = config + if config.track_dependency_injection: + _install_dependency_instrumentation() + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: """Process an ASGI request with dependency tracking.""" if scope["type"] != "http": @@ -117,31 +202,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: _dependency_tracking.set([]) _dependency_cache_stats.set({"hits": 0, "misses": 0, "total": 0}) - try: - await super().__call__(scope, receive, send) - finally: - if self.fastapi_config.track_dependency_injection: - context = get_request_context() - if context is not None: - self._populate_dependency_metadata(context) - - def _populate_dependency_metadata(self, context: Any) -> None: - """Populate dependency tracking data into the context.""" - tracking = _dependency_tracking.get() - stats = _dependency_cache_stats.get() - - context.metadata["dependencies"] = { - "resolved": tracking, - "tree": {}, - "cache_stats": stats, - } + await super().__call__(scope, receive, send) def track_dependency( dependency_callable: Any, *, cached: bool = False, - duration_ms: float = 0.0, + duration_ms: float | None = 0.0, ) -> None: """Helper function to track a dependency resolution. @@ -150,7 +218,7 @@ def track_dependency( Args: dependency_callable: The dependency function/class being resolved. cached: Whether the result came from cache. - duration_ms: Resolution time in milliseconds. + duration_ms: Resolution time in milliseconds, or ``None`` if unknown. """ name = getattr(dependency_callable, "__name__", str(dependency_callable)) module = getattr(dependency_callable, "__module__", None) diff --git a/src/debug_toolbar/fastapi/panels/dependencies.py b/src/debug_toolbar/fastapi/panels/dependencies.py index ba892e5..c71c7dd 100644 --- a/src/debug_toolbar/fastapi/panels/dependencies.py +++ b/src/debug_toolbar/fastapi/panels/dependencies.py @@ -42,7 +42,8 @@ async def generate_stats(self, context: RequestContext) -> dict[str, Any]: dependency_tree = dependencies_data.get("tree", {}) cache_stats = dependencies_data.get("cache_stats", {}) - total_time_ms = sum(d.get("duration_ms", 0) for d in resolved) + durations = [d["duration_ms"] for d in resolved if d.get("duration_ms") is not None] + total_time_ms = sum(durations) if durations else None cached_count = sum(1 for d in resolved if d.get("cached", False)) total_count = len(resolved) diff --git a/tests/integration/test_fastapi_dependencies_panel.py b/tests/integration/test_fastapi_dependencies_panel.py new file mode 100644 index 0000000..566103e --- /dev/null +++ b/tests/integration/test_fastapi_dependencies_panel.py @@ -0,0 +1,84 @@ +"""Integration tests for the FastAPI dependency injection panel.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi.testclient import TestClient + +from debug_toolbar.fastapi import FastAPIDebugToolbarConfig, setup_debug_toolbar +from fastapi import Depends, FastAPI + + +def get_settings() -> dict: + return {"debug": True} + + +def get_current_user(settings: Annotated[dict, Depends(get_settings)]) -> dict: + return {"id": 1, "debug": settings["debug"]} + + +def make_app() -> tuple[FastAPI, object]: + app = FastAPI() + config = FastAPIDebugToolbarConfig(enabled=True, track_dependency_injection=True) + toolbar = setup_debug_toolbar(app, config) + + @app.get("/users/me") + def read_current_user(user: Annotated[dict, Depends(get_current_user)]) -> dict: + return user + + return app, toolbar + + +def make_shared_dependency_app() -> tuple[FastAPI, object]: + """A route that uses the same dependency twice, directly and via another dependency.""" + app = FastAPI() + config = FastAPIDebugToolbarConfig(enabled=True, track_dependency_injection=True) + toolbar = setup_debug_toolbar(app, config) + + @app.get("/shared") + def read_shared( + settings: Annotated[dict, Depends(get_settings)], + user: Annotated[dict, Depends(get_current_user)], + ) -> dict: + return {"settings": settings, "user": user} + + return app, toolbar + + +class TestDependenciesPanelIntegration: + def test_resolved_dependencies_populated_for_depends_route(self) -> None: + """A route using Depends() must report non-empty resolved_dependencies.""" + app, toolbar = make_app() + client = TestClient(app) + + response = client.get("/users/me") + assert response.status_code == 200 + + _request_id, data = toolbar.storage.get_all()[0] + panel_stats = data["panel_data"]["DependencyInjectionPanel"] + + resolved = panel_stats["resolved_dependencies"] + assert resolved != [] + + names = {dep["name"] for dep in resolved} + assert "get_current_user" in names + assert "get_settings" in names + assert panel_stats["total_count"] == len(resolved) + + def test_shared_dependency_is_marked_cached_on_second_use(self) -> None: + """A dependency used twice in the same route is only resolved once; the + second (shared) use is reported as cached. + """ + app, toolbar = make_shared_dependency_app() + client = TestClient(app) + + response = client.get("/shared") + assert response.status_code == 200 + + _request_id, data = toolbar.storage.get_all()[0] + resolved = data["panel_data"]["DependencyInjectionPanel"]["resolved_dependencies"] + + settings_entries = [dep for dep in resolved if dep["name"] == "get_settings"] + assert len(settings_entries) == 2 + assert sorted(dep["cached"] for dep in settings_entries) == [False, True] diff --git a/tests/unit/fastapi/test_dependencies_panel.py b/tests/unit/fastapi/test_dependencies_panel.py index 3d4c0b4..7073e7c 100644 --- a/tests/unit/fastapi/test_dependencies_panel.py +++ b/tests/unit/fastapi/test_dependencies_panel.py @@ -52,7 +52,7 @@ async def test_generate_stats_empty(self, mock_toolbar: MagicMock, context: Requ assert stats["cached_count"] == 0 assert stats["fresh_count"] == 0 assert stats["cache_hit_rate"] == 0 - assert stats["total_time_ms"] == 0 + assert stats["total_time_ms"] is None @pytest.mark.asyncio async def test_generate_stats_with_dependencies(self, mock_toolbar: MagicMock, context: RequestContext) -> None: diff --git a/tests/unit/fastapi/test_middleware.py b/tests/unit/fastapi/test_middleware.py index a78249b..4e202e9 100644 --- a/tests/unit/fastapi/test_middleware.py +++ b/tests/unit/fastapi/test_middleware.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock import pytest @@ -166,21 +166,22 @@ async def test_call_non_http(self) -> None: await middleware(scope, receive, send) - def test_populate_dependency_metadata(self) -> None: - """Should populate dependency metadata into context.""" + def test_record_dependency_resolution_syncs_live_context(self) -> None: + """record_dependency_resolution must sync into the active request context immediately.""" + from debug_toolbar.core import RequestContext, set_request_context from debug_toolbar.fastapi.middleware import _dependency_cache_stats, _dependency_tracking - _dependency_tracking.set([{"name": "get_db", "type": "function", "cached": False, "duration_ms": 1.0}]) - _dependency_cache_stats.set({"hits": 0, "misses": 1, "total": 1}) - - app = AsyncMock() - middleware = DebugToolbarMiddleware(app) - - context = MagicMock() - context.metadata = {} + _dependency_tracking.set([]) + _dependency_cache_stats.set({"hits": 0, "misses": 0, "total": 0}) - middleware._populate_dependency_metadata(context) + context = RequestContext() + set_request_context(context) + try: + record_dependency_resolution("get_db", "function", cached=False, duration_ms=1.0) + finally: + set_request_context(None) assert "dependencies" in context.metadata assert len(context.metadata["dependencies"]["resolved"]) == 1 + assert context.metadata["dependencies"]["resolved"][0]["name"] == "get_db" assert context.metadata["dependencies"]["cache_stats"]["total"] == 1