From 7f48a9019004997e3b9c5e01a4daf6f02b484304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= <117113383+kemalcalak@users.noreply.github.com> Date: Wed, 6 May 2026 13:49:27 +0300 Subject: [PATCH 1/6] chore: add pre-commit hooks and prometheus metrics --- .env.example | 5 +- .pre-commit-config.yaml | 18 ++++++ README.md | 58 +++++++++++++------ app/main.py | 18 +++++- pyproject.toml | 2 + uv.lock | 124 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 203 insertions(+), 22 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.env.example b/.env.example index 3445ba6..0598f77 100644 --- a/.env.example +++ b/.env.example @@ -23,4 +23,7 @@ SMTP_USE_STARTTLS=True SMTP_USE_SSL=False SMTP_USER="smtp_username" SMTP_PASSWORD="smtp_password" -EMAILS_FROM_EMAIL="noreply@example.com" \ No newline at end of file +EMAILS_FROM_EMAIL="noreply@example.com" + +# Sentry (only initialized when ENVIRONMENT != "local") +SENTRY_DSN= diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7635c4c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + - id: check-merge-conflict + - id: detect-private-key + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.2 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/README.md b/README.md index 33a4bd6..601defc 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ While FastAPI is incredibly fast and flexible, it doesn't enforce a specific pro ## 🔗 Frontend Compatibility -This backend template is designed to seamlessly integrate with the companion **React + TypeScript + Vite Enterprise Template**. -You can find the frontend template here: [kemalcalak/React-Template](https://github.com/kemalcalak/React-Template). +This backend template is designed to seamlessly integrate with the companion **Next.js 16 + React 19 + TypeScript Enterprise Template**. +You can find the frontend template here: [kemalcalak/NextJS-Template](https://github.com/kemalcalak/NextJS-Template). + +The two templates share the same auth contract: HttpOnly `access_token` / `refresh_token` cookies, `/api/v1` prefix, `X-Requested-With` CSRF header, and a uniform `{ success, data, message, error }` response envelope. --- @@ -26,16 +28,20 @@ You can find the frontend template here: [kemalcalak/React-Template](https://git This template integrates the best-in-class Python ecosystem tools to provide a seamless developer experience: - **Framework:** [FastAPI](https://fastapi.tiangolo.com/) for building APIs with Python 3.12+ based on standard Python type hints. -- **Architecture:** Strict Layered Architecture separating routers, services, repositories, and models, fully utilizing FastAPI's dependency injection. +- **Architecture:** Strict Layered Architecture separating routers, services, repositories, use cases, and models, fully utilizing FastAPI's dependency injection. - **Database & ORM:** [SQLAlchemy 2.0](https://www.sqlalchemy.org/) with `asyncpg` for non-blocking operations, and [Alembic](https://alembic.sqlalchemy.org/) for schema migrations. - **Observability & Error Tracking:** [Sentry](https://sentry.io/) built-in integration for tracking unhandled exceptions and performance tracing. - **Caching:** [Redis](https://redis.io/) integration using `redis.asyncio` for robust, high-performance distributed caching. - **Validation & Config:** [Pydantic v2](https://docs.pydantic.dev/latest/) and `pydantic-settings` for robust data validation and environment management. -- **Security & Auth:** Built-in JWT validation, `bcrypt` password hashing, and [Slowapi](https://slowapi.readthedocs.io/en/latest/) for rate limiting and brute force protection. +- **Security & Auth:** JWT access/refresh tokens accepted via either HttpOnly cookies *or* `Authorization: Bearer`, `bcrypt` password hashing, **Redis-backed token blacklist** (logout invalidation), strict origin-check middleware (returns 404 for foreign origins), and [Slowapi](https://slowapi.readthedocs.io/en/latest/) rate limiting for brute-force protection. +- **Account Lifecycle:** Email verification, password reset, password change, and **soft-delete with grace period** — accounts marked for deletion can be reactivated until the cron worker purges them. +- **Background Jobs:** [arq](https://arq-docs.helpmanual.io/) worker (separate container in compose) runs cron jobs such as `delete_expired_accounts` at the configured time. +- **Audit Trail:** `user_activity` table records auth events and CRUD actions with IP / user agent. The `audit_unexpected_failure` decorator captures unexpected route failures. - **Smart Email Validation & Delivery:** Built-in asynchronous email sending with SMTP, domain MX record checking using `dnspython`, and auto-updating disposable email provider filtering via Redis cache. - **Standardized API Responses:** Global exception handlers standardizing success/error schemas, utilizing a centralized `messages` module (`app/core/messages/`) to prevent hardcoded responses. +- **First Superuser Seed:** On startup, an initial admin is created from `FIRST_SUPERUSER` / `FIRST_SUPERUSER_PASSWORD` if none exists. - **Tooling:** [uv](https://docs.astral.sh/uv/) for blazing-fast package management, and [Ruff](https://docs.astral.sh/ruff/) for linting and formatting. -- **Testing:** Comprehensive async testing setup with `pytest` and `pytest-asyncio`. +- **Testing:** Comprehensive async testing setup with `pytest` and `pytest-asyncio`, in-memory SQLite via `aiosqlite`, `fakeredis`, and autouse SMTP/MX patches — tests never hit Postgres or the network. --- @@ -104,11 +110,14 @@ SMTP_USE_SSL=False SMTP_USER="smtp_username" SMTP_PASSWORD="smtp_password" EMAILS_FROM_EMAIL="noreply@example.com" + +# Sentry (only initialized when ENVIRONMENT != "local") +SENTRY_DSN= ``` ### 3. Start the Application via Docker -This project provides a `docker-compose.yaml` to spin up the entire stack, including the backend service, a local PostgreSQL instance, and a Redis container: +This project provides a `docker-compose.yaml` to spin up the entire stack, including the backend service, a local PostgreSQL instance, a Redis container, and the **arq background worker**: ```bash docker-compose up -d --build @@ -116,6 +125,12 @@ docker-compose up -d --build The API will be available at `http://localhost:8000`. You can test the endpoints via the Swagger UI available at `http://localhost:8000/docs`. +To run the worker manually (e.g. when developing the API on the host): + +```bash +uv run arq app.worker.settings.WorkerSettings +``` + ### 4. Run Migrations To generate and apply the database tables using Alembic, run the migration command inside the backend container: @@ -160,22 +175,27 @@ This project uses [Ruff](https://docs.astral.sh/ruff/) for both code linting and ```bash ├── app/ -│ ├── alembic/ # Database migration configurations -│ ├── api/ # API Layer: FastAPI routers and route handlers -│ ├── core/ # Core configurations, security, exceptions, logging -│ ├── models/ # Domain Layer: SQLAlchemy definitions -│ ├── repositories/ # Data Layer: Database operations and queries -│ ├── schemas/ # API Layer: Pydantic request/response schemas -│ ├── services/ # Business Logic Layer: Core use cases and orchestration -│ ├── tests/ # Test suite (integration and unit tests) -│ ├── utils/ # Helper functions and shared utilities -│ └── main.py # Application entry point +│ ├── alembic/ # Alembic env + versions/ (generated migration scripts) +│ ├── api/ # API Layer: routers, deps.py, exception handlers, decorators +│ │ └── routes/ +│ │ ├── auth.py, users.py, health.py +│ │ └── admin/ # Admin surface (gated by CurrentSuperUser) +│ ├── core/ # config, db, security, redis, rate_limit, email, messages/ +│ ├── models/ # Domain Layer: SQLAlchemy ORM (User, UserActivity, …) +│ ├── repositories/ # Data Layer: async DB queries (no business rules) +│ ├── schemas/ # Pydantic v2 DTOs (Create / Update / Response per domain) +│ ├── services/ # Business Logic Layer: pure async functions, take AsyncSession +│ ├── use_cases/ # Cross-domain orchestration (e.g. activity logging) +│ ├── worker/ # arq worker — settings + cron jobs (e.g. account deletion) +│ ├── utils/ # Helper functions (datetime, email templates) +│ ├── tests/ # pytest suite (in-memory SQLite, fakeredis, mocked SMTP) +│ └── main.py # FastAPI app, lifespan, CORS + origin middleware ├── alembic.ini # Alembic settings -├── docker-compose.yaml # Docker compose configuration (DB & Redis) -├── Dockerfile # Dockerfile for backend container +├── docker-compose.yaml # Compose stack: backend + worker + db + redis +├── dockerfile # Backend image (uv-based multi-stage build) ├── pyproject.toml # Project dependencies and tool configurations ├── pytest.ini # Pytest settings -└── uv.lock # Dependency lock file +└── uv.lock # Dependency lock file (commit alongside dep changes) ``` --- diff --git a/app/main.py b/app/main.py index d43a77f..e923a79 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from fastapi.routing import APIRoute +from prometheus_fastapi_instrumentator import Instrumentator from slowapi.errors import RateLimitExceeded from starlette.exceptions import HTTPException from starlette.middleware.cors import CORSMiddleware @@ -24,8 +25,14 @@ def custom_generate_unique_id(route: APIRoute) -> str: - """Generate a stable operationId for OpenAPI clients.""" - return f"{route.tags[0]}-{route.name}" + """Generate a stable operationId for OpenAPI clients. + + Falls back to the bare route name for routes without tags (e.g. the + Prometheus /metrics endpoint registered by the instrumentator). + """ + if route.tags: + return f"{route.tags[0]}-{route.name}" + return route.name if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": @@ -109,3 +116,10 @@ async def origin_check_middleware(request: Request, call_next): ) app.include_router(api_router, prefix=settings.API_V1_STR) + +# Prometheus metrics — exposes GET /metrics (outside API_V1_STR so scrapers +# don't need auth headers). Kept out of OpenAPI/Swagger to avoid noise. +# In production, restrict /metrics at the reverse proxy or with allowlists. +Instrumentator( + excluded_handlers=["/metrics", f"{settings.API_V1_STR}/health/.*"], +).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False) diff --git a/pyproject.toml b/pyproject.toml index 6fa28c6..be3628a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "dnspython>=2.8.0", "fastapi[standard]>=0.129.2", "httpx>=0.28.1", + "prometheus-fastapi-instrumentator>=7.1.0", "pydantic-settings>=2.13.1", "pydantic[email]>=2.12.5", "pyjwt>=2.11.0", @@ -28,6 +29,7 @@ dev = [ "fakeredis>=2.35.0", "greenlet>=3.3.2", "httpx>=0.28.1", + "pre-commit>=4.6.0", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "pytest-cov>=7.1.0", diff --git a/uv.lock b/uv.lock index a020326..51a0fa8 100644 --- a/uv.lock +++ b/uv.lock @@ -190,6 +190,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -307,6 +316,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -421,6 +439,7 @@ dependencies = [ { name = "dnspython" }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, + { name = "prometheus-fastapi-instrumentator" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "pyjwt" }, @@ -437,6 +456,7 @@ dev = [ { name = "fakeredis" }, { name = "greenlet" }, { name = "httpx" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -452,6 +472,7 @@ requires-dist = [ { name = "dnspython", specifier = ">=2.8.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.129.2" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "prometheus-fastapi-instrumentator", specifier = ">=7.1.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "pyjwt", specifier = ">=2.11.0" }, @@ -468,6 +489,7 @@ dev = [ { name = "fakeredis", specifier = ">=2.35.0" }, { name = "greenlet", specifier = ">=3.3.2" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -542,6 +564,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, ] +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + [[package]] name = "greenlet" version = "3.3.2" @@ -711,6 +742,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -851,6 +891,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -860,6 +909,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -869,6 +927,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + +[[package]] +name = "prometheus-fastapi-instrumentator" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prometheus-client" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/6d/24d53033cf93826aa7857699a4450c1c67e5b9c710e925b1ed2b320c04df/prometheus_fastapi_instrumentator-7.1.0.tar.gz", hash = "sha256:be7cd61eeea4e5912aeccb4261c6631b3f227d8924542d79eaf5af3f439cbe5e", size = 20220, upload-time = "2025-03-19T19:35:05.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/72/0824c18f3bc75810f55dacc2dd933f6ec829771180245ae3cc976195dec0/prometheus_fastapi_instrumentator-7.1.0-py3-none-any.whl", hash = "sha256:978130f3c0bb7b8ebcc90d35516a6fe13e02d2eb358c8f83887cdef7020c31e9", size = 19296, upload-time = "2025-03-19T19:35:04.323Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1048,6 +1144,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-discovery" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -1445,6 +1554,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] +[[package]] +name = "virtualenv" +version = "21.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" }, +] + [[package]] name = "watchfiles" version = "1.1.1" From ee0729855b2a57b0472ddf1fed8f7fd447918c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= <117113383+kemalcalak@users.noreply.github.com> Date: Wed, 6 May 2026 14:00:39 +0300 Subject: [PATCH 2/6] chore: add pytest as pre-push hook --- .pre-commit-config.yaml | 12 ++++++++++++ README.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7635c4c..a411be0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,3 +16,15 @@ repos: - id: ruff-check args: [--fix] - id: ruff-format + + # Pre-push only — test suite must pass before code leaves the machine. + # Kept out of pre-commit so day-to-day commits stay fast. + - repo: local + hooks: + - id: pytest + name: pytest + entry: uv run pytest + language: system + pass_filenames: false + always_run: true + stages: [pre-push] diff --git a/README.md b/README.md index 601defc..3356e6a 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,36 @@ This project uses [Ruff](https://docs.astral.sh/ruff/) for both code linting and - **Format code:** `uv run ruff format .` - **Auto-fix lint issues:** `uv run ruff check --fix .` +### Git Hooks (pre-commit) + +The repo ships a `.pre-commit-config.yaml`. After cloning, install both hook stages once: + +```bash +uv run pre-commit install --hook-type pre-commit --hook-type pre-push +``` + +**`pre-commit`** (~5–15 s) — runs on every `git commit` against staged files: + +- `trim trailing whitespace`, `end-of-file-fixer`, `check-yaml`, `check-toml`, `check-added-large-files`, `check-merge-conflict`, `detect-private-key` +- `ruff check --fix` (auto-fix lint) +- `ruff format` + +**`pre-push`** (~30–60 s) — runs on every `git push`: + +- `uv run pytest` — full test suite must pass before code leaves the machine. + +To run all hooks manually against the whole repo: `uv run pre-commit run --all-files`. + +### Metrics (Prometheus) + +`prometheus-fastapi-instrumentator` exposes `GET /metrics` (outside `/api/v1`, hidden from Swagger). Default metrics: request count, latency histograms, in-progress requests, exceptions per handler. Health endpoints and `/metrics` itself are excluded from instrumentation to avoid noise. + +```bash +curl http://localhost:8000/metrics +``` + +> In production, restrict `/metrics` at the reverse proxy (e.g. allowlist Prometheus scraper IPs) — it is intentionally unauthenticated for scraper compatibility. + --- ## 📂 Project Structure From 772662b6b056c36dad6d212d10e655051e0c7abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= <117113383+kemalcalak@users.noreply.github.com> Date: Wed, 6 May 2026 14:10:51 +0300 Subject: [PATCH 3/6] feat: gate /metrics in production + add pytest pre-push hook --- .env.example | 4 +++ README.md | 28 +++++++++++++++-- app/core/config.py | 5 ++++ app/main.py | 35 ++++++++++++++++++---- app/tests/test_metrics.py | 63 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 app/tests/test_metrics.py diff --git a/.env.example b/.env.example index 0598f77..f3a1450 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,7 @@ EMAILS_FROM_EMAIL="noreply@example.com" # Sentry (only initialized when ENVIRONMENT != "local") SENTRY_DSN= + +# Prometheus /metrics bearer token. Required outside ENVIRONMENT="local"; without +# it /metrics returns 404. Configure your scraper with: Authorization: Bearer +METRICS_TOKEN= diff --git a/README.md b/README.md index 3356e6a..67f7a7a 100644 --- a/README.md +++ b/README.md @@ -191,13 +191,37 @@ To run all hooks manually against the whole repo: `uv run pre-commit run --all-f ### Metrics (Prometheus) -`prometheus-fastapi-instrumentator` exposes `GET /metrics` (outside `/api/v1`, hidden from Swagger). Default metrics: request count, latency histograms, in-progress requests, exceptions per handler. Health endpoints and `/metrics` itself are excluded from instrumentation to avoid noise. +`prometheus-fastapi-instrumentator` collects metrics for every handled request. The `/metrics` endpoint (root path, **outside `/api/v1`**, hidden from Swagger) exposes them in the standard Prometheus exposition format. Default metrics: request count, latency histograms, in-progress requests, exceptions per handler, plus the standard Python runtime + process metrics. Health endpoints and `/metrics` itself are excluded from instrumentation to avoid noise. + +**Auth model — three layers:** + +1. **`include_in_schema=False`** — the endpoint is invisible in Swagger / OpenAPI. +2. **Environment-gated bearer token** — outside `ENVIRONMENT="local"`, the endpoint requires `Authorization: Bearer ${METRICS_TOKEN}`. Mismatched or missing tokens return **404** (not 401/403) so the endpoint's existence is not disclosed. +3. **`origin_check_middleware`** — browser cross-origin requests with a foreign `Origin` header are rejected by the global middleware, regardless of the token. + +**Local dev — open access:** ```bash curl http://localhost:8000/metrics ``` -> In production, restrict `/metrics` at the reverse proxy (e.g. allowlist Prometheus scraper IPs) — it is intentionally unauthenticated for scraper compatibility. +**Production / staging — bearer token required:** + +```bash +# .env (or your secret store) +METRICS_TOKEN=$(openssl rand -hex 32) + +# Prometheus scrape config (prometheus.yml) +scrape_configs: + - job_name: fastapi + authorization: + type: Bearer + credentials: + static_configs: + - targets: ['api.example.com:8000'] +``` + +> Even with the token, prefer to also restrict `/metrics` at the reverse proxy (Prometheus scraper IP allowlist or VPC-internal-only exposure). Defense-in-depth — the token is one layer, network policy is another. --- diff --git a/app/core/config.py b/app/core/config.py index 0b389a6..06b78f9 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -53,6 +53,11 @@ def all_cors_origins(self) -> list[str]: PROJECT_NAME: str SENTRY_DSN: str | None = None + # Bearer token required to scrape /metrics outside ENVIRONMENT="local". + # When unset (or wrong), /metrics returns 404 to mirror origin_check_middleware + # and avoid disclosing endpoint existence. Local dev keeps /metrics open. + METRICS_TOKEN: str | None = None + SMTP_HOST: str | None = None SMTP_PORT: int = 587 SMTP_USE_STARTTLS: bool = True diff --git a/app/main.py b/app/main.py index e923a79..416550b 100644 --- a/app/main.py +++ b/app/main.py @@ -2,10 +2,11 @@ from contextlib import asynccontextmanager import sentry_sdk -from fastapi import FastAPI, Request +from fastapi import FastAPI, Header, Request from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, Response from fastapi.routing import APIRoute +from prometheus_client import CONTENT_TYPE_LATEST, generate_latest from prometheus_fastapi_instrumentator import Instrumentator from slowapi.errors import RateLimitExceeded from starlette.exceptions import HTTPException @@ -117,9 +118,31 @@ async def origin_check_middleware(request: Request, call_next): app.include_router(api_router, prefix=settings.API_V1_STR) -# Prometheus metrics — exposes GET /metrics (outside API_V1_STR so scrapers -# don't need auth headers). Kept out of OpenAPI/Swagger to avoid noise. -# In production, restrict /metrics at the reverse proxy or with allowlists. +# Prometheus instrumentation — collect metrics for every handled request. +# Health and /metrics itself are excluded from instrumentation to keep noise +# out of the time series. Instrumentator( excluded_handlers=["/metrics", f"{settings.API_V1_STR}/health/.*"], -).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False) +).instrument(app) + + +@app.get("/metrics", include_in_schema=False) +async def metrics_endpoint( + authorization: str | None = Header(default=None), +) -> Response: + """Expose Prometheus metrics in the standard exposition format. + + Local dev keeps the endpoint open for convenience. In every other + environment a bearer token (METRICS_TOKEN) is required; mismatched or + missing tokens return 404 to avoid disclosing the endpoint to outsiders. + """ + if settings.ENVIRONMENT != "local": + expected = ( + f"Bearer {settings.METRICS_TOKEN}" if settings.METRICS_TOKEN else None + ) + if expected is None or authorization != expected: + raise HTTPException( + status_code=404, + detail=ErrorMessages.RESOURCE_NOT_FOUND, + ) + return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/app/tests/test_metrics.py b/app/tests/test_metrics.py new file mode 100644 index 0000000..818577e --- /dev/null +++ b/app/tests/test_metrics.py @@ -0,0 +1,63 @@ +"""Tests for the /metrics endpoint and its environment-gated bearer-token guard.""" + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from app.core.config import settings +from app.main import app + + +@pytest_asyncio.fixture +async def root_client() -> AsyncClient: + """Async client without the /api/v1 prefix — /metrics is mounted at root.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +async def test_metrics_open_in_local(root_client: AsyncClient) -> None: + """Local environment exposes /metrics without authentication.""" + response = await root_client.get("/metrics") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/plain") + # Always-present default metric from prometheus_client + assert "python_gc_objects_collected_total" in response.text + + +async def test_metrics_404_without_token_in_production( + root_client: AsyncClient, monkeypatch: "pytest_asyncio.MonkeyPatch" +) -> None: + """Production hides /metrics when METRICS_TOKEN is not configured.""" + monkeypatch.setattr(settings, "ENVIRONMENT", "production") + monkeypatch.setattr(settings, "METRICS_TOKEN", None) + + response = await root_client.get("/metrics") + assert response.status_code == 404 + + +async def test_metrics_404_with_wrong_token( + root_client: AsyncClient, monkeypatch: "pytest_asyncio.MonkeyPatch" +) -> None: + """Production rejects mismatched bearer tokens with a generic 404.""" + monkeypatch.setattr(settings, "ENVIRONMENT", "production") + monkeypatch.setattr(settings, "METRICS_TOKEN", "correct-secret") + + response = await root_client.get( + "/metrics", headers={"Authorization": "Bearer wrong-secret"} + ) + assert response.status_code == 404 + + +async def test_metrics_200_with_correct_token( + root_client: AsyncClient, monkeypatch: "pytest_asyncio.MonkeyPatch" +) -> None: + """Production allows /metrics scraping when a matching bearer token is sent.""" + monkeypatch.setattr(settings, "ENVIRONMENT", "production") + monkeypatch.setattr(settings, "METRICS_TOKEN", "correct-secret") + + response = await root_client.get( + "/metrics", headers={"Authorization": "Bearer correct-secret"} + ) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/plain") From 560cebb170cac7d6483fd2db4688011c761cc56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= <117113383+kemalcalak@users.noreply.github.com> Date: Wed, 6 May 2026 14:25:30 +0300 Subject: [PATCH 4/6] feat: add opentelemetry tracing and tighten ci --- .env.example | 7 + .github/workflows/test.yml | 14 +- README.md | 26 +++ app/core/metrics.py | 60 +++++++ app/core/telemetry.py | 67 ++++++++ app/main.py | 39 +---- pyproject.toml | 7 + uv.lock | 344 +++++++++++++++++++++++++++++++++++++ 8 files changed, 530 insertions(+), 34 deletions(-) create mode 100644 app/core/metrics.py create mode 100644 app/core/telemetry.py diff --git a/.env.example b/.env.example index f3a1450..e73065b 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,10 @@ SENTRY_DSN= # Prometheus /metrics bearer token. Required outside ENVIRONMENT="local"; without # it /metrics returns 404. Configure your scraper with: Authorization: Bearer METRICS_TOKEN= + +# OpenTelemetry tracing (opt-in). Leave OTEL_EXPORTER_OTLP_ENDPOINT empty to +# disable tracing entirely — no overhead. Set it to an OTLP/HTTP collector +# (Tempo, Jaeger, Honeycomb, otel-collector, etc.) to start exporting traces. +OTEL_EXPORTER_OTLP_ENDPOINT= +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_SERVICE_NAME= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5244d50..1d9c5d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,9 +6,14 @@ on: - main pull_request: +concurrency: + group: test-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout code @@ -26,6 +31,12 @@ jobs: - name: Install dependencies run: uv sync + - name: Ruff Lint + run: uv run ruff check . + + - name: Ruff Format Check + run: uv run ruff format --check . + - name: Run tests env: PROJECT_NAME: "FastAPI Template Test" @@ -35,6 +46,3 @@ jobs: FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER || 'admin@example.com' }} FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD || 'testpassword' }} run: uv run pytest - - - name: Run Ruff - run: uv run ruff check diff --git a/README.md b/README.md index 67f7a7a..4c42a2e 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,32 @@ scrape_configs: > Even with the token, prefer to also restrict `/metrics` at the reverse proxy (Prometheus scraper IP allowlist or VPC-internal-only exposure). Defense-in-depth — the token is one layer, network policy is another. +### Tracing (OpenTelemetry) + +Metrics tell you *what* is slow ("p99 latency on `/users/{id}` doubled at 14:02"). Traces tell you *why* — a single request's full waterfall: route handler → SQLAlchemy queries → Redis calls → outbound httpx requests, each with its own span and timing. + +**Opt-in by design.** When `OTEL_EXPORTER_OTLP_ENDPOINT` is unset, `init_telemetry()` returns early and there is **zero overhead** — no spans created, no exporter started, no extra allocations. Local dev and the test suite stay clean. + +**Wiring (`app/core/telemetry.py`):** + +| Layer | Instrumentation | What you get | +| --- | --- | --- | +| FastAPI | `FastAPIInstrumentor` | Per-request span named after the route template (`/users/{id}`, not `/users/42`); `/metrics` and `/health` excluded | +| SQLAlchemy | `SQLAlchemyInstrumentor` | One span per query, with the SQL statement and duration | +| Redis | `RedisInstrumentor` | One span per Redis call (cache hits, rate-limit checks, pub/sub) | +| httpx | `HTTPXClientInstrumentor` | Outbound HTTP spans (e.g. disposable-email blocklist refresh) | + +**Enabling traces:** point `OTEL_EXPORTER_OTLP_ENDPOINT` at any OTLP/HTTP collector — Tempo, Jaeger, Honeycomb, the OTel Collector, etc. + +```bash +# .env +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_SERVICE_NAME=fastapi-template +``` + +The SDK reads every other `OTEL_*` variable natively (sampler, headers, batch size, etc.) — see the [OTel SDK environment variables reference](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) for the full list. + --- ## 📂 Project Structure diff --git a/app/core/metrics.py b/app/core/metrics.py new file mode 100644 index 0000000..30025dd --- /dev/null +++ b/app/core/metrics.py @@ -0,0 +1,60 @@ +"""Prometheus metrics: instrumentation and the gated /metrics endpoint. + +Two responsibilities live here so ``main.py`` stays uncluttered: + +1. ``prometheus-fastapi-instrumentator`` collects per-request metrics + (count, latency histogram, in-progress gauge, exceptions per handler) + plus the standard Python runtime metrics from ``prometheus_client``. +2. The ``/metrics`` endpoint exposes them in the standard Prometheus + exposition format. Local dev keeps it open; every other environment + requires a bearer token (``METRICS_TOKEN``). Mismatched / missing + tokens return 404 — mirrors ``origin_check_middleware`` so the + endpoint's existence is not disclosed to outsiders. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import Header +from fastapi.responses import Response +from prometheus_client import CONTENT_TYPE_LATEST, generate_latest +from prometheus_fastapi_instrumentator import Instrumentator +from starlette.exceptions import HTTPException + +from app.core.config import settings +from app.core.messages.error_message import ErrorMessages + +if TYPE_CHECKING: + from fastapi import FastAPI + + +def init_metrics(app: FastAPI) -> None: + """Wire Prometheus instrumentation and register the /metrics route.""" + # Health and /metrics itself are excluded from instrumentation to keep + # noise out of the time series. + Instrumentator( + excluded_handlers=["/metrics", f"{settings.API_V1_STR}/health/.*"], + ).instrument(app) + + @app.get("/metrics", include_in_schema=False) + async def metrics_endpoint( + authorization: str | None = Header(default=None), + ) -> Response: + """Expose Prometheus metrics in the standard exposition format. + + Local dev keeps the endpoint open for convenience. In every other + environment a bearer token (``METRICS_TOKEN``) is required; + mismatched or missing tokens return 404 to avoid disclosing the + endpoint to outsiders. + """ + if settings.ENVIRONMENT != "local": + expected = ( + f"Bearer {settings.METRICS_TOKEN}" if settings.METRICS_TOKEN else None + ) + if expected is None or authorization != expected: + raise HTTPException( + status_code=404, + detail=ErrorMessages.RESOURCE_NOT_FOUND, + ) + return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/app/core/telemetry.py b/app/core/telemetry.py new file mode 100644 index 0000000..c38b6bd --- /dev/null +++ b/app/core/telemetry.py @@ -0,0 +1,67 @@ +"""OpenTelemetry trace exporter and auto-instrumentation setup. + +Tracing is opt-in: when ``OTEL_EXPORTER_OTLP_ENDPOINT`` is unset, this +module is a no-op so local dev and tests have zero tracing overhead. + +Once an endpoint is configured (Tempo, Jaeger, Honeycomb, an OTel +collector, etc.), every FastAPI request becomes a trace spanning the +matched route, all SQLAlchemy queries, every Redis call, and any +outbound httpx request — one waterfall view per request. + +Example collector wiring:: + + OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" + OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" + OTEL_SERVICE_NAME="fastapi-template" +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor +from opentelemetry.instrumentation.redis import RedisInstrumentor +from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from app.core.config import settings + +if TYPE_CHECKING: + from fastapi import FastAPI + + +def init_telemetry(app: FastAPI) -> None: + """Wire OTel exporters and auto-instrumentation onto the FastAPI app. + + No-op when ``OTEL_EXPORTER_OTLP_ENDPOINT`` is not set, so it is safe + to call unconditionally from ``main.py``. + """ + if not os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"): + return + + resource = Resource.create( + { + "service.name": os.getenv("OTEL_SERVICE_NAME") or settings.PROJECT_NAME, + "deployment.environment": settings.ENVIRONMENT, + } + ) + provider = TracerProvider(resource=resource) + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(provider) + + # Span name uses the route template (/users/{id}, not /users/42), so + # cardinality stays bounded. /metrics and /health are excluded to keep + # scrape and probe traffic out of the trace stream. + FastAPIInstrumentor.instrument_app( + app, + excluded_urls=f"/metrics,{settings.API_V1_STR}/health", + ) + SQLAlchemyInstrumentor().instrument() + RedisInstrumentor().instrument() + HTTPXClientInstrumentor().instrument() diff --git a/app/main.py b/app/main.py index 416550b..56bfef1 100644 --- a/app/main.py +++ b/app/main.py @@ -2,12 +2,10 @@ from contextlib import asynccontextmanager import sentry_sdk -from fastapi import FastAPI, Header, Request +from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse, Response +from fastapi.responses import JSONResponse from fastapi.routing import APIRoute -from prometheus_client import CONTENT_TYPE_LATEST, generate_latest -from prometheus_fastapi_instrumentator import Instrumentator from slowapi.errors import RateLimitExceeded from starlette.exceptions import HTTPException from starlette.middleware.cors import CORSMiddleware @@ -21,8 +19,10 @@ from app.api.main import api_router from app.core.config import settings from app.core.messages.error_message import ErrorMessages +from app.core.metrics import init_metrics from app.core.rate_limit import limiter from app.core.redis import close_redis, init_redis +from app.core.telemetry import init_telemetry def custom_generate_unique_id(route: APIRoute) -> str: @@ -118,31 +118,8 @@ async def origin_check_middleware(request: Request, call_next): app.include_router(api_router, prefix=settings.API_V1_STR) -# Prometheus instrumentation — collect metrics for every handled request. -# Health and /metrics itself are excluded from instrumentation to keep noise -# out of the time series. -Instrumentator( - excluded_handlers=["/metrics", f"{settings.API_V1_STR}/health/.*"], -).instrument(app) +# OpenTelemetry tracing — no-op unless OTEL_EXPORTER_OTLP_ENDPOINT is set. +init_telemetry(app) - -@app.get("/metrics", include_in_schema=False) -async def metrics_endpoint( - authorization: str | None = Header(default=None), -) -> Response: - """Expose Prometheus metrics in the standard exposition format. - - Local dev keeps the endpoint open for convenience. In every other - environment a bearer token (METRICS_TOKEN) is required; mismatched or - missing tokens return 404 to avoid disclosing the endpoint to outsiders. - """ - if settings.ENVIRONMENT != "local": - expected = ( - f"Bearer {settings.METRICS_TOKEN}" if settings.METRICS_TOKEN else None - ) - if expected is None or authorization != expected: - raise HTTPException( - status_code=404, - detail=ErrorMessages.RESOURCE_NOT_FOUND, - ) - return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) +# Prometheus instrumentation + gated /metrics endpoint. +init_metrics(app) diff --git a/pyproject.toml b/pyproject.toml index be3628a..198bae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,13 @@ dependencies = [ "dnspython>=2.8.0", "fastapi[standard]>=0.129.2", "httpx>=0.28.1", + "opentelemetry-api>=1.41.1", + "opentelemetry-exporter-otlp-proto-http>=1.41.1", + "opentelemetry-instrumentation-fastapi>=0.62b1", + "opentelemetry-instrumentation-httpx>=0.62b1", + "opentelemetry-instrumentation-redis>=0.62b1", + "opentelemetry-instrumentation-sqlalchemy>=0.62b1", + "opentelemetry-sdk>=1.41.1", "prometheus-fastapi-instrumentator>=7.1.0", "pydantic-settings>=2.13.1", "pydantic[email]>=2.12.5", diff --git a/uv.lock b/uv.lock index 51a0fa8..e29b208 100644 --- a/uv.lock +++ b/uv.lock @@ -75,6 +75,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/47/626ad09333ffc73fdd2ed270d81ddd60befd8f5b3c904be132bc0e7ccede/arq-0.25.0-py3-none-any.whl", hash = "sha256:db072d0f39c0bc06b436db67ae1f315c81abc1527563b828955670531815290b", size = 25774, upload-time = "2022-12-02T13:21:15.874Z" }, ] +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -199,6 +208,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -439,6 +521,13 @@ dependencies = [ { name = "dnspython" }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-instrumentation-redis" }, + { name = "opentelemetry-instrumentation-sqlalchemy" }, + { name = "opentelemetry-sdk" }, { name = "prometheus-fastapi-instrumentator" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -472,6 +561,13 @@ requires-dist = [ { name = "dnspython", specifier = ">=2.8.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.129.2" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "opentelemetry-api", specifier = ">=1.41.1" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.41.1" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.62b1" }, + { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.62b1" }, + { name = "opentelemetry-instrumentation-redis", specifier = ">=0.62b1" }, + { name = "opentelemetry-instrumentation-sqlalchemy", specifier = ">=0.62b1" }, + { name = "opentelemetry-sdk", specifier = ">=1.41.1" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=7.1.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, @@ -573,6 +669,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, +] + [[package]] name = "greenlet" version = "3.3.2" @@ -760,6 +868,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -900,6 +1020,191 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/43/b2f0703ff46718ff7b17d7fbf8e9d7f20e26a23c7c325092dd762d09cf9d/opentelemetry_instrumentation_asgi-0.62b1.tar.gz", hash = "sha256:7cf5f5d5c493bbb1edd2bd6d51fa879d964e94048904017258a32ffa47329310", size = 26781, upload-time = "2026-04-24T13:22:37.158Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/41/968c1fe12fb90abffca6620e65d4af91451c02ecca8f74a17a62cac490de/opentelemetry_instrumentation_asgi-0.62b1-py3-none-any.whl", hash = "sha256:b7f89be48528512619bd54fa2459f72afb1695ba71d7024d382ad96d467e7fa8", size = 17011, upload-time = "2026-04-24T13:21:38.006Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/38/91780475a25370b6d483afbaed3e1e170459d6351c5f7c08d66b65e2172e/opentelemetry_instrumentation_fastapi-0.62b1.tar.gz", hash = "sha256:b377d4ba32868fb1ff0f64da3fcdd3aa154d698fc83d65f5d380ea21bf31ee19", size = 25054, upload-time = "2026-04-24T13:22:50.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/6f/602e4081d3fe82731aff7e3e9c2f1662d85701841d6dc25f16a1874e11cd/opentelemetry_instrumentation_fastapi-0.62b1-py3-none-any.whl", hash = "sha256:93fa9cc4f315819aee5f4fceb6196c1e5b0fbd789c5520c631de228bd3e5285b", size = 13484, upload-time = "2026-04-24T13:21:54.538Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/cb/7a418e69c7dad281803529cb4f6de1b747d802cca44c38032668690b4836/opentelemetry_instrumentation_httpx-0.62b1.tar.gz", hash = "sha256:a1fac9bcc3a6ef5996a7990563f1af0798468b2c146de535fd598369383fba7e", size = 24181, upload-time = "2026-04-24T13:22:52.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e0/eca824e9492ccec00e055bdd243aeda8eb7c5eda746d98af4d7a2d97ecf3/opentelemetry_instrumentation_httpx-0.62b1-py3-none-any.whl", hash = "sha256:88614015df451d61bc7e73f22524e6f223611f80b6caad2f6bdcbe05fa0df653", size = 17201, upload-time = "2026-04-24T13:21:58.072Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-redis" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/ff/35414ad80409bd9e472c7959832524c5f2c8f63965af08c41c2b42d3a6a6/opentelemetry_instrumentation_redis-0.62b1.tar.gz", hash = "sha256:2d3c421d95e05ade075bee5becbe34e743b1cdf5bdee2085cb524f88c4f13dcb", size = 14796, upload-time = "2026-04-24T13:23:01.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/37/bc2271f3472e3041eeade8b8da1cfd3b06badae76fe5d0ff135b6285e70c/opentelemetry_instrumentation_redis-0.62b1-py3-none-any.whl", hash = "sha256:9aedd02c1acf631251d1d676634db47da9da04e0a626cd0c7d83fe0eb791d165", size = 15501, upload-time = "2026-04-24T13:22:11.705Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-sqlalchemy" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/53/fa511ab998dd66b4eb66a36d8c262d0604cc5bad7a9c82e923be038dda97/opentelemetry_instrumentation_sqlalchemy-0.62b1.tar.gz", hash = "sha256:bdeac015351a1de057e8ea39f1fe26c9e60ea6bedbf1d5ad6a8262a516b3dc7d", size = 18539, upload-time = "2026-04-24T13:23:03.169Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/c5/aa2abcf8752a435536901636c5d540ba7a2c0ba2c4e98c7d119482e04262/opentelemetry_instrumentation_sqlalchemy-0.62b1-py3-none-any.whl", hash = "sha256:613542ecd52aabeec83d8813b5c287a3fb6c9ac3cd660694c94c0571f066e972", size = 15536, upload-time = "2026-04-24T13:22:14.767Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/1b/aa71b63e18d30a8384036b9937f40f7618f8030a7aa213155fb54f6f2b47/opentelemetry_util_http-0.62b1.tar.gz", hash = "sha256:adf6facbb89aef8f8bc566e2f04624942ba08a7b678b3479a91051a8f4dc70a3", size = 11393, upload-time = "2026-04-24T13:23:12.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/a9d9d32161c1ced61346267db4c9702da54f81ec5dc88214bc65c23f4e9d/opentelemetry_util_http-0.62b1-py3-none-any.whl", hash = "sha256:c57e8a6c19fc422c288e6074e882f506f85030b69b7376182f74f9257b9261f0", size = 9295, upload-time = "2026-04-24T13:22:28.078Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -965,6 +1270,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/72/0824c18f3bc75810f55dacc2dd933f6ec829771180245ae3cc976195dec0/prometheus_fastapi_instrumentator-7.1.0-py3-none-any.whl", hash = "sha256:978130f3c0bb7b8ebcc90d35516a6fe13e02d2eb358c8f83887cdef7020c31e9", size = 19296, upload-time = "2025-03-19T19:35:04.323Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1235,6 +1555,21 @@ hiredis = [ { name = "hiredis" }, ] +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + [[package]] name = "rich" version = "14.3.3" @@ -1737,3 +2072,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/cc/5f6193c32166faee1d2a613f278608e6f3b95b96589d020f0088459c46c9/wrapt-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7ea74fc0bec172f1ae5f3505b6655c541786a5cabe4bbc0d9723a56ac32eb9b9", size = 60443, upload-time = "2026-02-03T02:11:30.869Z" }, { url = "https://files.pythonhosted.org/packages/c4/da/5a086bf4c22a41995312db104ec2ffeee2cf6accca9faaee5315c790377d/wrapt-2.1.1-py3-none-any.whl", hash = "sha256:3b0f4629eb954394a3d7c7a1c8cca25f0b07cefe6aa8545e862e9778152de5b7", size = 43886, upload-time = "2026-02-03T02:11:45.048Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +] From b3639c2639f7006eb1a05d5cb5e1e82b20544fc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= <117113383+kemalcalak@users.noreply.github.com> Date: Wed, 6 May 2026 14:28:07 +0300 Subject: [PATCH 5/6] feat: add opentelemetry tracing and slim main.py --- app/api/exception_handlers.py | 16 ++++- app/core/lifespan.py | 18 +++++ app/core/middleware.py | 70 ++++++++++++++++++++ app/core/openapi.py | 14 ++++ app/core/sentry.py | 16 +++++ app/main.py | 119 +++++----------------------------- 6 files changed, 148 insertions(+), 105 deletions(-) create mode 100644 app/core/lifespan.py create mode 100644 app/core/middleware.py create mode 100644 app/core/openapi.py create mode 100644 app/core/sentry.py diff --git a/app/api/exception_handlers.py b/app/api/exception_handlers.py index 74467d5..fdc58c7 100644 --- a/app/api/exception_handlers.py +++ b/app/api/exception_handlers.py @@ -1,12 +1,13 @@ import logging -from fastapi import Request, status +from fastapi import FastAPI, Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from slowapi.errors import RateLimitExceeded from starlette.exceptions import HTTPException as StarletteHTTPException from app.core.messages.error_message import ErrorMessages +from app.core.rate_limit import limiter logger = logging.getLogger(__name__) @@ -75,3 +76,16 @@ async def rate_limit_exception_handler(_request: Request, exc: RateLimitExceeded "detail": str(exc), }, ) + + +def register_exception_handlers(app: FastAPI) -> None: + """Wire all exception handlers onto the FastAPI app. + + Also stashes the slowapi ``limiter`` on ``app.state`` so the + ``@limiter.limit(...)`` decorator can resolve it from request scope. + """ + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(StarletteHTTPException, http_exception_handler) + app.add_exception_handler(Exception, general_exception_handler) + app.add_exception_handler(RateLimitExceeded, rate_limit_exception_handler) + app.state.limiter = limiter diff --git a/app/core/lifespan.py b/app/core/lifespan.py new file mode 100644 index 0000000..3bff460 --- /dev/null +++ b/app/core/lifespan.py @@ -0,0 +1,18 @@ +"""FastAPI lifespan: process-wide setup and teardown for shared resources.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.core.redis import close_redis, init_redis + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + """Initialise and dispose shared resources (Redis) for the API process.""" + await init_redis() + try: + yield + finally: + await close_redis() diff --git a/app/core/middleware.py b/app/core/middleware.py new file mode 100644 index 0000000..eab92b9 --- /dev/null +++ b/app/core/middleware.py @@ -0,0 +1,70 @@ +"""HTTP middleware registration: strict origin check + CORS. + +The origin check runs on every request and returns a generic 404 for +foreign origins — same shape as ``ErrorMessages.RESOURCE_NOT_FOUND`` so +the API never advertises which origins it actually trusts. CORS is then +layered on top for browser-friendly preflights from allowlisted origins. +""" + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.core.messages.error_message import ErrorMessages + + +def register_middleware(app: FastAPI) -> None: + """Wire the origin check and (when configured) the CORS middleware.""" + + @app.middleware("http") + async def origin_check_middleware(request: Request, call_next): + """Strict origin check. + + Returns 404 for unauthorised origins, allows same-origin requests + (from the API's own origin), and hides error details so the API + never confirms which origins are allowed. + """ + origin = request.headers.get("origin") + if origin: + origin = origin.rstrip("/") + + # Allow same-origin requests + if origin: + scheme = request.url.scheme + host = request.headers.get("host", "").rstrip("/") + request_origin = f"{scheme}://{host}".rstrip("/") + + if origin == request_origin: + return await call_next(request) + + allowed_origins = settings.all_cors_origins + + if origin and allowed_origins and "*" not in allowed_origins: + if origin not in allowed_origins: + return JSONResponse( + status_code=404, + content={ + "success": False, + "error": ErrorMessages.RESOURCE_NOT_FOUND, + }, + ) + return await call_next(request) + + # ``allow_credentials=True`` combined with the ``"*"`` wildcard origin + # is unsafe — some browsers honour it and would let any origin issue + # authenticated requests. Refuse that combination outright. + if settings.all_cors_origins: + if "*" in settings.all_cors_origins: + raise RuntimeError( + "CORS misconfiguration: wildcard origin '*' cannot be combined " + "with credentialed requests. Set explicit origins in " + "BACKEND_CORS_ORIGINS / FRONTEND_HOST." + ) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.all_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) diff --git a/app/core/openapi.py b/app/core/openapi.py new file mode 100644 index 0000000..ef6fd77 --- /dev/null +++ b/app/core/openapi.py @@ -0,0 +1,14 @@ +"""OpenAPI schema customisations.""" + +from fastapi.routing import APIRoute + + +def custom_generate_unique_id(route: APIRoute) -> str: + """Generate a stable operationId for OpenAPI clients. + + Falls back to the bare route name for routes without tags (e.g. the + Prometheus /metrics endpoint registered by the instrumentator). + """ + if route.tags: + return f"{route.tags[0]}-{route.name}" + return route.name diff --git a/app/core/sentry.py b/app/core/sentry.py new file mode 100644 index 0000000..f2d1d5d --- /dev/null +++ b/app/core/sentry.py @@ -0,0 +1,16 @@ +"""Sentry error-tracking init. + +Sentry is opt-in: it only initialises when ``SENTRY_DSN`` is set *and* +``ENVIRONMENT`` is not ``local``. That keeps developer machines and the +test suite from polluting the project's Sentry quota with noise. +""" + +import sentry_sdk + +from app.core.config import settings + + +def init_sentry() -> None: + """Initialise Sentry if a DSN is configured and we are not running locally.""" + if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": + sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) diff --git a/app/main.py b/app/main.py index 56bfef1..d2ad015 100644 --- a/app/main.py +++ b/app/main.py @@ -1,54 +1,23 @@ -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager +"""FastAPI application entrypoint. -import sentry_sdk -from fastapi import FastAPI, Request -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse -from fastapi.routing import APIRoute -from slowapi.errors import RateLimitExceeded -from starlette.exceptions import HTTPException -from starlette.middleware.cors import CORSMiddleware +Heavy lifting lives in dedicated modules under ``app.core`` and +``app.api``; this file only wires them together so a reader can see the +whole composition at a glance. +""" -from app.api.exception_handlers import ( - general_exception_handler, - http_exception_handler, - rate_limit_exception_handler, - validation_exception_handler, -) +from fastapi import FastAPI + +from app.api.exception_handlers import register_exception_handlers from app.api.main import api_router from app.core.config import settings -from app.core.messages.error_message import ErrorMessages +from app.core.lifespan import lifespan from app.core.metrics import init_metrics -from app.core.rate_limit import limiter -from app.core.redis import close_redis, init_redis +from app.core.middleware import register_middleware +from app.core.openapi import custom_generate_unique_id +from app.core.sentry import init_sentry from app.core.telemetry import init_telemetry - -def custom_generate_unique_id(route: APIRoute) -> str: - """Generate a stable operationId for OpenAPI clients. - - Falls back to the bare route name for routes without tags (e.g. the - Prometheus /metrics endpoint registered by the instrumentator). - """ - if route.tags: - return f"{route.tags[0]}-{route.name}" - return route.name - - -if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": - sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) - - -@asynccontextmanager -async def lifespan(_: FastAPI) -> AsyncIterator[None]: - """Initialize and dispose shared resources (Redis) for the API process.""" - await init_redis() - try: - yield - finally: - await close_redis() - +init_sentry() app = FastAPI( title=settings.PROJECT_NAME, @@ -57,69 +26,11 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: lifespan=lifespan, ) -# Exception Handlers -app.add_exception_handler(RequestValidationError, validation_exception_handler) -app.add_exception_handler(HTTPException, http_exception_handler) -app.add_exception_handler(Exception, general_exception_handler) -app.add_exception_handler(RateLimitExceeded, rate_limit_exception_handler) -app.state.limiter = limiter - - -@app.middleware("http") -async def origin_check_middleware(request: Request, call_next): - """ - Strict origin check. Returns 404 for unauthorized origins. - Allows same-origin requests (from the API's own origin). - Hides error details and ensures always 404 for these cases. - """ - origin = request.headers.get("origin") - if origin: - origin = origin.rstrip("/") - - # Allow same-origin requests - if origin: - # Reconstruct the request's own origin from scheme and host header - scheme = request.url.scheme - host = request.headers.get("host", "").rstrip("/") - request_origin = f"{scheme}://{host}".rstrip("/") - - # Allow if origins match (same-origin request) - if origin == request_origin: - return await call_next(request) - - allowed_origins = settings.all_cors_origins - - if origin and allowed_origins and "*" not in allowed_origins: - if origin not in allowed_origins: - return JSONResponse( - status_code=404, - content={"success": False, "error": ErrorMessages.RESOURCE_NOT_FOUND}, - ) - return await call_next(request) - - -# Set all CORS enabled origins. -# allow_credentials + "*" is unsafe: some browsers honour it and would let any -# origin issue authenticated requests. Refuse that combination outright. -if settings.all_cors_origins: - if "*" in settings.all_cors_origins: - raise RuntimeError( - "CORS misconfiguration: wildcard origin '*' cannot be combined " - "with credentialed requests. Set explicit origins in " - "BACKEND_CORS_ORIGINS / FRONTEND_HOST." - ) - app.add_middleware( - CORSMiddleware, - allow_origins=settings.all_cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - +register_exception_handlers(app) +register_middleware(app) app.include_router(api_router, prefix=settings.API_V1_STR) # OpenTelemetry tracing — no-op unless OTEL_EXPORTER_OTLP_ENDPOINT is set. init_telemetry(app) - # Prometheus instrumentation + gated /metrics endpoint. init_metrics(app) From 330cf81183430bd3e4ede7635b3917050de3bf13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= <117113383+kemalcalak@users.noreply.github.com> Date: Wed, 6 May 2026 15:32:31 +0300 Subject: [PATCH 6/6] docs: add /review slash command and REVIEW.md for PR audits --- .claude/commands/review.md | 369 +++++++++++++++++++++++++++++++++++++ REVIEW.md | 333 +++++++++++++++++++++++++++++++++ 2 files changed, 702 insertions(+) create mode 100644 .claude/commands/review.md create mode 100644 REVIEW.md diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 0000000..15b5e44 --- /dev/null +++ b/.claude/commands/review.md @@ -0,0 +1,369 @@ +--- +allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(gh pr comment:*), Bash(gh pr diff:*), Bash(gh pr view:*), Bash(gh pr list:*), Bash(gh api:*) +description: Code review a pull request against Fastapi-Template conventions +disable-model-invocation: false +--- + +# /review — Fastapi-Template PR Review Pipeline + +You are orchestrating a multi-agent code review pipeline for a pull request in the **Fastapi-Template** (FastAPI 0.129 + Python 3.12 + uv + async SQLAlchemy 2.0 + asyncpg + Pydantic v2 + Redis + slowapi + arq + Alembic + OpenTelemetry + Prometheus + Sentry + pytest) repository. Every agent is grounded in this repo's conventions, captured in `REVIEW.md` at the repo root. + +## Inputs + +- `$ARGUMENTS` may contain a PR number or URL. If empty, default to the PR for the current branch (resolve via `gh pr view --json number,url,headRefOid,baseRefName,state,isDraft,title,author,body`). +- All `gh` calls must use `--json` with explicit fields. Never parse human-readable output. + +## Pipeline + +Execute the eight steps below in order. Each step that says "spawn an agent" must use the Agent tool. Agent prompts are written in English (more reliable for model instructions); the final user-facing comment is in **Turkish** (the project's working language). + +--- + +### Step 1 — Eligibility check (Sonnet) + +Spawn a `general-purpose` agent with `model: "sonnet"`. Prompt: + +> You are gating a code review. Decide whether this PR is eligible. +> +> Run `gh pr view --json state,isDraft,author,title,body,labels,headRefOid,baseRefName,url,comments` and respond with strict JSON: +> `{ "eligible": boolean, "reason": string, "headSha": string, "baseRef": string, "url": string, "owner": string, "repo": string, "number": number }`. +> +> Mark `eligible: false` if any of the following hold: +> +> - PR `state` is not `OPEN` +> - PR `isDraft` is `true` +> - The author login matches a bot pattern (`*[bot]`, `dependabot`, `renovate`, `github-actions`) +> - The title or body indicates an auto-generated release/version-bump (e.g., `chore(release):`, `Bump dependency`, "production deploy" merges) +> - There is already a comment from a previous `/review` run (search comment bodies for the marker ``) +> +> Otherwise `eligible: true`. Return only the JSON. + +If `eligible` is false, abort the pipeline and print the reason. Do not post any comment. + +Capture `headSha`, `owner`, `repo`, `number`, `url` for later steps. + +--- + +### Step 2 — REVIEW.md path discovery (Sonnet) + +Spawn a `general-purpose` agent with `model: "sonnet"`. Prompt: + +> Find the path to `REVIEW.md` at the repository root for the PR head SHA ``. Use `gh api repos///contents/REVIEW.md?ref= --jq '.path'`. If it does not exist at root, search the repo tree for any file named `REVIEW.md` via `gh api repos///git/trees/?recursive=1 --jq '.tree[] | select(.path | endswith("REVIEW.md")) | .path'` and pick the shortest path. +> +> Return strict JSON: `{ "reviewMdPath": string | null }`. + +If `reviewMdPath` is null, post a single comment to the PR explaining `REVIEW.md` could not be found and abort. Do not flag any issues without it (false-positive risk is too high). + +--- + +### Step 3 — PR summary (Sonnet) + +Spawn a `general-purpose` agent with `model: "sonnet"`. Prompt: + +> Summarize the PR for downstream reviewers. +> +> Run `gh pr view --json title,body,additions,deletions,changedFiles,files` and `gh pr diff ` (truncate to first ~3000 lines if larger). +> +> Output strict JSON: +> +> ``` +> { +> "title": string, +> "intent": string, // 1-2 sentences in English describing the change goal +> "touchedAreas": string[], // domain folders, e.g. ["app/api/routes", "app/services", "app/repositories", "app/models", "app/alembic/versions", "app/core"] +> "changedFiles": [{ "path": string, "additions": number, "deletions": number, "status": string }], +> "diffExcerpt": string // truncated unified diff (max 80k chars), used by downstream agents +> } +> ``` + +Pass this object to the five parallel reviewers in Step 4. + +--- + +### Step 4 — Five parallel Sonnet reviewers + +Spawn the following five agents **in parallel** in a single message, all with `subagent_type: "general-purpose"` and `model: "sonnet"`. Each must return strict JSON of the shape: + +```json +{ + "issues": [ + { + "file": "app/...", + "startLine": 12, + "endLine": 18, + "category": "review-md-compliance | bug | history | past-comment | code-comment", + "severity": "blocker | major | minor", + "title": "Short Turkish phrase", + "explanation": "Turkish, 1-3 sentences. Cite REVIEW.md section or concrete reasoning.", + "evidence": "Optional verbatim quote from REVIEW.md or the changed code", + "suggestion": "Turkish, one-line concrete fix" + } + ] +} +``` + +#### Agent #1 — REVIEW.md compliance + +> You are auditing a Fastapi-Template PR against the repository's conventions captured in `REVIEW.md` at the repo root. +> +> 1. Fetch `REVIEW.md` content: `gh api repos///contents/?ref= --jq '.content' | base64 -d`. +> 2. Read the full PR diff (provided by the orchestrator as `diffExcerpt`, plus run `gh pr diff ` if needed). +> 3. For every **changed line** (only lines added or modified in this PR), check whether it violates a rule in REVIEW.md sections **3 (Konvansiyonlar)**, **4 (Anti-pattern'ler)** or **5 (Review'da KESİNLİKLE Flag'lenecekler)**. +> 4. Apply REVIEW.md section **6 (Görmezden Gelinecekler)** as a hard filter — if a finding falls under that list, drop it. +> +> Anchor every issue to a section/quote from REVIEW.md (`evidence` field). Do not invent rules. Do not flag pre-existing code that the PR did not touch. Output the JSON schema above. + +#### Agent #2 — Shallow bug scan (FastAPI + async SQLAlchemy + Pydantic v2 specific) + +> You are looking for concrete FastAPI / async-SQLAlchemy / Pydantic / Python bugs in the changed lines of this Fastapi-Template PR. +> +> Read the diff via `gh pr diff `. Only flag bugs in **added or modified lines**. Focus on: +> +> **Layer architecture (`api → services → repositories → models`):** +> +> - Route calling repository directly without going through a service. +> - Service calling another service (use `use_cases/` or repository). +> - `Depends()` placed in service or repository function signatures (only allowed in route handlers). +> - Business logic (validation, policy, transform) inside `api/routes/` instead of services. +> - Repository function raising `HTTPException` (HTTP semantics belong in service/route, not persistence). +> - Class-based service or repository (this repo uses pure async functions only). +> +> **Async / SQLAlchemy 2.0:** +> +> - Sync DB call (`session.query(...)` SQLAlchemy 1.x style; `session.execute(...)` not awaited). +> - `await` missing on an async call → coroutine returned to caller. +> - `expire_on_commit=False` overridden somewhere new. +> - N+1 query in a list endpoint (await inside a Python `for` over rows). +> - `with_for_update()` lock removed from concurrent-mutation paths (e.g., deactivate/reactivate). +> - New `create_async_engine`/`async_sessionmaker` instance outside `app/core/db.py`. +> +> **Auth & security:** +> +> - `jwt.encode/decode` placed outside `app/core/security.py`. +> - `bcrypt` bypass — password compared with `==` or hashed with anything other than `get_password_hash`. +> - Plain-text password being logged, returned in response, or written to error message. +> - Protected route missing `Depends(get_current_user)` / `CurrentUser` / `CurrentActiveUser` / `CurrentSuperUser`. +> - `is_token_blacklisted` check skipped in custom auth resolution. +> - Token (JWT) leaked into URL query, path, log, or HTTPException detail. +> - Cookie path/domain/HttpOnly/SameSite/Secure flags changed without coordination (`access_token` "/", `refresh_token` `f"{API_V1_STR}/auth/refresh"`). +> - `verify_token` called without `expected_type` enforcement — wrong-type tokens may be accepted. +> - Sensitive endpoint (login, forgot-password, reset, account delete/reactivate, admin mutations) missing rate-limit decorator. +> - Rate-limit decorator added but route signature is missing `request: Request` (slowapi cannot resolve scope → runtime exception). +> - New `Limiter(...)` instance created outside `app/core/rate_limit.py`. +> +> **Messages / i18n:** +> +> - Inline error string (`detail="User not found"`) instead of `ErrorMessages.X`. +> - Inline success string instead of `SuccessMessages.X`. +> - New constant used but not declared in `core/messages/error_message.py` or `success_message.py` → `AttributeError`. +> - Backend message in Turkish or other natural-language string instead of `error..` i18n key format. +> +> **Pydantic v2 / schemas:** +> +> - ORM model returned directly as response (without `response_model` Pydantic schema mapping). +> - Schema missing `model_config = ConfigDict(from_attributes=True)` while reading from ORM. +> - `field_validator` raising with inline string instead of `ErrorMessages.X`. +> - Sensitive fields (`hashed_password`, `password`, JWT) leaking into a `*Public` schema. +> - `dict()` / `parse_obj()` (Pydantic v1 deprecated) instead of `model_dump()` / `model_validate()`. +> +> **Migrations & models:** +> +> - Model field/index/constraint changed but no new migration in `app/alembic/versions/`. +> - Partial index / GIN index in `__table_args__` removed (Alembic autogenerate would keep proposing drops). +> - `passive_deletes=True` relationship without `ondelete="CASCADE"` on the FK side. +> - Raw `op.execute(...)` in migration without idempotency/justification. +> +> **Config & env:** +> +> - `os.getenv(...)` / `os.environ[...]` direct usage — must go through `app.core.config.settings`. +> - New env field used in code but not added to `Settings` class → runtime `AttributeError`. +> - New `Settings` field but `.env.example` not updated. +> - Secret with literal default fallback (`os.getenv("SECRET_KEY", "default")`) — `_check_default_secret` is intentional. +> +> **Audit logging:** +> +> - `request.client.host` / `request.headers["user-agent"]` read manually inside route — must go through `log_activity` use case. +> - Critical mutation route missing `@audit_unexpected_failure` decorator. +> +> **Observability:** +> +> - Duplicate `Sentry.init(...)` / `TracerProvider(...)` outside `app/core/sentry.py` / `app/core/telemetry.py`. +> - `/metrics` `METRICS_TOKEN` Bearer enforcement loosened or removed. +> - `/metrics` or `/health` removed from instrumentation excluded list. +> - OTel opt-in env-gate (`OTEL_EXPORTER_OTLP_ENDPOINT`) removed. +> +> **Python / typing:** +> +> - New `Any` annotation (Pyright `reportAny=error`). +> - New unjustified `# type: ignore` (existing ones have inline rationale). +> - Relative import `from ..foo import bar` (Ruff TID252 disallows). +> - Function lacking docstring (CLAUDE.md hard rule). +> - Docstring written in a language other than English. +> +> **Tests:** +> +> - Test that hits real SMTP/Redis/Postgres (autouse fixtures `mock_email_send`, `fake_redis`, `override_get_db` overridden). +> - New endpoint without test (auth/security/account-mutation → major; otherwise minor). +> - Async test missing `pytest-asyncio` setup or breaking `asyncio_mode = "auto"` config. +> +> **Origin check / CORS:** +> +> - `allow_credentials=True` combined with `"*"` origin (middleware already raises `RuntimeError`, but if PR weakens that check → blocker). +> - Origin-check 404 response shape changed (it intentionally mirrors `RESOURCE_NOT_FOUND` to avoid leaking allowed origins). +> +> Use **REVIEW.md section 6 (Görmezden Gelinecekler)** as a filter — anything in that list must be dropped, even if technically suboptimal. Output strict JSON per the shared schema. + +#### Agent #3 — Git blame & history + +> Investigate whether the PR's changes contradict prior intent in the same files. +> +> For each changed file, run `gh api repos///commits?path=&per_page=20 --jq '.[] | {sha,commit:.commit.message,author:.commit.author.name}'` and `gh pr list --state merged --search "" --json number,title,url --limit 10`. +> +> Flag an issue when: +> +> - The PR reverts a recent fix (commit message indicates a bug fix on the same lines/region within the last 90 days). +> - The PR re-introduces a pattern that a previous commit explicitly removed (look for `revert`, `fix`, `refactor` messages on the same path). +> - A recent commit on the same area suggests a constraint the PR may break (e.g., `app/main.py`, `app/api/deps.py`, `app/core/security.py`, `app/core/config.py`, `app/core/middleware.py`, `app/core/rate_limit.py`, `app/api/exception_handlers.py` are tightly coordinated — changes to one without the others may break a prior fix). +> +> Be conservative: only flag with `severity: major` or `blocker` if the contradiction is direct and the prior commit message is explicit. Otherwise omit. Output strict JSON. + +#### Agent #4 — Past PR comments on touched files + +> Discover whether reviewers previously raised concerns on the files this PR touches. +> +> For each changed file, run `gh search prs --repo / --json number,title,url ""` and for the top 5 results fetch `gh api repos///pulls//comments --jq '.[] | {path,line,body,user:.user.login}'`. +> +> Flag if: +> +> - A reviewer previously rejected an identical pattern that is being reintroduced here. +> - A previously-agreed convention (e.g., "use `ErrorMessages` not inline string", "go through service not repository", "no `Depends()` in services", "Alembic migration required for model change", "rate-limit decorator on sensitive endpoints", "log_activity for audit, not manual `request.client.host`") is being violated again. +> +> Quote the prior comment in `evidence`. Skip noise. Output strict JSON. + +#### Agent #5 — Inline code-comment compliance & Python typing + +> Audit comments, docstrings, and Python typing added by this PR. The repo style discourages narrating the obvious; use REVIEW.md section 5 (KESİNLİKLE Flag'lenecekler) and CLAUDE.md hard rules. +> +> Flag: +> +> - New comments that describe **what** the code does when the identifier already says it (`# fetch user — get_user_by_id(...)`). +> - Comments that reference the current ticket or PR (`# added for issue X`, `# per Ali's review`) — these rot in the codebase. +> - `TODO` / `FIXME` without a tracker reference or owner. +> - Stale comments that contradict adjacent code after the change. +> - **Function added without a docstring** ([CLAUDE.md:111](CLAUDE.md) hard rule — minimum one English line). +> - Docstring written in Turkish (or any non-English language). +> - New `Any` annotation introduced — Pyright `reportAny=error` will fail. Use `unknown`-ish patterns (generics, narrowing, `object`). +> - New unjustified `# type: ignore` — existing ones have rationale (`[prop-decorator]`, etc.). +> - Relative import `from ..foo import bar` (Ruff TID252). +> - Unused function argument without `_` prefix (Ruff ARG001). +> - `print(...)` left in production-impacting code (services, repositories, core/) — use `logging`. +> - `logger.info(token)` / similar token or password leakage into logs. +> - Function complexity exceeding ruff/pyproject limits (very long functions; split into use_case/repository helpers). +> - Inline error/success string `detail="..."` even in a docstring "raises" example — this normalises the anti-pattern. +> +> Do NOT flag commented-out code unless it is clearly leftover debug. Output strict JSON. + +--- + +### Step 5 — Per-issue confidence scoring (Haiku) + +Aggregate all `issues` from the five reviewers. For each issue, spawn a `general-purpose` agent with `model: "haiku"` (or batch them in a single Haiku call passing an array — preferred for speed; Haiku is sufficient for the 0/25/50/75/100 bucketing task). Prompt: + +> Score the confidence that this is a real, actionable issue worth posting on a PR review. +> +> Use the rubric below — pick the closest band, then return the integer. +> +> - **0**: Not an issue. Misreads the diff, contradicts REVIEW.md "Görmezden Gelinecekler", or describes a hypothetical that the code does not actually do. +> - **25**: Probably noise. Generic best-practice rather than a repo-specific rule. Linter/Pyright would catch it. +> - **50**: Plausible but unverified. The reasoning is sound but lacks a direct REVIEW.md anchor or a concrete reproduction. +> - **75**: Likely correct. Anchored in REVIEW.md or a clear FastAPI/async-SQLAlchemy/Pydantic bug pattern, with a specific file:line reference; minor uncertainty about user intent. +> - **100**: Definitely correct. Bug or rule violation visible in the diff, with verbatim REVIEW.md quote or unambiguous reasoning. Suggested fix is concrete. +> +> Input: `{ issue: , reviewMdExcerpt: }`. +> Output strict JSON: `{ "confidence": 0|25|50|75|100, "reason": "one short sentence" }`. + +--- + +### Step 6 — Filter + +Drop every issue whose `confidence < 70`. The threshold is fixed. + +With the rubric's 0/25/50/75/100 bands, a threshold of 70 admits the **75 ("Likely correct")** and **100 ("Definitely correct")** tiers and rejects everything ≤ 50. + +If no issues remain, post a comment indicating a clean review (still mark with the `` sentinel) and end. + +--- + +### Step 7 — Re-check eligibility + +Re-run Step 1 quickly (the PR may have been closed, marked draft, or already commented during the run). If now ineligible, abort without posting. + +--- + +### Step 8 — Post the review comment + +Build the comment in **Turkish**. For each surviving issue, generate a permanent GitHub link using the **full head SHA** captured in Step 1: + +``` +https://github.com///blob//#L-L +``` + +Comment template: + +```markdown +## /review — N bulgu + + + +### 1. + +**Dosya:** [#L-L]() +**Kategori:** · **Önem:** + + + +> (REVIEW.md alıntısı veya kod kanıtı — varsa) + +**Öneri:** + +--- + +### 2. ... + +--- + +🤖 Generated with [Claude Code](https://claude.ai/code) +``` + +If `N == 0`, the body is: + +```markdown +## /review — temiz + + + +Bu PR'da REVIEW.md kurallarına aykırı veya tespit edilebilir bir bug bulunamadı. + +🤖 Generated with [Claude Code](https://claude.ai/code) +``` + +Post via: + +``` +gh pr comment --body-file +``` + +Use `--body-file` (not `--body`) to preserve markdown formatting and avoid shell-escaping issues. + +--- + +## Hard rules for the orchestrator + +- **Never edit files.** This command only reviews. +- **Never post more than one comment per run.** All findings are batched into a single comment. +- **Never post without `REVIEW.md`** — abort instead. The whole pipeline relies on it for false-positive control. +- **Always use the full head SHA** in permalinks, captured at Step 1, even if the PR receives new commits during the run. The review applies to the SHA you actually read. +- **Never flag pre-existing code** the PR did not modify. The "changed lines" rule is non-negotiable for sections 3-5 of REVIEW.md. +- **Confidence threshold is 70**. Do not lower it; do not weight your own opinion against the Haiku score. +- **Do not skip Step 7.** A PR that flipped to draft or got closed mid-run must not receive a comment. +- **Agent prompts are English; the user-facing comment is Turkish.** diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..f47beca --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,333 @@ +# REVIEW.md — Fastapi-Template Code Review Rehberi + +`/review` slash komutunun PR audit'inde referans aldığı repo-spesifik konvansiyonlar. Her madde gerçek bir dosyaya `path:line` ile bağlıdır. Generic best-practice yoktur. + +--- + +## 1. Stack + +| Katman | Seçim | +|---|---| +| Framework | FastAPI 0.129 (`fastapi[standard]`) | +| Runtime | Python 3.12+ (`requires-python = ">=3.12"`) | +| Package mgr | **uv** (pip yasak) | +| Tip kontrolü | Pyright `strict` mode + `reportAny=error` | +| Lint/Format | Ruff (E,W,F,I,B,C4,UP,ARG001,TID252,ANN401) | +| ORM | SQLAlchemy 2.0 async + asyncpg | +| Validation | Pydantic v2 + pydantic-settings (`@t3-oss/env`-vari) | +| Auth | PyJWT (HS256) + bcrypt + HttpOnly cookie / Bearer fallback | +| Cache / Rate limit / Token blacklist | Redis 7 | +| Rate limit | slowapi (memory in local, async-redis in prod) | +| Background jobs | arq (Redis-backed) | +| Migrations | Alembic | +| Observability | OpenTelemetry (opt-in via env), Prometheus (gated `/metrics`), Sentry | +| Test | pytest + pytest-asyncio + httpx ASGI + aiosqlite + fakeredis | +| Pre-commit / Pre-push | pre-commit (ruff) / pytest | +| CI | GitHub Actions (ruff + pytest) | + +Kanıt: [pyproject.toml](pyproject.toml), [.pre-commit-config.yaml](.pre-commit-config.yaml), [.github/workflows/test.yml](.github/workflows/test.yml). + +--- + +## 2. Klasör Yapısı (katmanlı: `api → services → repositories → models`) + +``` +app/ +├── main.py # 37 satır — sadece composition (init_sentry, register_*, init_*) +├── api/ +│ ├── deps.py # SessionDep, CurrentUser, CurrentActiveUser, CurrentSuperUser +│ ├── decorators.py # audit_unexpected_failure +│ ├── exception_handlers.py# register_exception_handlers + slowapi limiter wiring +│ ├── main.py # api_router (health, auth, users, admin) +│ └── routes/{auth,users,health}.py + admin/ +├── services/ # async fonksiyonlar — class YASAK +│ ├── auth_service.py +│ ├── user_service.py +│ └── admin/ +├── repositories/ # async DB queries — class YASAK +│ ├── user.py +│ ├── user_activity.py +│ ├── token_blacklist.py +│ └── admin/ +├── use_cases/ # cross-cutting (log_activity) — birden fazla servisten çağrılan +├── models/ # SQLAlchemy DeclarativeBase — sadece ORM, mantık yok +├── schemas/ # Pydantic DTO'lar (Create/Update/Response ayrı) +├── core/ +│ ├── config.py # pydantic-settings — boot-time validate +│ ├── db.py # async engine + AsyncSessionLocal +│ ├── security.py # JWT + bcrypt +│ ├── messages/{error,success}_message.py # ⚠️ Tüm hata/başarı string'leri +│ ├── lifespan.py # init/close redis +│ ├── middleware.py # origin_check + CORS +│ ├── exception_handlers içinde (api/) ama register middleware/metrics/telemetry/sentry/openapi +│ ├── rate_limit.py # slowapi limiter + decorator factory'ler +│ ├── redis.py +│ ├── metrics.py # Prometheus instrumentator + gated /metrics +│ ├── telemetry.py # OTel — OTEL_EXPORTER_OTLP_ENDPOINT set ise +│ ├── sentry.py +│ ├── openapi.py # custom_generate_unique_id +│ └── email.py +├── worker/ # arq jobs (delete_expired_accounts vs.) +├── utils/ # pure helpers (datetime_utils, email_templates) +├── alembic/ # migration scripts +└── tests/ # pytest — sqlite+aiosqlite + fakeredis +``` + +Yeni domain: model → schema → repository → service → route (her katman sırayla). Model değiştiyse **Alembic migration zorunlu**. + +--- + +## 3. Konvansiyonlar (kanıtlı) + +### 3.1 Katman akışı (tek yönlü) +- **`api → services → repositories → models`** — bu sıra bozulamaz. +- `api/routes/` repository çağırmaz, **her zaman service üzerinden** geçer ([CLAUDE.md:84](CLAUDE.md)). +- Services başka service çağırmaz — paylaşılan repo fonksiyonu veya `use_cases/` ile çözülür. +- `Depends()` **sadece route handler'larda** ([deps.py:107-111](app/api/deps.py)). Service/repository fonksiyonları parametre olarak `session: AsyncSession` alır. +- Route'tan ham ORM objesi değil, Pydantic schema response döner ([users.py:25-28](app/api/routes/users.py): `response_model=UserPublic`). + +### 3.2 Async-only +- **Sync DB call yasak.** Tüm queries `async/await`. SQLAlchemy 2.0 `select(...)` + `await session.execute(...)` paterni ([repositories/user.py:17-21](app/repositories/user.py)). +- Engine [core/db.py:11-19](app/core/db.py): `create_async_engine` + `async_sessionmaker(expire_on_commit=False)`. `pool_pre_ping=True`. + +### 3.3 No classes (services/repositories) +- **Services ve repositories saf async fonksiyonlardır** ([CLAUDE.md:98](CLAUDE.md)). Class wrapper ekleme. +- Repository fonksiyonu: `async def _(session: AsyncSession, ...)` ([repositories/user.py:12-43](app/repositories/user.py)). + +### 3.4 Pydantic schemas +- Domain başına `Create`/`Update`/`UpdateMe`/`Public`/`UpdateResponse` ayrı sınıflar ([schemas/user.py:34-79](app/schemas/user.py)). +- `model_config = ConfigDict(from_attributes=True)` ORM → Pydantic mapping için zorunlu ([schemas/user.py:22](app/schemas/user.py)). +- Validator hata mesajları **`ErrorMessages.X`**'tan gelir, hardcode yasak ([schemas/user.py:43](app/schemas/user.py)). +- `field_validator` + `@classmethod` paterni ([schemas/user.py:39-44](app/schemas/user.py)). + +### 3.5 Error/Success messages — single source of truth +- **Tüm hata mesajları [app/core/messages/error_message.py](app/core/messages/error_message.py)** — `ErrorMessages.X` class attribute olarak. +- **Tüm başarı mesajları [app/core/messages/success_message.py](app/core/messages/success_message.py)**. +- Inline string kesinlikle yasak (`detail="User not found"` ❌; `detail=ErrorMessages.USER_NOT_FOUND` ✅) ([CLAUDE.md:107, 123-126](CLAUDE.md)). +- i18n key'leri `error..` veya `error.account.` formatında — frontend bu key'i çevirir. +- Sistem hataları (validation, 401, 403, 404, 409, 429, 500) için yukarı seviye sabitler [error_message.py:1-9](app/core/messages/error_message.py). + +### 3.6 Auth & güvenlik +- JWT logic **sadece [app/core/security.py](app/core/security.py)** — başka yerde `jwt.encode/decode` yasak. HS256. +- Token tipleri: `access`, `refresh`, `password_reset`, `new_account` — `type` claim ile ayrılır ([security.py:88-90](app/core/security.py)). Yanlış tip → `None`. +- Password hashing **bcrypt** ([security.py:107-140](app/core/security.py)) — plain-text password log/return yasak. +- Auth flow: cookie `access_token` (HttpOnly) öncelikli, fallback `Authorization: Bearer` ([deps.py:42](app/api/deps.py)). +- `is_token_blacklisted` Redis kontrolü her `get_current_user` çağrısında ([deps.py:51-56](app/api/deps.py)). +- `CurrentUser` deletion grace window'undakileri kabul eder; `CurrentActiveUser` red eder ([deps.py:31-91](app/api/deps.py)). Süpper-admin için `CurrentSuperUser` ([deps.py:94-103](app/api/deps.py)). +- Logout/deactivate token blacklist'e eklenir ([user_service.py:74-80](app/services/user_service.py)). +- Cookie path: `access_token` "/", `refresh_token` `f"{API_V1_STR}/auth/refresh"` ([users.py:82-85](app/api/routes/users.py)). + +### 3.7 Rate limiting +- `slowapi` ile [core/rate_limit.py](app/core/rate_limit.py). Local'de in-memory, prod'da `async+redis://...` (replica'lar arası paylaşım). +- Üç decorator factory: `rate_limit_public` (10/min default), `rate_limit_authenticated` (100/min), `rate_limit_strict` (3/min — şifre reset/hesap silme gibi). +- Decorator route'ta kullanılır ve **route signature'ında `request: Request` zorunlu** (slowapi limiter scope'tan okur). +- Limiter `app.state.limiter` üzerinden register edilir ([exception_handlers.py:91](app/api/exception_handlers.py)). + +### 3.8 Audit logging +- `@audit_unexpected_failure(activity_type, resource_type, endpoint)` decorator'u ([api/decorators.py:31-71](app/api/decorators.py)) — beklenmeyen exception'da `log_activity` ile kayıt + re-raise. +- Service'lerde manuel `log_activity` çağrısı login fail / deactivate fail gibi domain-spesifik durumlarda ([user_service.py:55-64](app/services/user_service.py)). +- `log_activity` use case [use_cases/log_activity.py](app/use_cases/log_activity.py) — request'ten IP + user-agent extract eder. +- IP/user-agent **sadece** `log_activity` üzerinden alınır; route içinde manuel `request.client.host` yazılmaz. + +### 3.9 Migrations +- Model değişti → **Alembic migration zorunlu** ([CLAUDE.md:108](CLAUDE.md)). `uv run alembic revision --autogenerate -m "..."`. +- Partial/GIN index gibi özel index'ler model'in `__table_args__`'ında deklare edilir ([models/user.py:17-46](app/models/user.py)) — autogenerate aksi takdirde drop önerir. +- `passive_deletes=True` + FK `ON DELETE CASCADE` paterni ([models/user.py:78-83](app/models/user.py)). + +### 3.10 Config & env +- **`os.getenv`/`os.environ` doğrudan kullanılmaz** — `from app.core.config import settings`. +- `pydantic-settings` boot-time validate eder ([core/config.py:24-29](app/core/config.py)). `extra="ignore"`, `env_ignore_empty=True`. +- `_check_default_secret` `"changethis"` değerlerini local dışında **fail** eder ([core/config.py:112-121](app/core/config.py)). `SECRET_KEY` non-local'de explicit env zorunlu ([core/config.py:135-139](app/core/config.py)). +- Yeni env: `Settings` field'ı + `.env.example` güncelleme. + +### 3.11 main.py composition +[app/main.py](app/main.py) sadece kompozisyon — 37 satır, mantık yok: +1. `init_sentry()` (lifespan başlamadan) +2. `FastAPI(...)` instance + `lifespan` + `custom_generate_unique_id` +3. `register_exception_handlers(app)` (slowapi limiter `app.state`'e burada bağlanır) +4. `register_middleware(app)` (origin_check + CORS) +5. `app.include_router(api_router, prefix=settings.API_V1_STR)` +6. `init_telemetry(app)` (OTel — `OTEL_EXPORTER_OTLP_ENDPOINT` set değilse no-op) +7. `init_metrics(app)` (Prometheus + gated `/metrics`) + +Yeni global concern → kendi modülüne `register_X(app)` / `init_X(app)` ile eklenir, `main.py` şişmez. + +### 3.12 Origin check + CORS +- [core/middleware.py](app/core/middleware.py): yabancı origin'e **404** döner (`ErrorMessages.RESOURCE_NOT_FOUND`) — hangi origin'lerin trusted olduğunu sızdırmaz. +- `allow_credentials=True` + `"*"` wildcard kombinasyonu **runtime'da reddedilir** ([middleware.py:57-63](app/core/middleware.py)) — explicit origin gerekir. +- Same-origin request'lere izin verilir ([middleware.py:32-39](app/core/middleware.py)). + +### 3.13 Metrics endpoint +- [core/metrics.py](app/core/metrics.py): `/metrics` non-local'de `Authorization: Bearer ` ister; aksi halde **404** (origin check ile aynı şekil — endpoint varlığını sızdırmaz). +- `/health/.*` ve `/metrics` instrumentation'dan exclude edilir. + +### 3.14 OpenTelemetry (opt-in) +- [core/telemetry.py](app/core/telemetry.py): `OTEL_EXPORTER_OTLP_ENDPOINT` set değilse **no-op return**, sıfır overhead. +- Auto-instrument: `FastAPIInstrumentor` + `SQLAlchemyInstrumentor` + `RedisInstrumentor` + `HTTPXClientInstrumentor`. +- `/metrics` ve `/health` excluded. + +### 3.15 Test +- `app/tests/` altında `test_*.py`. **In-memory SQLite + aiosqlite** ([conftest.py:14-24](app/tests/conftest.py)). +- Her test fresh DB (autouse fixture); `fake_redis` autouse fixture ile gerçek Redis swap'lanır. +- `httpx.AsyncClient` + `ASGITransport` ile FastAPI app'e direkt vurulur ([conftest.py:60-68](app/tests/conftest.py)). +- `mock_email_send` ve `mock_email_validation` autouse — gerçek SMTP/MX lookup test'lerden bypass edilir. +- `pytest-asyncio` `asyncio_mode = "auto"` ([pyproject.toml:46-49](pyproject.toml)). + +### 3.16 Naming & docstring +- Modül adı snake_case; class adı PascalCase; fonksiyon snake_case. +- **Her fonksiyonun docstring'i olmalı** — minimum bir satır, İngilizce ([CLAUDE.md:111](CLAUDE.md)). +- Repository: `_` (`get_user_by_id`, `create_user`, `deactivate_user`). +- Service: `__service` (`update_user_service`, `deactivate_own_account_service`). +- Schema: `` (`UserCreate`, `UserUpdate`, `UserUpdateMe`, `UserPublic`). + +### 3.17 Commit / hooks +- Conventional Commits zorunlu (`feat:`, `fix:`, `refactor:`, `chore:`, `docs:`). +- Pre-commit: ruff check (--fix) + ruff format. Pre-push: `uv run pytest` ([.pre-commit-config.yaml:22-31](.pre-commit-config.yaml)). +- `uv.lock` dependency değişikliği ile birlikte commit edilir ([CLAUDE.md:109](CLAUDE.md)). `pip install` yasak. + +--- + +## 4. Anti-Pattern'ler (kaçınılır) + +1. **Class-based service/repository.** Sadece async fonksiyonlar. ([CLAUDE.md:98](CLAUDE.md)) +2. **Service/repository içinde `Depends()`.** Sadece route handler'da; aşağıya parametre olarak geçilir. +3. **Route'tan repository'ye doğrudan çağrı** — service katmanı atlanmamalı. +4. **Service başka service çağırma** — `use_cases/` veya repository. +5. **Sync DB call.** Hepsi `async/await`. +6. **Inline error/success string** (`detail="User not found"`) — `ErrorMessages.X` zorunlu. +7. **JWT işlemini `core/security.py` dışında yapma** (`jwt.encode/decode` başka modülde). +8. **`os.getenv`/`os.environ` doğrudan** — `settings`. +9. **Plain-text password log/return.** +10. **`Authorization: Bearer` header'ı doğrulamadan kullanma** (cookie öncelikli, blacklist kontrolü zorunlu). +11. **Model değişimini Alembic migration'sız bırakma.** +12. **Ham ORM objesi response** — Pydantic schema mapping zorunlu. +13. **`pip install`** — `uv add`. +14. **`uv.lock` commit etmeden dependency değişimi.** +15. **`request.client.host`/`request.headers["user-agent"]` route içinde manuel okuma** — `log_activity` use case kullanılır. +16. **Yeni `slowapi` limiter instance yaratma** — tek `limiter` [core/rate_limit.py](app/core/rate_limit.py) üzerinden gider. +17. **`@router.` üzerinde `request: Request` parametresi olmadan rate-limit decorator** — slowapi scope alamaz, runtime exception. +18. **`allow_credentials=True` + `"*"` CORS wildcard** — middleware bunu zaten reddeder, ama PR'da gelirse blocker. + +--- + +## 5. Review'da KESİNLİKLE Flag'lenecekler + +### 5.1 Katman ihlali +- **Route → repository doğrudan çağrı** (service atlanmış). +- **Service → service çağrısı** (use_case veya repository olmalı). +- **Service/repository signature'ında `Depends()`.** +- **Route handler'da business logic** (validation/transform/policy) — service'e taşınmalı. +- Repository fonksiyonunda HTTPException raise (repository persistence katmanıdır; HTTP servis/route işi). + +### 5.2 Async / DB +- **Sync DB call** (`session.query(...)` SQLAlchemy 1.x stili, `session.execute` await edilmemiş). +- `await` unutulmuş async çağrı → coroutine return ediliyor. +- `expire_on_commit=False` override'ı (session config kırılır). +- N+1 query: list endpoint'inde her item için ek `await session.execute(...)`. +- **`with_for_update()` lock'u kaldırma** ([repositories/user.py:63-65](app/repositories/user.py)) — concurrent deactivate/reactivate race condition. + +### 5.3 Mesaj/i18n +- **Inline `detail="..."` string** — `ErrorMessages.X` zorunlu. +- `ErrorMessages`'a yeni eklenmeden kullanılmış sabit (NameError). +- Backend i18n key formatı dışında kalan ham mesaj (`detail="Bir hata oluştu"`). +- Success mesajının `SuccessMessages` dışından gelmesi. + +### 5.4 Auth & güvenlik +- **`jwt.encode/decode` `core/security.py` dışında** — token logic single source of truth. +- **`get_password_hash`/`verify_password` bypass'i** — bcrypt kullanılmamış. +- **Password log'lama / response'a koyma** (`return {"password": ...}`). +- **`get_current_user` bypass eden route** (`Depends(get_current_user)` olmadan korumalı route). +- **`is_token_blacklisted` kontrolünü atlama.** +- Token'ı URL query/path/log/error'a sızdırma. +- **Cookie path/domain ayarını değiştirme** (`access_token` "/", `refresh_token` `f"{API_V1_STR}/auth/refresh"`) — diğer parça kırılır. +- HttpOnly/SameSite/Secure flag'larını kaldırma. +- **`@router.delete("/users/me")` veya silme/şifre değişimi gibi endpoint'lerde rate limit decorator yok**. +- `verify_token` `expected_type` parametresini değiştirme/kaldırma — yanlış tip token kabul edilebilir. + +### 5.5 Pydantic / Schemas +- **ORM modeli response'ta** — `response_model` Pydantic schema olmalı. +- `from_attributes=True` eksik schema (ORM mapping çalışmaz). +- `field_validator` hata mesajının inline string olması. +- `UserPublic`'e password/hashed_password sızması. +- `model_dump()` yerine `dict()` (Pydantic v2'de deprecated). + +### 5.6 Migrations / models +- **Model değişikliği var ama `app/alembic/versions/` altında migration yok.** +- Migration file'ında `op.execute(...)` raw SQL — gerekçe yoksa flag (idempotent değil olabilir). +- `__table_args__`'tan partial/GIN index silinmesi ([models/user.py:17-46](app/models/user.py)) — autogenerate sürekli drop önerir. +- `passive_deletes=True` ile birlikte FK'da `ondelete="CASCADE"` eksikliği. +- Yeni `unique` constraint migration'ında index drop/create sıralaması yanlış. + +### 5.7 Config / env +- **`os.getenv("X")` / `os.environ["X"]` doğrudan kullanım** — `settings.X`. +- Yeni env değeri `Settings`'e eklenmemiş — runtime `AttributeError`. +- `.env.example` güncellenmemiş yeni env. +- `SECRET_KEY` veya benzer secret'ı default literal'la fallback (`os.getenv("SECRET_KEY", "default")`). + +### 5.8 Rate limit / audit +- Hassas endpoint (`/auth/login`, `/auth/forgot-password`, `/users/me` DELETE, `/users/me/reactivate`) decorator'sız. +- Rate limit decorator var ama `request: Request` parametresi route signature'ında yok. +- **Yeni `Limiter(...)` instance** [core/rate_limit.py](app/core/rate_limit.py) dışında. +- Beklenmeyen failure'lı route'ta `@audit_unexpected_failure` eksik (kritik domain mutation'larında). +- `log_activity` parametre olmadan IP/user-agent manuel toplanmış (`request.client.host` route içinde). + +### 5.9 Tests +- Yeni endpoint için test yok (auth/security/payment-impacting'se major). +- Test'te gerçek SMTP/Redis'e vurma (autouse fixture'ları override etmiş). +- `get_db` override edilmemiş test (production DB'ye bağlanır). +- `pytest-asyncio` decorator'ları olmadan async test (`@pytest.mark.asyncio` veya autouse mode olmazsa skip). +- DB değiştiren test fixture'ı temizlemiyor (test isolation kırılır). + +### 5.10 Type / lint +- **`Any` kullanımı** — Pyright `reportAny=error`. `unknown` veya generic narrow olmalı. +- `# type: ignore` gerekçesiz ekleme ([config.py:45, 97](app/core/config.py) gibi yorum açıklayıcı olmalı). +- Function return type annotation eksik (Ruff ANN). +- Ruff `E,W,F,I,B,C4,UP,ARG001,TID252` ihlali. +- Relative import (`from ..foo import bar`) — Ruff TID252 yasak. +- Unused function argument (Ruff ARG001). + +### 5.11 Docstring & file size +- **Yeni fonksiyonun docstring'i yok** ([CLAUDE.md:111](CLAUDE.md)). +- Türkçe docstring (İngilizce zorunlu). + +### 5.12 Observability +- [core/sentry.py](app/core/sentry.py)/[core/telemetry.py](app/core/telemetry.py) içine ek `Sentry.init(...)` / `TracerProvider(...)` çağrısı (duplicate init). +- `/metrics` veya `/health` instrumentation exclude listesinden çıkarılması. +- `METRICS_TOKEN` Bearer kontrolünün gevşetilmesi/kaldırılması ([metrics.py](app/core/metrics.py)). +- OTel `init_telemetry`'nin opt-in (`OTEL_EXPORTER_OTLP_ENDPOINT` env) kontrolünü kaldırma. + +--- + +## 6. Görmezden Gelinecekler (false-positive) + +Bu maddeleri **flag etme**: + +1. **Ruff/Pyright zaten yakalıyorsa** (unused import, unused arg, missing return type) — pre-commit/CI fail eder. +2. **PR diff'inde olmayan satır** — pre-existing kabul. +3. **Mevcut `# type: ignore` yorumlu/gerekçeli** ([config.py:45, 97](app/core/config.py) `[prop-decorator]` gibi). +4. **Formatting** (line-length, quote style) — ruff format halleder. +5. **Eski dosyada `Any`/inline string PR'da değişmediyse**. Yeni eklenen flag. +6. **TODO** ticket/sahip referansı varsa kabul; sahipsizse minor. +7. **Test eksikliği** non-critical endpoint için minor öneri, blocking değil. Auth/payment/silme'de major. +8. **`response_model` opsiyonel return tip annotation** (FastAPI zaten serialize eder). +9. **Generic best-practice** önerileri (clean code, hexagonal, DDD) — bu repo idiomatic FastAPI; mimari öneri review skopu dışı. +10. **`B904`** (raise from) — pyproject'te ignored, gerekçe orada. +11. **`B008`** (Depends() default) — pyproject'te ignored, FastAPI paterni. +12. **`E501`** line too long — formatter halleder. +13. **`async def` içinde `await` yok** uyarısı — bilinçli olabilir (FastAPI dependency uyumluluğu). +14. **`HTTPException` raise hem service hem route'ta** — repo bilinçli ([CLAUDE.md:117](CLAUDE.md)). Sadece repository'de raise flag. +15. **Print/console** — ruff yakalar, low-noise. + +--- + +## 7. Review Yazma Stili + +- Türkçe, kısa, doğrudan. +- "Bu değişiklik X kuralını ihlal ediyor: " — soyut kural değil somut kanıt. +- Her bulgu: **dosya + satır + kural + öneri** (tek satırda anlaşılır düzeltme). +- Confidence < 80 ise yorumlama (slash komutu zaten filtreliyor). +- "Görmezden gelinecekler" listesindeki şeyleri yorumlama. + +--- + +Son güncelleme: 2026-05-06.