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)
    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"