Skip to content

Commit ab887ad

Browse files
authored
Merge pull request #236 from grillazz/235-profiler
feat: add profiling middleware and update README for performance prof…
2 parents 59b673e + 447e4e9 commit ab887ad

9 files changed

Lines changed: 106 additions & 3 deletions

File tree

README.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
<li><a href="#uv-knowledge-and-inspirations">UV knowledge and inspirations</a></li>
3535
<li><a href="#large-language-model">Integration with local LLM</a></li>
3636
<li><a href="#ha-sample-with-nginx-as-load-balancer">High Availability sample with nginx as load balancer</a></li>
37+
<li><a href="#performance-profiling-with-pyinstrument">Performance Profiling with Pyinstrument</a></li>
3738
</ul>
3839
</li>
3940
<li><a href="#acknowledgments">Acknowledgments</a></li>
@@ -193,6 +194,35 @@ make docker-up-ha
193194
<p align="right">(<a href="#readme-top">back to top</a>)</p>
194195

195196

197+
### Performance Profiling with Pyinstrument
198+
To help identify performance bottlenecks and analyze request handling, this project integrates `pyinstrument` for on-demand profiling.
199+
The `ProfilingMiddleware` allows you to profile any endpoint by simply adding a query parameter to your request.
200+
201+
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.
202+
This report provides a visual breakdown of where time is spent within your code.
203+
204+
To enable profiling for an endpoint, you need to:
205+
1. Add a `pyprofile` query parameter to the endpoint's signature. This makes the functionality discoverable through the API documentation.
206+
2. Make a request to the endpoint with the query parameter `?pyprofile=true`.
207+
208+
Here is an example from the `redis_check` health endpoint:
209+
```python
210+
from typing import Annotated
211+
from fastapi import Query
212+
213+
@router.get("/redis", status_code=status.HTTP_200_OK)
214+
async def redis_check(
215+
request: Request,
216+
pyprofile: Annotated[
217+
bool, Query(description="Enable profiler for this request")
218+
] = False,
219+
):
220+
# ... endpoint logic
221+
```
222+
223+
<p align="right">(<a href="#readme-top">back to top</a>)</p>
224+
225+
196226
### UV knowledge and inspirations
197227
- https://docs.astral.sh/uv/
198228
- https://hynek.me/articles/docker-uv/
@@ -226,7 +256,8 @@ I've included a few of my favorites to kick things off!
226256
<details>
227257
<summary>2026 (1 change)</summary>
228258
<ul>
229-
<li>[JAN 11 2026] refactor test fixture infrastructure to improve test isolation :test_tube:</li>
259+
<li>[FEB 5 2026] add profiler middleware :crystal_ball:</li>
260+
<li>[JAN 11 2026] refactor test fixture infrastructure to improve test isolation :test_tube:</li>
230261
</ul>
231262
</details>
232263
<details>

app/api/health.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313

1414

1515
@router.get("/redis", status_code=status.HTTP_200_OK)
16-
async def redis_check(request: Request):
16+
async def redis_check(
17+
request: Request,
18+
pyprofile: Annotated[ # noqa: ARG001
19+
bool, Query(description="Enable profiler for this request")
20+
] = False,
21+
):
1722
"""
1823
Endpoint to check Redis health and retrieve server information.
1924
@@ -23,6 +28,7 @@ async def redis_check(request: Request):
2328
2429
Args:
2530
request (Request): The incoming HTTP request.
31+
pyprofile (bool, optional): If `True`, enables the profiler for this request. Defaults to `False`.
2632
2733
Returns:
2834
dict or None: Returns Redis server information as a dictionary if successful,

app/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from fastapi.responses import HTMLResponse
77
from fastapi.templating import Jinja2Templates
88
from rotoger import get_logger
9+
from starlette.middleware import Middleware
10+
from starlette.middleware.gzip import GZipMiddleware
911

1012
from app.api.health import router as health_router
1113
from app.api.ml import router as ml_router
@@ -15,6 +17,7 @@
1517
from app.api.user import router as user_router
1618
from app.config import settings as global_settings
1719
from app.exception_handlers import register_exception_handlers
20+
from app.middleware.profiler import ProfilingMiddleware
1821
from app.redis import get_redis
1922
from app.services.auth import AuthBearer
2023

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

4649

50+
middleware = [
51+
Middleware(GZipMiddleware),
52+
Middleware(ProfilingMiddleware),
53+
]
54+
55+
4756
def create_app() -> FastAPI:
4857
app = FastAPI(
4958
title="Stuff And Nonsense API",
5059
version="1.22.0",
5160
lifespan=lifespan,
61+
middleware=middleware,
5262
)
5363
app.include_router(stuff_router)
5464
app.include_router(nonsense_router)

app/middleware/__init__.py

Whitespace-only changes.

app/middleware/profiler.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from __future__ import annotations
2+
3+
from fastapi import Request
4+
from pyinstrument import Profiler
5+
from starlette.middleware.base import (
6+
BaseHTTPMiddleware,
7+
RequestResponseEndpoint,
8+
)
9+
from starlette.responses import HTMLResponse, Response
10+
11+
12+
class ProfilingMiddleware(BaseHTTPMiddleware):
13+
async def dispatch(
14+
self, request: Request, call_next: RequestResponseEndpoint
15+
) -> Response:
16+
if request.query_params.get("pyprofile") == "true":
17+
profiler = Profiler(interval=0.001, async_mode="enabled")
18+
profiler.start()
19+
20+
await call_next(request)
21+
22+
profiler.stop()
23+
return HTMLResponse(
24+
profiler.output_html(),
25+
headers={"Content-Disposition": "attachment; filename=profile.html"},
26+
)
27+
28+
return await call_next(request)

compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ services:
1010
command: bash -c "
1111
uvicorn app.main:app
1212
--host 0.0.0.0 --port 8080
13-
--lifespan=on --use-colors --loop uvloop --http httptools
13+
--lifespan=on --use-colors --loop uvloop --http httptools --reload
1414
"
1515
volumes:
1616
- ./app:/panettone/app

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies = [
3030
"granian==2.6.0",
3131
"apscheduler[redis,sqlalchemy]>=4.0.0a6",
3232
"rotoger==0.2.1",
33+
"pyinstrument>=5.1.2",
3334
]
3435

3536
[tool.uv]

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
def anyio_backend(request):
2222
return request.param
2323

24+
2425
def _create_db(conn) -> None:
2526
"""Create the test database if it doesn't exist."""
2627
try:

uv.lock

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)