Skip to content
Merged

Dev #11

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
```

---

Expand Down
87 changes: 65 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 7 additions & 9 deletions apiforgepy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
"""

import atexit
import os

from .aggregator import Aggregator
from .database import ApiForgeDatabase
Expand All @@ -24,7 +23,7 @@
from .transport import LocalTransport
from .cloud_transport import CloudTransport

__version__ = "2.2.1"
__version__ = "3.0.0"
__all__ = ["ApiForgeMiddleware"]


Expand All @@ -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'].
Expand All @@ -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)

Expand All @@ -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"],
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [],
)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading