diff --git a/README.md b/README.md
index f0cfc09..3c62fa6 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@
UV knowledge and inspirations
Integration with local LLM
High Availability sample with nginx as load balancer
+ Performance Profiling with Pyinstrument
Acknowledgments
@@ -193,6 +194,35 @@ make docker-up-ha
(back to top)
+### 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
+```
+
+(back to top)
+
+
### UV knowledge and inspirations
- https://docs.astral.sh/uv/
- https://hynek.me/articles/docker-uv/
@@ -226,7 +256,8 @@ I've included a few of my favorites to kick things off!
2026 (1 change)
- - [JAN 11 2026] refactor test fixture infrastructure to improve test isolation :test_tube:
+ - [FEB 5 2026] add profiler middleware :crystal_ball:
+ - [JAN 11 2026] refactor test fixture infrastructure to improve test isolation :test_tube:
diff --git a/app/api/health.py b/app/api/health.py
index e08754e..4990281 100644
--- a/app/api/health.py
+++ b/app/api/health.py
@@ -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.
@@ -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,
diff --git a/app/main.py b/app/main.py
index 32a2c86..801393d 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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
@@ -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
@@ -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)
diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/middleware/profiler.py b/app/middleware/profiler.py
new file mode 100644
index 0000000..c1c978e
--- /dev/null
+++ b/app/middleware/profiler.py
@@ -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)
diff --git a/compose.yml b/compose.yml
index 9ba2656..9589e0f 100644
--- a/compose.yml
+++ b/compose.yml
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
index c78b233..e4b761c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
diff --git a/tests/conftest.py b/tests/conftest.py
index 25b3a50..f706aec 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -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:
diff --git a/uv.lock b/uv.lock
index 7c182b9..af58202 100644
--- a/uv.lock
+++ b/uv.lock
@@ -395,6 +395,7 @@ dependencies = [
{ name = "polyfactory" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" },
+ { name = "pyinstrument" },
{ name = "pyjwt" },
{ name = "pytest" },
{ name = "pytest-cov" },
@@ -433,6 +434,7 @@ requires-dist = [
{ name = "polyfactory", specifier = "==3.1.0" },
{ name = "pydantic", extras = ["email"], specifier = "==2.12.5" },
{ name = "pydantic-settings", specifier = "==2.12.0" },
+ { name = "pyinstrument", specifier = ">=5.1.2" },
{ name = "pyjwt", specifier = "==2.10.1" },
{ name = "pytest", specifier = "==9.0.2" },
{ name = "pytest-cov", specifier = "==7.0.0" },
@@ -999,6 +1001,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
+[[package]]
+name = "pyinstrument"
+version = "5.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/32/7f/d3c4ef7c43f3294bd5a475dfa6f295a9fee5243c292d5c8122044fa83bcb/pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0", size = 266889, upload-time = "2026-01-04T18:38:58.464Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/66/0f/7d5154c92904bdf25be067a7fe4cad4ba48919f16ccbb51bb953d9ae1a20/pyinstrument-5.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0baed297beee2bb9897e737bbd89e3b9d45a2fbbea9f1ad4e809007d780a9b1e", size = 131388, upload-time = "2026-01-04T18:38:10.491Z" },
+ { url = "https://files.pythonhosted.org/packages/17/28/bf83231a3f951e11b4dfaf160e1eeba1ce29377eab30e3d2eb6ee22ff3ba/pyinstrument-5.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ebb910a32a45bde6c3fc30c578efc28a54517990e11e94b5e48a0d5479728568", size = 124456, upload-time = "2026-01-04T18:38:11.792Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/98/762cf10896d907268629e1db08a48f128984a53e8d92b99ea96f862597e5/pyinstrument-5.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bad403c157f9c6dba7f731a6fca5bfcd8ca2701a39bcc717dcc6e0b10055ffc4", size = 149594, upload-time = "2026-01-04T18:38:13.434Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/1b/48580e16e623d89af58b89c552c95a2ae65f70a1f4fab1d97879f34791db/pyinstrument-5.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f456cabdb95fd343c798a7f2a56688b028f981522e283c5f59bd59195b66df5", size = 148339, upload-time = "2026-01-04T18:38:14.767Z" },
+ { url = "https://files.pythonhosted.org/packages/62/7e/38157a8a6ec67789d8ee109fd09877ea3340df44e1a7add8f249e30a8ade/pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e9c4dcc1f2c4a0cd6b576e3604abc37496a7868243c9a1443ad3b9db69d590f", size = 148485, upload-time = "2026-01-04T18:38:16.121Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/34/31ee72b19cfc48a82801024b5d653f07982154a11381a3ae65bbfdbf2c7b/pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:acf93b128328c6d80fdb85431068ac17508f0f7845e89505b0ea6130dead5ca6", size = 148106, upload-time = "2026-01-04T18:38:17.623Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/b4/7ab20243187262d66ab062778b1ccac4ca55090752f32a83f603f4e5e3a2/pyinstrument-5.1.2-cp314-cp314-win32.whl", hash = "sha256:9c7f0167903ecff8b1d744f7e37b2bd4918e05a69cca724cb112f5ed59d1e41b", size = 126593, upload-time = "2026-01-04T18:38:18.968Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/a0/db6a8ae3182546227f5a043b1be29b8d5f98bf973e20d922981ef206de85/pyinstrument-5.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:ce3f6b1f9a2b5d74819ecc07d631eadececf915f551474a75ad65ac580ec5a0e", size = 127358, upload-time = "2026-01-04T18:38:20.28Z" },
+ { url = "https://files.pythonhosted.org/packages/59/d2/719f439972b3f80e35fb5b1bcd888c3218d60dbc91957b99ffafd7ac9221/pyinstrument-5.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:af8651b239049accbeecd389d35823233f649446f76f47fd005316b05d08cef2", size = 132317, upload-time = "2026-01-04T18:38:21.669Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/1c/0ebfef69ae926665fae635424c5647411235c3689c9a9ad69fd68de6cae2/pyinstrument-5.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c6082f1c3e43e1d22834e91ba8975f0080186df4018a04b4dd29f9623c59df1d", size = 124917, upload-time = "2026-01-04T18:38:23.385Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/ee/5599f769f515a0f1c97443edc7394fe2b9829bf39f404c046499c1a62378/pyinstrument-5.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c031eb066ddc16425e1e2f56aad5c1ce1e27b2432a70329e5385b85e812decee", size = 157407, upload-time = "2026-01-04T18:38:24.774Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/40/32aa865252288caef301237488ee309bd6701125888bf453d23ab764e357/pyinstrument-5.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f447ec391cad30667ba412dce41607aaa20d4a2496a7ab867e0c199f0fe3ae3d", size = 155068, upload-time = "2026-01-04T18:38:26.112Z" },
+ { url = "https://files.pythonhosted.org/packages/91/68/0b56a1540fe1c357dfcda82d4f5b52c87fada5962cbf18703ea39ccbbe69/pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50299bddfc1fe0039898f895b10ef12f9db08acffb4d85326fad589cda24d2ee", size = 155186, upload-time = "2026-01-04T18:38:27.914Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/48/7ef84abfc3e41148cf993095214f104e75ecff585e94c6e8be001e672573/pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a193ff08825ece115ececa136832acb14c491c77ab1e6b6a361905df8753d5c6", size = 153979, upload-time = "2026-01-04T18:38:29.236Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/cf/a28ad117d58b33c1d74bcdfbbcf1603b67346883800ac7d510cff8d3bcee/pyinstrument-5.1.2-cp314-cp314t-win32.whl", hash = "sha256:de887ba19e1057bd2d86e6584f17788516a890ae6fe1b7eed9927873f416b4d8", size = 127267, upload-time = "2026-01-04T18:38:30.619Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/97/03635143a12a5d941f545548b00f8ac39d35565321a2effb4154ed267338/pyinstrument-5.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b6a71f5e7f53c86c9b476b30cf19509463a63581ef17ddbd8680fee37ae509db", size = 128164, upload-time = "2026-01-04T18:38:32.281Z" },
+]
+
[[package]]
name = "pyjwt"
version = "2.10.1"