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
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<li><a href="#uv-knowledge-and-inspirations">UV knowledge and inspirations</a></li>
<li><a href="#large-language-model">Integration with local LLM</a></li>
<li><a href="#ha-sample-with-nginx-as-load-balancer">High Availability sample with nginx as load balancer</a></li>
<li><a href="#performance-profiling-with-pyinstrument">Performance Profiling with Pyinstrument</a></li>
</ul>
</li>
<li><a href="#acknowledgments">Acknowledgments</a></li>
Expand Down Expand Up @@ -193,6 +194,35 @@ make docker-up-ha
<p align="right">(<a href="#readme-top">back to top</a>)</p>


### Performance Profiling with Pyinstrument
To help identify performance bottlenecks and analyze request handling, this project integrates `pyinstrument` for on-demand profiling.
The `ProfilingMiddleware` allows you to profile any endpoint by simply adding a query parameter to your request.

When profiling is enabled for a request, `pyinstrument` will monitor the execution, and the server will respond with a detailed HTML report that you can download and view in your browser.
This report provides a visual breakdown of where time is spent within your code.

To enable profiling for an endpoint, you need to:
1. Add a `pyprofile` query parameter to the endpoint's signature. This makes the functionality discoverable through the API documentation.
2. Make a request to the endpoint with the query parameter `?pyprofile=true`.

Here is an example from the `redis_check` health endpoint:
```python
from typing import Annotated
from fastapi import Query

@router.get("/redis", status_code=status.HTTP_200_OK)
async def redis_check(
request: Request,
pyprofile: Annotated[
bool, Query(description="Enable profiler for this request")
] = False,
):
# ... endpoint logic
```

<p align="right">(<a href="#readme-top">back to top</a>)</p>


### UV knowledge and inspirations
- https://docs.astral.sh/uv/
- https://hynek.me/articles/docker-uv/
Expand Down Expand Up @@ -226,7 +256,8 @@ I've included a few of my favorites to kick things off!
<details>
<summary>2026 (1 change)</summary>
<ul>
<li>[JAN 11 2026] refactor test fixture infrastructure to improve test isolation :test_tube:</li>
<li>[FEB 5 2026] add profiler middleware :crystal_ball:</li>
<li>[JAN 11 2026] refactor test fixture infrastructure to improve test isolation :test_tube:</li>
</ul>
</details>
<details>
Expand Down
8 changes: 7 additions & 1 deletion app/api/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@


@router.get("/redis", status_code=status.HTTP_200_OK)
async def redis_check(request: Request):
async def redis_check(
request: Request,
pyprofile: Annotated[ # noqa: ARG001
bool, Query(description="Enable profiler for this request")
] = False,
):
"""
Endpoint to check Redis health and retrieve server information.

Expand All @@ -23,6 +28,7 @@ async def redis_check(request: Request):

Args:
request (Request): The incoming HTTP request.
pyprofile (bool, optional): If `True`, enables the profiler for this request. Defaults to `False`.

Returns:
dict or None: Returns Redis server information as a dictionary if successful,
Expand Down
10 changes: 10 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from rotoger import get_logger
from starlette.middleware import Middleware
from starlette.middleware.gzip import GZipMiddleware

from app.api.health import router as health_router
from app.api.ml import router as ml_router
Expand All @@ -15,6 +17,7 @@
from app.api.user import router as user_router
from app.config import settings as global_settings
from app.exception_handlers import register_exception_handlers
from app.middleware.profiler import ProfilingMiddleware
from app.redis import get_redis
from app.services.auth import AuthBearer

Expand Down Expand Up @@ -44,11 +47,18 @@ async def lifespan(app: FastAPI):
await app.postgres_pool.close()


middleware = [
Middleware(GZipMiddleware),
Middleware(ProfilingMiddleware),
]


def create_app() -> FastAPI:
app = FastAPI(
title="Stuff And Nonsense API",
version="1.22.0",
lifespan=lifespan,
middleware=middleware,
)
app.include_router(stuff_router)
app.include_router(nonsense_router)
Expand Down
Empty file added app/middleware/__init__.py
Empty file.
28 changes: 28 additions & 0 deletions app/middleware/profiler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

from fastapi import Request
from pyinstrument import Profiler
from starlette.middleware.base import (
BaseHTTPMiddleware,
RequestResponseEndpoint,
)
from starlette.responses import HTMLResponse, Response


class ProfilingMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if request.query_params.get("pyprofile") == "true":
profiler = Profiler(interval=0.001, async_mode="enabled")
profiler.start()

await call_next(request)

profiler.stop()
return HTMLResponse(
profiler.output_html(),
headers={"Content-Disposition": "attachment; filename=profile.html"},
)

return await call_next(request)
2 changes: 1 addition & 1 deletion compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ services:
command: bash -c "
uvicorn app.main:app
--host 0.0.0.0 --port 8080
--lifespan=on --use-colors --loop uvloop --http httptools
--lifespan=on --use-colors --loop uvloop --http httptools --reload
"
volumes:
- ./app:/panettone/app
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies = [
"granian==2.6.0",
"apscheduler[redis,sqlalchemy]>=4.0.0a6",
"rotoger==0.2.1",
"pyinstrument>=5.1.2",
]

[tool.uv]
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
def anyio_backend(request):
return request.param


def _create_db(conn) -> None:
"""Create the test database if it doesn't exist."""
try:
Expand Down
26 changes: 26 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.