diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff066b..b78aaf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,39 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — versioning ## [Unreleased] +--- + +## [3.0.0] — 2026-06-04 + +### Breaking Changes + +- `flush_interval` parameter **removed** from `ApiForgeMiddleware` — passing it now raises `TypeError`. The flush window is fixed at **60 seconds**. +- `env` no longer falls back to `os.environ.get("ENV")` — must be passed explicitly. Default is now `'production'`. +- `release` no longer falls back to `os.environ.get("APP_VERSION")` — must be passed explicitly. Default is now `None`. + ### Added -- `bytes_avg` field: average response body size (bytes) per route per bucket, sourced from the `Content-Length` response header — stored in SQLite and exposed via `/api/routes` -- 4 unit tests covering `bytes_avg` aggregation and storage +- `bytes_avg` field: average response body size (bytes) per route per bucket, sourced from the `Content-Length` response header +- `inflight_avg` and `inflight_max` per route — inflight concurrency count captured via ASGI scope and aggregated per minute bucket + +### Migration guide + +```python +# Before (v2.x) +app.add_middleware( + ApiForgeMiddleware, + flush_interval=30_000, # ← remove (TypeError in v3) + env=os.environ.get("ENV", "production"), # ← pass explicitly + release=os.environ.get("APP_VERSION"), # ← still OK (your app reads the env var) +) + +# After (v3.0) +app.add_middleware( + ApiForgeMiddleware, + env="production", # set explicitly + release="v1.4.0", # set explicitly +) +``` --- diff --git a/README.md b/README.md index c604312..75ea9b4 100644 --- a/README.md +++ b/README.md @@ -27,60 +27,103 @@ from apiforgepy import ApiForgeMiddleware app = FastAPI() -app.add_middleware( - ApiForgeMiddleware, - mode="local", -) +app.add_middleware(ApiForgeMiddleware) @app.get("/users/{user_id}") def get_user(user_id: int): return {"id": user_id} -# Dashboard → http://localhost:4242 +# Dashboard auto-starts at http://localhost:4242 ``` +## Dashboard + +Open **http://localhost:4242** after starting your app. No configuration needed — the dashboard server starts automatically in the background. + +- **Health Score** (0–100) — global API health at a glance +- **Latency percentiles** — P50 / P90 / P99 per route +- **Error rates** — 4xx and 5xx breakdown +- **Automatic insights** — latency anomalies, dead endpoints, release regressions +- **Time series chart** — click any route to see its latency over time + +Data is stored locally in `.apiforge.db` (SQLite). Nothing leaves your machine. + ## Configuration ```python app.add_middleware( ApiForgeMiddleware, - mode="local", db_path=".apiforge.db", - dashboard_port=4242, # set to 0 to disable - flush_interval=60_000, # ms + dashboard_port=4242, # set to 0 to disable env="production", - release="v1.4.0", # enables release regression detection + release="v1.4.0", # enables release regression detection service="user-service", - sampling=1.0, # 0.0–1.0 + sampling=1.0, # 0.0–1.0 sample rate ignore_paths=["/health", "/favicon.ico"], ) ``` +## Cloud mode + +Send metrics to the APIForge SaaS platform instead of storing them locally: + +```python +app.add_middleware( + ApiForgeMiddleware, + cloud_url=os.environ["APIFORGE_CLOUD_URL"], + api_key=os.environ["APIFORGE_API_KEY"], + service="user-service", + env="production", + release=os.environ.get("APP_VERSION"), +) +``` + +In cloud mode, metrics are aggregated in memory for 60 seconds and sent as a single batch — the local dashboard and SQLite database are not used. + +## Release tracking + +Pass your release version to enable before/after deployment comparison: + +```python +import os +app.add_middleware(ApiForgeMiddleware, release=os.environ.get("APP_VERSION")) +``` + +When a new release is detected, APIForge compares P90 latency before and after and surfaces regressions automatically. + ## What you get -- **Latency percentiles** — P50 / P90 / P99 per endpoint, updated every 60s -- **Error rate by route** — 2xx / 4xx / 5xx breakdown in real time -- **API Health Score** — a single 0–100 score summarizing your API's health -- **Automatic insights** — plain-language alerts with no configuration -- **Dead endpoint detection** — routes with no traffic in 21+ days -- **Release impact tracking** — before/after comparison on every deploy +- **Per-route latency** — P50, P90, P99 per endpoint, updated every 60 s +- **Error rate by route** — 2xx / 3xx / 4xx / 5xx breakdown +- **API Health Score** — a single 0–100 score summarising your API's health +- **Ghost route detection** — requests that match no declared Starlette/FastAPI route +- **Latency anomaly alerts** — Z-score detection against a 7-day baseline +- **Dead endpoint detection** — routes with no traffic for 21+ days +- **Release regression analysis** — automatic P90 comparison per deploy +- **Progressive drift detection** — slow latency increases over weeks +- **Untracked route detection** — declared routes that never received traffic +- **Inflight concurrency tracking** — `inflight_avg` and `inflight_max` per route ## Graceful shutdown +Cleanup (flush buffer, close dashboard, close SQLite) happens automatically via `atexit`. For explicit control in long-running processes: + ```python -import signal +from contextlib import asynccontextmanager +from fastapi import FastAPI +from apiforgepy import ApiForgeMiddleware -mw = None +forge = None @asynccontextmanager async def lifespan(app): yield - if mw: - mw.shutdown() + if forge: + forge.shutdown() app = FastAPI(lifespan=lifespan) -mw = ApiForgeMiddleware.__new__(ApiForgeMiddleware) -app.add_middleware(ApiForgeMiddleware, mode="local") +forge = ApiForgeMiddleware(app) # store reference before adding +app.add_middleware(ApiForgeMiddleware) ``` ## Privacy by design diff --git a/apiforgepy/__init__.py b/apiforgepy/__init__.py index 0bf4c80..7481099 100644 --- a/apiforgepy/__init__.py +++ b/apiforgepy/__init__.py @@ -15,7 +15,6 @@ """ import atexit -import os from .aggregator import Aggregator from .database import ApiForgeDatabase @@ -24,7 +23,7 @@ from .transport import LocalTransport from .cloud_transport import CloudTransport -__version__ = "2.2.1" +__version__ = "3.0.0" __all__ = ["ApiForgeMiddleware"] @@ -39,9 +38,8 @@ class ApiForgeMiddleware(_Base): api_key: Cloud mode: project API key starting with 'af_'. db_path: Local mode: SQLite file path. Default: '.apiforge.db'. dashboard_port: Local mode: dashboard port. 0 = disabled. Default: 4242. - flush_interval: Aggregation flush interval in ms. Default: 60 000. - env: Environment label. Default: ENV env var or 'production'. - release: Release tag. Default: APP_VERSION env var. + env: Environment label. Default: 'production'. + release: Release tag. Default: None. service: Service name. Default: 'default'. sampling: Sample rate 0.0–1.0. Default: 1.0. ignore_paths: Paths to exclude. Default: ['/favicon.ico']. @@ -55,12 +53,12 @@ def __init__( api_key: str | None = None, db_path: str = ".apiforge.db", dashboard_port: int = 4242, - flush_interval: int = 60_000, env: str | None = None, release: str | None = None, service: str = "default", sampling: float = 1.0, ignore_paths: list[str] = None, + _flush_interval: int = 60_000, # internal — not part of the public API ): is_cloud = bool(cloud_url and api_key) @@ -69,8 +67,8 @@ def __init__( config = { "mode": "cloud" if is_cloud else "local", - "env": env or os.environ.get("ENV", "production"), - "release": release or os.environ.get("APP_VERSION"), + "env": env or "production", + "release": release, "service": service, "sampling": sampling, "ignore_paths": ignore_paths or ["/favicon.ico"], @@ -88,7 +86,7 @@ def __init__( transport = LocalTransport(self._db) config["store_routes"] = self._db.upsert_known_routes - aggregator = Aggregator(transport, flush_interval) + aggregator = Aggregator(transport, _flush_interval) aggregator.start() if not is_cloud and dashboard_port: diff --git a/pyproject.toml b/pyproject.toml index 07cd111..a129927 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "apiforgepy" -version = "2.2.1" +version = "3.0.0" description = "API observability & intelligence for FastAPI/Starlette — local-first, privacy-first" readme = "README.md" diff --git a/tests/test_middleware.py b/tests/test_middleware.py index f0566c3..425ef84 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -11,7 +11,7 @@ def make_app(db_path=":memory:", sampling=1.0, ignore_paths=None): ApiForgeMiddleware, db_path=db_path, dashboard_port=0, - flush_interval=999_999, + _flush_interval=999_999, sampling=sampling, ignore_paths=ignore_paths or [], ) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 06560da..0798d7e 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -11,7 +11,7 @@ def make_app(db_path=":memory:"): ApiForgeMiddleware, db_path=db_path, dashboard_port=0, - flush_interval=999_999, + _flush_interval=999_999, ) @app.get("/health")