From 443b4806157dbfa6854ed4f173dbee1203550671 Mon Sep 17 00:00:00 2001 From: Fabien83560 Date: Fri, 15 May 2026 23:31:40 +0200 Subject: [PATCH 1/4] feat: add cloud mode transport with circuit breaker and lat_avg metric --- apiforgepy/__init__.py | 78 ++++++++++++++++++++--------------- apiforgepy/aggregator.py | 2 + apiforgepy/cloud_transport.py | 71 +++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 34 deletions(-) create mode 100644 apiforgepy/cloud_transport.py diff --git a/apiforgepy/__init__.py b/apiforgepy/__init__.py index 3cf70c2..a827c0a 100644 --- a/apiforgepy/__init__.py +++ b/apiforgepy/__init__.py @@ -2,18 +2,26 @@ apiforgepy — API observability & intelligence for FastAPI/Starlette. Local-first, privacy-first. Dashboard on port 4242. -Usage: +Usage (local): from apiforgepy import ApiForgeMiddleware - app.add_middleware(ApiForgeMiddleware, mode="local") + app.add_middleware(ApiForgeMiddleware) + +Usage (cloud): + app.add_middleware( + ApiForgeMiddleware, + cloud_url="https://api.apiforge.fr", + api_key="af_...", + ) """ import os -from .aggregator import Aggregator -from .database import ApiForgeDatabase -from .dashboard import start_dashboard -from .middleware import ApiForgeMiddleware as _Base -from .transport import LocalTransport +from .aggregator import Aggregator +from .database import ApiForgeDatabase +from .dashboard import start_dashboard +from .middleware import ApiForgeMiddleware as _Base +from .transport import LocalTransport +from .cloud_transport import CloudTransport __version__ = "0.1.0" __all__ = ["ApiForgeMiddleware"] @@ -21,32 +29,29 @@ class ApiForgeMiddleware(_Base): """ - Starlette/FastAPI middleware for APIForge local-first observability. + Starlette/FastAPI middleware for APIForge observability. Parameters ---------- app: The ASGI app to wrap. - mode: Storage mode. Only 'local' (SQLite) in v0.x. - db_path: SQLite file path. Default: '.apiforge.db' - dashboard_port: Port for the built-in dashboard. Set 0 to disable. Default: 4242. + cloud_url: Cloud mode: SaaS API base URL (e.g. 'https://api.apiforge.fr'). + 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: NODE_ENV or 'production'. - release: Release tag for deployment correlation. Default: APP_VERSION env var. - service: Service name for multi-service setups. Default: 'default'. + env: Environment label. Default: ENV env var or 'production'. + release: Release tag. Default: APP_VERSION env var. + service: Service name. Default: 'default'. sampling: Sample rate 0.0–1.0. Default: 1.0. ignore_paths: Paths to exclude. Default: ['/favicon.ico']. """ - _instance_db = None - _instance_transport = None - _instance_aggregator = None - _instance_dashboard = None - def __init__( self, app, *, - mode: str = "local", + cloud_url: str | None = None, + api_key: str | None = None, db_path: str = ".apiforge.db", dashboard_port: int = 4242, flush_interval: int = 60_000, @@ -56,12 +61,13 @@ def __init__( sampling: float = 1.0, ignore_paths: list[str] = None, ): - if mode != "local": - raise ValueError(f"[apiforgepy] mode '{mode}' is not yet supported. Use 'local'.") + is_cloud = bool(cloud_url and api_key) + + if (cloud_url and not api_key) or (api_key and not cloud_url): + raise ValueError("[apiforgepy] Cloud mode requires both cloud_url and api_key.") config = { - "mode": mode, - "db_path": db_path, + "mode": "cloud" if is_cloud else "local", "env": env or os.environ.get("ENV", "production"), "release": release or os.environ.get("APP_VERSION"), "service": service, @@ -69,21 +75,25 @@ def __init__( "ignore_paths": ignore_paths or ["/favicon.ico"], } - db = ApiForgeDatabase(db_path) - transport = LocalTransport(db) - aggregator = Aggregator(transport, flush_interval) + self._db = None + + if is_cloud: + transport = CloudTransport(cloud_url, api_key, service) + else: + self._db = ApiForgeDatabase(db_path) + transport = LocalTransport(self._db) + + aggregator = Aggregator(transport, flush_interval) aggregator.start() - if dashboard_port: - start_dashboard(db, dashboard_port) + if not is_cloud and dashboard_port: + start_dashboard(self._db, dashboard_port) - self._db = db - self._transport = transport self._aggregator_ref = aggregator - super().__init__(app, aggregator=aggregator, config=config) def shutdown(self): - """Flush remaining buffer and close the SQLite connection.""" + """Flush remaining buffer and close resources.""" self._aggregator_ref.stop() - self._db.close() + if self._db: + self._db.close() diff --git a/apiforgepy/aggregator.py b/apiforgepy/aggregator.py index 96bc461..add2824 100644 --- a/apiforgepy/aggregator.py +++ b/apiforgepy/aggregator.py @@ -78,6 +78,7 @@ def _flush(self): n = len(sorted_d) sizes = bucket["response_sizes"] bytes_avg = sum(sizes) / len(sizes) if sizes else None + lat_avg = sum(bucket["durations"]) / n if n > 0 else None rows.append({ "bucket_ts": bucket_ts, "route": bucket["route"], @@ -91,6 +92,7 @@ def _flush(self): "lat_p50": _percentile(sorted_d, 0.50), "lat_p90": _percentile(sorted_d, 0.90), "lat_p99": _percentile(sorted_d, 0.99), + "lat_avg": lat_avg, "lat_min": sorted_d[0] if sorted_d else 0, "lat_max": sorted_d[-1] if sorted_d else 0, "bytes_avg": bytes_avg, diff --git a/apiforgepy/cloud_transport.py b/apiforgepy/cloud_transport.py new file mode 100644 index 0000000..a9ebe1d --- /dev/null +++ b/apiforgepy/cloud_transport.py @@ -0,0 +1,71 @@ +import json +import time +import threading +import urllib.request +import urllib.error +from datetime import datetime, timezone + +_CIRCUIT_OPEN_S = 60 +_FAILURE_THRESHOLD = 5 + + +class CloudTransport: + """Sends aggregated metrics to the APIForge SaaS ingest endpoint.""" + + def __init__(self, cloud_url: str, api_key: str, service: str): + self._url = cloud_url.rstrip("/") + "/ingest" + self._api_key = api_key + self._service = service + self._failures = 0 + self._open_until = 0.0 + self._lock = threading.Lock() + + def write(self, rows: list[dict]) -> None: + if not rows: + return + if time.monotonic() < self._open_until: + return + + metrics = [ + { + "route": r["route"], + "method": r["method"], + "service": self._service, + "env": r["env"], + "release": r.get("release_tag"), + "time": datetime.fromtimestamp(r["bucket_ts"], tz=timezone.utc).isoformat(), + "calls_total": r["total_calls"], + "calls_2xx": r["status_2xx"], + "calls_4xx": r["status_4xx"], + "calls_5xx": r["status_5xx"], + "lat_p50": r.get("lat_p50"), + "lat_p90": r.get("lat_p90"), + "lat_p99": r.get("lat_p99"), + "lat_avg": r.get("lat_avg"), + "bytes_avg": r.get("bytes_avg"), + } + for r in rows + ] + + payload = json.dumps({"metrics": metrics}).encode() + req = urllib.request.Request( + self._url, + data=payload, + headers={"Content-Type": "application/json", "X-API-Key": self._api_key}, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=10): + with self._lock: + self._failures = 0 + except (urllib.error.URLError, OSError) as exc: + with self._lock: + self._failures += 1 + if self._failures >= _FAILURE_THRESHOLD: + self._open_until = time.monotonic() + _CIRCUIT_OPEN_S + self._failures = 0 + print( + f"[apiforgepy] Cloud flush failures — pausing for {_CIRCUIT_OPEN_S}s. " + f"Error: {exc}" + ) From c5a5451b8ffac15c80b098f594d347ca2cfd745b Mon Sep 17 00:00:00 2001 From: Fabien83560 Date: Fri, 15 May 2026 23:47:55 +0200 Subject: [PATCH 2/4] fix(cloud): use Z suffix in ISO timestamp for Zod compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit datetime.isoformat() produces "+00:00" offset which is rejected by z.string().datetime() — use strftime to always emit the Z suffix. --- apiforgepy/cloud_transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiforgepy/cloud_transport.py b/apiforgepy/cloud_transport.py index a9ebe1d..210f753 100644 --- a/apiforgepy/cloud_transport.py +++ b/apiforgepy/cloud_transport.py @@ -33,7 +33,7 @@ def write(self, rows: list[dict]) -> None: "service": self._service, "env": r["env"], "release": r.get("release_tag"), - "time": datetime.fromtimestamp(r["bucket_ts"], tz=timezone.utc).isoformat(), + "time": datetime.fromtimestamp(r["bucket_ts"], tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z'), "calls_total": r["total_calls"], "calls_2xx": r["status_2xx"], "calls_4xx": r["status_4xx"], From 39ec6a584c3ce4853460dc5002b6b502d2ce9e61 Mon Sep 17 00:00:00 2001 From: Fabien83560 Date: Fri, 15 May 2026 23:51:01 +0200 Subject: [PATCH 3/4] fix(tests): update middleware tests to remove deprecated mode parameter Remove mode="local" usage and update test_invalid_mode_raises to test_partial_cloud_config_raises, reflecting the new cloud_url/api_key API. --- tests/test_middleware.py | 1 - tests/test_smoke.py | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 184d53f..f0566c3 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -9,7 +9,6 @@ def make_app(db_path=":memory:", sampling=1.0, ignore_paths=None): app = FastAPI() app.add_middleware( ApiForgeMiddleware, - mode="local", db_path=db_path, dashboard_port=0, flush_interval=999_999, diff --git a/tests/test_smoke.py b/tests/test_smoke.py index aae7e8b..06560da 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -9,7 +9,6 @@ def make_app(db_path=":memory:"): app = FastAPI() app.add_middleware( ApiForgeMiddleware, - mode="local", db_path=db_path, dashboard_port=0, flush_interval=999_999, @@ -62,10 +61,10 @@ def test_multiple_methods(): assert client.post("/users").status_code == 200 -def test_invalid_mode_raises(): - with pytest.raises(ValueError, match="not yet supported"): +def test_partial_cloud_config_raises(): + with pytest.raises(ValueError, match="both cloud_url and api_key"): app = FastAPI() - app.add_middleware(ApiForgeMiddleware, mode="saas", dashboard_port=0) + app.add_middleware(ApiForgeMiddleware, cloud_url="https://api.apiforge.fr", dashboard_port=0) client = TestClient(app) client.get("/") From 7a6a284c1015060f096c27d5c208c51ad811f3aa Mon Sep 17 00:00:00 2001 From: Fabien83560 Date: Sat, 16 May 2026 00:11:27 +0200 Subject: [PATCH 4/4] chore(sdk-python): bump version to 2.0.0 --- apiforgepy/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apiforgepy/__init__.py b/apiforgepy/__init__.py index a827c0a..7381a4d 100644 --- a/apiforgepy/__init__.py +++ b/apiforgepy/__init__.py @@ -23,7 +23,7 @@ from .transport import LocalTransport from .cloud_transport import CloudTransport -__version__ = "0.1.0" +__version__ = "2.0.0" __all__ = ["ApiForgeMiddleware"] diff --git a/pyproject.toml b/pyproject.toml index 8df0126..75e64f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "apiforgepy" -version = "1.0.3" +version = "2.0.0" description = "API observability & intelligence for FastAPI/Starlette — local-first, privacy-first" readme = "README.md"