├── src └── fastapi_sqlalchemy_monitor │ ├── __init__.py │ ├── statistics.py │ ├── action.py │ └── middleware.py ├── tests ├── common.py ├── test_middleware_async.py └── test_middleware_sync.py ├── .github ├── actions │ └── setup-uv │ │ └── action.yml └── workflows │ ├── deploy.yml │ └── test.yml ├── LICENSE ├── pyproject.toml ├── .gitignore └── README.md /src/fastapi_sqlalchemy_monitor/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_sqlalchemy_monitor.middleware import SQLAlchemyMonitor 2 | from fastapi_sqlalchemy_monitor.statistics import AlchemyStatistics, QueryStatistic 3 | 4 | __all__ = [SQLAlchemyMonitor, AlchemyStatistics, QueryStatistic] 5 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | from fastapi_sqlalchemy_monitor.action import Action 2 | from fastapi_sqlalchemy_monitor.statistics import AlchemyStatistics 3 | 4 | 5 | class TestAction(Action): 6 | def __init__(self): 7 | self.statistics = None 8 | 9 | def handle(self, statistics: AlchemyStatistics): 10 | self.statistics = statistics 11 | -------------------------------------------------------------------------------- /.github/actions/setup-uv/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Setup uv Environment' 2 | description: 'Setup uv Environment' 3 | inputs: 4 | python-version: 5 | description: 'Python version to use' 6 | required: true 7 | runs: 8 | using: "composite" 9 | steps: 10 | - id: setup-python 11 | uses: actions/setup-python@v5 12 | with: 13 | python-version: ${{ inputs.python-version }} 14 | - run: | 15 | python -m pip install -U pip 16 | python -m pip install -U uv uv-dynamic-versioning 17 | python_path=${{ steps.setup-python.outputs.python-path }} 18 | python_root_dir=$(dirname $(dirname $python_path)) 19 | export UV_PROJECT_ENVIRONMENT=$python_root_dir 20 | export UV_PYTHON=${{ steps.setup-python.outputs.python-path }} 21 | echo "UV_PROJECT_ENVIRONMENT is set to $UV_PROJECT_ENVIRONMENT" 22 | uv sync --frozen 23 | shell: bash -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Artifacts & Pages 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: write 9 | pages: write 10 | id-token: write 11 | 12 | jobs: 13 | test: 14 | uses: ./.github/workflows/test.yml 15 | secrets: inherit 16 | permissions: 17 | contents: read 18 | pull-requests: write 19 | 20 | deploy: 21 | needs: [test] 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - uses: ./.github/actions/setup-uv 28 | with: 29 | python-version: '3.11' 30 | - name: Deploy Library 31 | id: deploy 32 | run: | 33 | uv build 34 | uv publish --username "$INDEX_AUTH_USERNAME" --password "$INDEX_AUTH_PASSWORD" 35 | env: 36 | INDEX_AUTH_USERNAME: ${{ secrets.PYPI_USERNAME }} 37 | INDEX_AUTH_PASSWORD: ${{ secrets.PYPI_PASSWORD }} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Iwan Bolzern 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | workflow_call: 8 | workflow_dispatch: 9 | jobs: 10 | unit-test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [ "3.10", "3.11", "3.12", "3.13" ] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ./.github/actions/setup-uv 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: 'Run Linting' 21 | run: | 22 | ruff format --check . 23 | ruff check . 24 | - name: 'Run Unit Tests' 25 | run: | 26 | pytest tests --junitxml=pytest-junit.xml --cov=src/fastapi_sqlalchemy_monitor --cov-report xml:pytest-coverage.xml 27 | - name: 'Upload Artifact' 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: junit and cobertura - ${{ matrix.python-version }} 31 | path: | 32 | pytest-junit.xml 33 | pytest-coverage.xml 34 | retention-days: 7 35 | - name: Pytest coverage comment 36 | if: ${{ github.event_name == 'pull_request' }} 37 | uses: MishaKav/pytest-coverage-comment@v1.1.51 38 | with: 39 | pytest-xml-coverage-path: ./pytest-coverage.xml 40 | junitxml-path: pytest-junit.xml 41 | hide-report: true -------------------------------------------------------------------------------- /src/fastapi_sqlalchemy_monitor/statistics.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | 4 | @dataclass 5 | class QueryStatistic: 6 | """Statistics for a single SQL query. 7 | 8 | Tracks execution count and timing information for a specific SQL query. 9 | """ 10 | 11 | query: str # The query that was executed 12 | total_invocations: int = 0 # The total number of invocations of this query 13 | total_invocation_time_ms: int = 0 # The total time (ms) spent waiting for the database to respond 14 | invocation_times_ms: list[int] = field( 15 | default_factory=lambda: [] 16 | ) # The time (ms) spent waiting for the database to respond for each invocation of this query 17 | 18 | 19 | @dataclass 20 | class AlchemyStatistics: 21 | """Aggregates statistics for all SQL queries during a request. 22 | 23 | Maintains counters for total query executions and timing, plus detailed stats per query. 24 | """ 25 | 26 | total_invocations: int = 0 # Total number of invocations cline <---> database round trips 27 | total_invocation_time_ms: int = 0 # Total time (ms) spent waiting for the database to respond 28 | query_stats: dict[str, QueryStatistic] = field(default_factory=lambda: {}) # The statistics for each query 29 | 30 | def add_query_stat(self, query: str, invocation_time_ms: int): 31 | query_hash = hash(query) 32 | query_stat = self.query_stats.get(query_hash, None) 33 | if query_stat is None: 34 | query_stat = QueryStatistic( 35 | query=query, 36 | ) 37 | self.query_stats[query_hash] = query_stat 38 | 39 | query_stat.total_invocations += 1 40 | query_stat.total_invocation_time_ms += invocation_time_ms 41 | query_stat.invocation_times_ms.append(invocation_time_ms) 42 | 43 | def __str__(self): 44 | return f"Total invocations: {self.total_invocations}, total invocation time: {self.total_invocation_time_ms} ms" 45 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fastapi-sqlalchemy-monitor" 3 | dynamic = ["version"] 4 | description = "Seamlessly track SQLAlchemy performance in FastAPI with plug-and-play monitoring middleware 🔍" 5 | authors = [ 6 | { name = "Iwan Bolzern" }, 7 | ] 8 | readme = "README.md" 9 | urls = { Documentation = "https://github.com/iwanbolzern/fastapi-sqlalchemy-monitor" } 10 | requires-python = ">=3.10" 11 | dependencies = [ 12 | "fastapi>=0.115.6", 13 | "sqlalchemy[asyncio]>=2.0.36", 14 | ] 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Intended Audience :: Developers", 18 | "Intended Audience :: Healthcare Industry", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | ] 28 | 29 | [dependency-groups] 30 | dev = [ 31 | "aiosqlite>=0.20.0", 32 | "pytest>=8.3.4", 33 | "pytest-asyncio>=0.25.0", 34 | "pytest-cov>=6.0.0", 35 | "ruff>=0.8.4", 36 | "fastapi[all]>=0.115.6", 37 | "uv-dynamic-versioning>=0.4.0", 38 | ] 39 | 40 | [tool.uv] 41 | package = true 42 | python-downloads = "manual" 43 | default-groups = ["dev"] 44 | 45 | [build-system] 46 | requires = ["hatchling", "uv-dynamic-versioning"] 47 | build-backend = "hatchling.build" 48 | 49 | [tool.hatch.version] 50 | source = "uv-dynamic-versioning" 51 | 52 | [tool.uv-dynamic-versioning] 53 | enable = true 54 | vcs = "git" 55 | style = "pep440" 56 | bump = true 57 | 58 | [tool.uv-dynamic-versioning.substitution] 59 | folders = [ 60 | { path = "src" } 61 | ] 62 | 63 | [tool.ruff] 64 | line-length = 120 65 | include = ["src/**.py", "tests/**.py"] 66 | 67 | [tool.ruff.lint] 68 | select = [ 69 | "E", # pycodestyle errors 70 | "W", # pycodestyle warnings 71 | "F", # pyflakes 72 | "I", # isort 73 | "C", # flake8-comprehensions 74 | "B", # flake8-bugbear 75 | ] 76 | ignore = [ 77 | ] 78 | -------------------------------------------------------------------------------- /tests/test_middleware_async.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from common import TestAction 3 | from fastapi import FastAPI 4 | from fastapi.testclient import TestClient 5 | from sqlalchemy import text 6 | from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine 7 | 8 | from fastapi_sqlalchemy_monitor.middleware import SQLAlchemyMonitor 9 | 10 | 11 | @pytest.fixture 12 | def session_maker() -> tuple[async_sessionmaker[AsyncSession], AsyncEngine]: 13 | engine = create_async_engine("sqlite+aiosqlite://", echo=False, connect_args={"check_same_thread": False}) 14 | 15 | return async_sessionmaker(engine), engine 16 | 17 | 18 | @pytest.fixture 19 | def app(session_maker: tuple[async_sessionmaker[AsyncSession], AsyncEngine]) -> FastAPI: 20 | app = FastAPI() 21 | 22 | return app 23 | 24 | 25 | def test_middleware(app: FastAPI, session_maker: tuple[async_sessionmaker[AsyncSession], AsyncEngine]): 26 | @app.get("/") 27 | async def test_method(): 28 | session = session_maker[0]() 29 | await session.execute(text("SELECT 1")) 30 | await session.execute(text("SELECT 1")) 31 | 32 | test_action = TestAction() 33 | app.add_middleware(SQLAlchemyMonitor, engine=session_maker[1], actions=[test_action]) 34 | 35 | test_client = TestClient(app) 36 | 37 | res = test_client.get("/") 38 | assert res.status_code == 200 39 | 40 | # assert stdout is Total invocations: 2 41 | assert test_action.statistics.total_invocations == 2 42 | 43 | 44 | def test_middleware_with_engine_factory( 45 | app: FastAPI, session_maker: tuple[async_sessionmaker[AsyncSession], AsyncEngine] 46 | ): 47 | @app.get("/") 48 | async def test_method(): 49 | session = session_maker[0]() 50 | await session.execute(text("SELECT 1")) 51 | await session.execute(text("SELECT 1")) 52 | 53 | test_action = TestAction() 54 | app.add_middleware(SQLAlchemyMonitor, engine_factory=lambda: session_maker[1], actions=[test_action]) 55 | 56 | test_client = TestClient(app) 57 | 58 | res = test_client.get("/") 59 | assert res.status_code == 200 60 | 61 | # assert stdout is Total invocations: 2 62 | assert test_action.statistics.total_invocations == 2 63 | -------------------------------------------------------------------------------- /src/fastapi_sqlalchemy_monitor/action.py: -------------------------------------------------------------------------------- 1 | """Actions module for FastAPI SQLAlchemy Monitor. 2 | 3 | This module provides action handlers and actions that can be triggered based on 4 | SQLAlchemy query statistics. Actions can log, print, or raise exceptions when 5 | certain conditions are met. 6 | """ 7 | 8 | import logging 9 | from abc import ABC, abstractmethod 10 | from dataclasses import asdict 11 | 12 | from fastapi_sqlalchemy_monitor.statistics import AlchemyStatistics 13 | 14 | 15 | class Action(ABC): 16 | """Abstract base class for monitoring actions.""" 17 | 18 | @abstractmethod 19 | def handle(self, statistics: AlchemyStatistics): 20 | """Handle the statistics.""" 21 | pass 22 | 23 | 24 | class ConditionalAction(Action): 25 | """Base class for actions that only execute when a condition is met.""" 26 | 27 | def handle(self, statistics: AlchemyStatistics): 28 | """Evaluate condition and handle if true.""" 29 | if self._condition(statistics): 30 | self._handle(statistics) 31 | 32 | @abstractmethod 33 | def _condition(self, statistics: AlchemyStatistics) -> bool: 34 | """Evaluate if action should be taken.""" 35 | pass 36 | 37 | @abstractmethod 38 | def _handle(self, statistics: AlchemyStatistics): 39 | """Handle the statistics if condition is met.""" 40 | pass 41 | 42 | 43 | class LogStatistics(Action): 44 | """Action that logs current statistics.""" 45 | 46 | def __init__(self, log_level=logging.INFO): 47 | self.log_level = log_level 48 | 49 | def handle(self, statistics: AlchemyStatistics): 50 | logging.log(self.log_level, str(statistics), asdict(statistics)) 51 | 52 | 53 | class PrintStatistics(Action): 54 | """Action that prints current statistics.""" 55 | 56 | def handle(self, statistics: AlchemyStatistics): 57 | print(str(statistics), asdict(statistics)) 58 | 59 | 60 | class MaxTotalInvocationAction(ConditionalAction): 61 | """Action that triggers when total query invocations exceed a threshold.""" 62 | 63 | def __init__(self, max_invocations: int, log_level: int = None): 64 | self.max_invocations = max_invocations 65 | self.log_level = log_level 66 | 67 | def _condition(self, statistics: AlchemyStatistics) -> bool: 68 | return statistics.total_invocations > self.max_invocations 69 | 70 | def _handle(self, statistics: AlchemyStatistics): 71 | msg = f"Maximum invocations exceeded: {statistics.total_invocations} > {self.max_invocations}" 72 | if self.log_level is not None: 73 | logging.log(self.log_level, msg) 74 | else: 75 | raise ValueError(msg) 76 | 77 | 78 | class WarnMaxTotalInvocation(MaxTotalInvocationAction): 79 | def __init__(self, max_invocations: int): 80 | super().__init__(max_invocations, logging.WARNING) 81 | 82 | 83 | class ErrorMaxTotalInvocation(MaxTotalInvocationAction): 84 | def __init__(self, max_invocations: int): 85 | super().__init__(max_invocations, logging.ERROR) 86 | 87 | 88 | class RaiseMaxTotalInvocation(MaxTotalInvocationAction): 89 | def __init__(self, max_invocations: int): 90 | super().__init__(max_invocations) 91 | -------------------------------------------------------------------------------- /tests/test_middleware_sync.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from common import TestAction 3 | from fastapi import FastAPI 4 | from fastapi.testclient import TestClient 5 | from sqlalchemy import Engine, StaticPool, create_engine, select, text 6 | from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker 7 | 8 | from fastapi_sqlalchemy_monitor.middleware import SQLAlchemyMonitor 9 | 10 | 11 | @pytest.fixture 12 | def session_maker() -> tuple[sessionmaker[Session], Engine]: 13 | engine = create_engine("sqlite://", echo=True, connect_args={"check_same_thread": False}, poolclass=StaticPool) 14 | 15 | return sessionmaker(engine), engine 16 | 17 | 18 | @pytest.fixture 19 | def app(session_maker: tuple[sessionmaker[Session], Engine]) -> FastAPI: 20 | app = FastAPI() 21 | 22 | return app 23 | 24 | 25 | def test_middleware(capsys, app: FastAPI, session_maker: tuple[sessionmaker[Session], Engine]): 26 | @app.get("/") 27 | def test_method(): 28 | session = session_maker[0]() 29 | session.execute(text("SELECT 1")) 30 | session.execute(text("SELECT 1")) 31 | 32 | test_action = TestAction() 33 | app.add_middleware(SQLAlchemyMonitor, engine=session_maker[1], actions=[test_action]) 34 | 35 | test_client = TestClient(app) 36 | 37 | res = test_client.get("/") 38 | assert res.status_code == 200 39 | 40 | # assert stdout is Total invocations: 2 41 | assert test_action.statistics.total_invocations == 2 42 | 43 | 44 | def test_middleware_with_engine_factory(capsys, app: FastAPI, session_maker: tuple[sessionmaker[Session], Engine]): 45 | @app.get("/") 46 | def test_method(): 47 | session = session_maker[0]() 48 | session.execute(text("SELECT 1")) 49 | session.execute(text("SELECT 1")) 50 | 51 | test_action = TestAction() 52 | app.add_middleware(SQLAlchemyMonitor, engine_factory=lambda: session_maker[1], actions=[test_action]) 53 | 54 | test_client = TestClient(app) 55 | 56 | res = test_client.get("/") 57 | assert res.status_code == 200 58 | 59 | # assert stdout is Total invocations: 2 60 | assert test_action.statistics.total_invocations == 2 61 | 62 | 63 | def test_middleware_orm(app: FastAPI, session_maker: tuple[sessionmaker[Session], Engine]): 64 | # create an ORM class 65 | class Base(DeclarativeBase): 66 | pass 67 | 68 | class Test(Base): 69 | __tablename__ = "test" 70 | id: Mapped[int] = mapped_column(primary_key=True) 71 | test_2: Mapped[str] 72 | 73 | # create the table 74 | Base.metadata.create_all(session_maker[1]) 75 | 76 | @app.get("/") 77 | def test_method(): 78 | session = session_maker[0]() 79 | session.bulk_save_objects([Test(test_2="test") for _ in range(10000)]) 80 | session.execute(select(Test).where(Test.test_2 == "test")) 81 | 82 | test_action = TestAction() 83 | app.add_middleware(SQLAlchemyMonitor, engine=session_maker[1], actions=[test_action]) 84 | 85 | test_client = TestClient(app) 86 | 87 | res = test_client.get("/") 88 | assert res.status_code == 200 89 | 90 | # assert stdout is Total invocations: 2 91 | assert test_action.statistics.total_invocations == 2 92 | assert test_action.statistics.total_invocation_time_ms > 0 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # PyPI configuration file 171 | .pypirc 172 | -------------------------------------------------------------------------------- /src/fastapi_sqlalchemy_monitor/middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from contextvars import ContextVar 4 | from typing import Callable 5 | 6 | from sqlalchemy import Engine, event 7 | from sqlalchemy.ext.asyncio import AsyncEngine 8 | from starlette.middleware.base import BaseHTTPMiddleware 9 | from starlette.requests import Request 10 | from starlette.types import ASGIApp 11 | 12 | from fastapi_sqlalchemy_monitor.action import Action 13 | from fastapi_sqlalchemy_monitor.statistics import AlchemyStatistics 14 | 15 | 16 | class SQLAlchemyMonitor(BaseHTTPMiddleware): 17 | """Middleware for monitoring SQLAlchemy database operations. 18 | 19 | Tracks query execution time and counts for each request. Can trigger actions 20 | based on configured thresholds. 21 | 22 | Args: 23 | app: The ASGI application 24 | engine: SQLAlchemy engine instance (sync or async) 25 | engine_factory: Factory function to get SQLAlchemy engine instance (sync or async). Use this if SQLAlchemy 26 | engine is not available at the time of middleware initialization 27 | actions: List of monitoring actions to execute 28 | allow_no_request_context: Whether to allow DB operations outside request context 29 | """ 30 | 31 | request_context = ContextVar[AlchemyStatistics]("request_context", default=None) 32 | 33 | def __init__( 34 | self, 35 | app: ASGIApp, 36 | engine: Engine | AsyncEngine = None, 37 | engine_factory: Callable[[], Engine | AsyncEngine] = None, 38 | actions: list[Action] = None, 39 | allow_no_request_context=False, 40 | ): 41 | super().__init__(app) 42 | 43 | if engine is None and engine_factory is None: 44 | raise ValueError("SQLAlchemyMonitor middleware requires either engine or engine_factory") 45 | 46 | if engine and engine_factory: 47 | raise ValueError("SQLAlchemyMonitor middleware requires either engine or engine_factory, not both") 48 | 49 | self._actions = actions or [] 50 | self._allow_no_request_context = allow_no_request_context 51 | self._engine = None 52 | self._engine_factory = engine_factory 53 | 54 | if engine: 55 | self._engine = self._register_listener(engine) 56 | 57 | def init_statistics(self): 58 | self.request_context.set(AlchemyStatistics()) 59 | 60 | @property 61 | def statistics(self) -> AlchemyStatistics: 62 | return self.request_context.get() 63 | 64 | def before_cursor_execute(self, conn, cursor, statement, parameters, context, executemany): 65 | if context is None: 66 | logging.warning("Received before_cursor_execute event without context") 67 | return 68 | 69 | context.query_start_time = time.time() 70 | 71 | def after_cursor_execute(self, conn, cursor, statement, parameters, context, executemany): 72 | if context is None: 73 | logging.warning("Received after_cursor_execute event without context") 74 | return 75 | 76 | query_start_time = getattr(context, "query_start_time", None) 77 | if query_start_time is None: 78 | logging.warning("Received after_cursor_execute event without before_cursor_execute event") 79 | return 80 | 81 | total = time.time() - query_start_time 82 | 83 | if self.statistics is None: 84 | if not self._allow_no_request_context: 85 | logging.warning( 86 | "Received database event without requests context. Please make sure that the " 87 | "middleware is the first middleware in the stack e.g.\n" 88 | "app = FastAPI()\n" 89 | "app.add_middleware(SQLAlchemyMonitor, engine=engine\n" 90 | "app.add_middleware(other middleware)\n\n" 91 | "or if you want to allow database events without request context, " 92 | "set SQLAlchemyMonitor(..., allow_no_request_context=True)" 93 | ) 94 | 95 | return 96 | 97 | # update global stats 98 | self.statistics.total_invocations += 1 99 | self.statistics.total_invocation_time_ms += total * 1000 100 | 101 | # update query stats 102 | self.statistics.add_query_stat(query=statement, invocation_time_ms=total * 1000) 103 | 104 | def on_do_orm_execute(self, orm_execute_state): 105 | print(orm_execute_state) 106 | 107 | async def dispatch(self, request: Request, call_next: Callable): 108 | if self._engine_factory and self._engine is None: 109 | self._engine = self._register_listener(self._engine_factory()) 110 | 111 | return await self._dispatch(request, call_next) 112 | 113 | async def _dispatch(self, request: Request, call_next: Callable): 114 | self.init_statistics() 115 | res = await call_next(request) 116 | 117 | for action in self._actions: 118 | action.handle(self.statistics) 119 | 120 | return res 121 | 122 | def _register_listener(self, engine: Engine | AsyncEngine) -> Engine: 123 | if isinstance(engine, AsyncEngine): 124 | engine = engine.sync_engine 125 | 126 | event.listen(engine, "before_cursor_execute", self.before_cursor_execute) 127 | event.listen(engine, "after_cursor_execute", self.after_cursor_execute) 128 | 129 | return engine 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI SQLAlchemy Monitor 2 | 3 | [![PyPI version](https://badge.fury.io/py/fastapi-sqlalchemy-monitor.svg)](https://badge.fury.io/py/fastapi-sqlalchemy-monitor) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | [![Test](https://github.com/iwanbolzern/fastapi-sqlalchemy-monitor/actions/workflows/test.yml/badge.svg)](https://github.com/iwanbolzern/fastapi-sqlalchemy-monitor/actions/workflows/test.yml) 6 | [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-sqlalchemy-monitor.svg)](https://pypi.org/project/fastapi-sqlalchemy-monitor/) 7 | 8 | A middleware for FastAPI that monitors SQLAlchemy database queries, providing insights into database usage patterns and helping catch potential performance issues. 9 | 10 | ## Features 11 | 12 | - 📊 Track total database query invocations and execution times 13 | - 🔍 Detailed per-query statistics 14 | - ⚡ Async support 15 | - 🎯 Configurable actions for monitoring and alerting 16 | - 🛡️ Built-in protection against N+1 query problems 17 | 18 | ## Installation 19 | 20 | ```bash 21 | pip install fastapi-sqlalchemy-monitor 22 | ``` 23 | 24 | ## Quick Start 25 | 26 | ```python 27 | from fastapi import FastAPI 28 | from sqlalchemy import create_engine 29 | 30 | from fastapi_sqlalchemy_monitor import SQLAlchemyMonitor 31 | from fastapi_sqlalchemy_monitor.action import WarnMaxTotalInvocation, PrintStatistics 32 | 33 | # Create async engine 34 | engine = create_engine("sqlite:///./test.db") 35 | 36 | app = FastAPI() 37 | 38 | # Add the middleware with actions 39 | app.add_middleware( 40 | SQLAlchemyMonitor, 41 | engine=engine, 42 | actions=[ 43 | WarnMaxTotalInvocation(max_invocations=10), # Warn if too many queries 44 | PrintStatistics() # Print statistics after each request 45 | ] 46 | ) 47 | ``` 48 | 49 | ## Actions 50 | 51 | The middleware supports different types of actions that can be triggered based on query statistics. 52 | 53 | ### Built-in Actions 54 | 55 | - `WarnMaxTotalInvocation`: Log a warning when query count exceeds threshold 56 | - `ErrorMaxTotalInvocation`: Log an error when query count exceeds threshold 57 | - `RaiseMaxTotalInvocation`: Raise an exception when query count exceeds threshold 58 | - `LogStatistics`: Log query statistics 59 | - `PrintStatistics`: Print query statistics 60 | 61 | ### Custom Actions 62 | 63 | The middleware provides two interfaces for implementing custom actions: 64 | 65 | - `Action`: Simple interface that executes after every request 66 | - `ConditionalAction`: Advanced interface that executes only when specific conditions are met 67 | 68 | #### Basic Custom Action 69 | 70 | Here's an example of a custom action that records Prometheus metrics: 71 | 72 | ```python 73 | from prometheus_client import Counter 74 | 75 | from fastapi_sqlalchemy_monitor import AlchemyStatistics 76 | from fastapi_sqlalchemy_monitor.action import Action 77 | 78 | class PrometheusAction(Action): 79 | def __init__(self): 80 | self.query_counter = Counter( 81 | 'sql_queries_total', 82 | 'Total number of SQL queries executed' 83 | ) 84 | 85 | def handle(self, statistics: AlchemyStatistics): 86 | self.query_counter.inc(statistics.total_invocations) 87 | ``` 88 | 89 | #### Conditional Action Example 90 | 91 | Here's an example of a conditional action that monitors for slow queries: 92 | 93 | ```python 94 | import logging 95 | 96 | from fastapi_sqlalchemy_monitor import AlchemyStatistics 97 | from fastapi_sqlalchemy_monitor.action import ConditionalAction 98 | 99 | class SlowQueryMonitor(ConditionalAction): 100 | def __init__(self, threshold_ms: float): 101 | self.threshold_ms = threshold_ms 102 | 103 | def _condition(self, statistics: AlchemyStatistics) -> bool: 104 | # Check if any query exceeds the time threshold 105 | return any( 106 | query.total_invocation_time_ms > self.threshold_ms 107 | for query in statistics.query_stats.values() 108 | ) 109 | 110 | def _handle(self, statistics: AlchemyStatistics): 111 | # Log details of slow queries 112 | for query_stat in statistics.query_stats.values(): 113 | if query_stat.total_invocation_time_ms > self.threshold_ms: 114 | logging.warning( 115 | f"Slow query detected ({query_stat.total_invocation_time_ms:.2f}ms): " 116 | f"{query_stat.query}" 117 | ) 118 | ``` 119 | 120 | #### Using Custom Actions 121 | 122 | Here's how to use custom actions: 123 | 124 | ```python 125 | app.add_middleware( 126 | SQLAlchemyMonitor, 127 | engine=engine, 128 | actions=[ 129 | PrometheusAction(), 130 | SlowQueryMonitor(threshold_ms=100) 131 | ] 132 | ) 133 | ``` 134 | 135 | #### Available Statistics 136 | 137 | When implementing custom actions, you have access to these statistics properties: 138 | 139 | - `statistics.total_invocations`: Total number of queries executed 140 | - `statistics.total_invocation_time_ms`: Total execution time in milliseconds 141 | - `statistics.query_stats`: Dictionary of per-query statistics 142 | 143 | Each `QueryStatistic` in `query_stats` contains: 144 | - `query`: The SQL query string 145 | - `total_invocations`: Number of times this query was executed 146 | - `total_invocation_time_ms`: Total execution time for this query 147 | - `invocation_times_ms`: List of individual execution times 148 | 149 | #### Best Practices 150 | 151 | 1. Keep actions focused on a single responsibility 152 | 2. Use appropriate log levels for different severity conditions 153 | 3. Consider performance impact of complex evaluations 154 | 4. Use type hints for better code maintenance 155 | 156 | ## Example with Async SQLAlchemy 157 | 158 | ```python 159 | from fastapi import FastAPI 160 | from sqlalchemy.ext.asyncio import create_async_engine 161 | 162 | from fastapi_sqlalchemy_monitor import SQLAlchemyMonitor 163 | from fastapi_sqlalchemy_monitor.action import PrintStatistics 164 | 165 | # Create async engine 166 | engine = create_async_engine("sqlite+aiosqlite:///./test.db") 167 | 168 | app = FastAPI() 169 | 170 | # Add middleware 171 | app.add_middleware( 172 | SQLAlchemyMonitor, 173 | engine=engine, 174 | actions=[PrintStatistics()] 175 | ) 176 | ``` 177 | 178 | ## Contributing 179 | 180 | Contributions are welcome! Please feel free to submit a Pull Request. 181 | 182 | ## License 183 | 184 | This project is licensed under the MIT License. 185 | --------------------------------------------------------------------------------