Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 90 additions & 22 deletions src/debug_toolbar/fastapi/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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":
Expand All @@ -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.

Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/debug_toolbar/fastapi/panels/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
84 changes: 84 additions & 0 deletions tests/integration/test_fastapi_dependencies_panel.py
Original file line number Diff line number Diff line change
@@ -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]
2 changes: 1 addition & 1 deletion tests/unit/fastapi/test_dependencies_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 13 additions & 12 deletions tests/unit/fastapi/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock

import pytest

Expand Down Expand Up @@ -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
Loading