Skip to content
Merged

Dev #12

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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,23 @@ jobs:

- name: Run tests
run: python -m pytest tests/ -v

lint:
name: Lint
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip

- name: Install dependencies
run: pip install -e ".[dev]"

- name: Run lint
run: ruff check .
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — versioning

---

## [4.0.0] — 2026-07-08

### Breaking Changes

- **Cloud mode now sends the `X-APIForge-Key` header instead of `X-API-Key`**, matching the saas-api rename (CDC §8.4.1). Cloud ingest against the current APIForge API **requires** this version — 3.x and earlier send the old header and are rejected with `401`. The header is internal to the SDK, so no code change is needed on your side beyond upgrading.

### Changed

- The local dashboard now serves its React/Babel runtime from files **vendored inside the package** instead of downloading them from a CDN (jsDelivr) on first launch. The dashboard works fully offline and makes no third-party requests — privacy-first parity with the Node and PHP SDKs. Bundled versions: react 18.3.1, react-dom 18.3.1, @babel/standalone 7.29.7.

### Migration guide

```bash
pip install "apiforgepy>=4.0.0"
```

No configuration change is required — the header is set internally by `CloudTransport`. Upgrade any service running the SDK in cloud mode.

---

## [3.0.0] — 2026-06-04

### Breaking Changes
Expand Down
24 changes: 15 additions & 9 deletions apiforgepy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,22 @@

import atexit

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__ = "3.0.0"
from .aggregator import Aggregator
from .cloud_transport import CloudTransport
from .dashboard import start_dashboard
from .database import ApiForgeDatabase
from .middleware import ApiForgeMiddleware as _Base
from .transport import LocalTransport

__version__ = "4.0.0"
__all__ = ["ApiForgeMiddleware"]

# Hard floor for the send cadence. The `_flush_interval` kwarg stays internal (used by
# the test suite with larger values); the floor guarantees no caller can shorten the
# delay below 60s. This only guards against misconfiguration — real ingest throttling
# is enforced server-side (per-key rate limit + monthly quota).
_MIN_FLUSH_INTERVAL_MS = 60_000


class ApiForgeMiddleware(_Base):
"""
Expand Down Expand Up @@ -86,7 +92,7 @@ def __init__(
transport = LocalTransport(self._db)
config["store_routes"] = self._db.upsert_known_routes

aggregator = Aggregator(transport, _flush_interval)
aggregator = Aggregator(transport, max(_flush_interval, _MIN_FLUSH_INTERVAL_MS))
aggregator.start()

if not is_cloud and dashboard_port:
Expand Down
20 changes: 14 additions & 6 deletions apiforgepy/aggregator.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import json
import math
import threading
import time
import math


def _percentile(sorted_vals: list[float], p: float) -> float:
Expand Down Expand Up @@ -30,7 +30,11 @@ def stop(self):

def record(self, event: dict):
is_ghost = event.get("is_ghost", False)
key = f"{event['method']}|{event['route']}|{event['env']}|{event.get('release') or ''}|{'1' if is_ghost else '0'}"
ghost_flag = "1" if is_ghost else "0"
key = (
f"{event['method']}|{event['route']}|{event['env']}|"
f"{event.get('release') or ''}|{ghost_flag}"
)
with self._lock:
if key not in self._buffer:
self._buffer[key] = {
Expand Down Expand Up @@ -63,10 +67,14 @@ def record(self, event: dict):
bucket["inflight_samples"].append(event["inflight"])

s = event["status"]
if 200 <= s < 300: bucket["status_2xx"] += 1
elif 300 <= s < 400: bucket["status_3xx"] += 1
elif 400 <= s < 500: bucket["status_4xx"] += 1
elif s >= 500: bucket["status_5xx"] += 1
if 200 <= s < 300:
bucket["status_2xx"] += 1
elif 300 <= s < 400:
bucket["status_3xx"] += 1
elif 400 <= s < 500:
bucket["status_4xx"] += 1
elif s >= 500:
bucket["status_5xx"] += 1

bucket["status_map"][s] = bucket["status_map"].get(s, 0) + 1

Expand Down
4 changes: 4 additions & 0 deletions apiforgepy/assets/babel.js

Large diffs are not rendered by default.

267 changes: 267 additions & 0 deletions apiforgepy/assets/react-dom.js

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions apiforgepy/assets/react.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions apiforgepy/cloud_transport.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import json
import time
import threading
import urllib.request
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone

_CIRCUIT_OPEN_S = 60
Expand Down Expand Up @@ -32,7 +32,7 @@ def write_routes(self, routes: list[dict]) -> None:
req = urllib.request.Request(
self._url + "/routes",
data=payload,
headers={"Content-Type": "application/json", "X-API-Key": self._api_key},
headers={"Content-Type": "application/json", "X-APIForge-Key": self._api_key},
method="POST",
)
try:
Expand All @@ -54,7 +54,9 @@ 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).strftime('%Y-%m-%dT%H:%M:%S.000Z'),
"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_3xx": r.get("status_3xx", 0),
Expand All @@ -81,7 +83,7 @@ def write(self, rows: list[dict]) -> None:
req = urllib.request.Request(
self._url,
data=payload,
headers={"Content-Type": "application/json", "X-API-Key": self._api_key},
headers={"Content-Type": "application/json", "X-APIForge-Key": self._api_key},
method="POST",
)

Expand Down
48 changes: 11 additions & 37 deletions apiforgepy/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import json
import os
import sys
import threading
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
from urllib.parse import parse_qs, urlparse

from .insights import get_insights, compute_health_score
from .insights import compute_health_score, get_insights

# <<DASHBOARD_UI_START>>
_HTML = """\
Expand Down Expand Up @@ -1687,35 +1685,12 @@
"""
# <<DASHBOARD_UI_END>>

_ASSET_URLS = {
"react.js": "https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js",
"react-dom.js": "https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js",
"babel.js": "https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js",
}


def _asset_cache_dir() -> str:
if sys.platform == "win32":
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
else:
base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
return os.path.join(base, "apiforgepy", "assets")


def _ensure_assets() -> None:
"""Download JS runtime assets to local cache on first startup (one-time, ~1.5 MB)."""
directory = _asset_cache_dir()
os.makedirs(directory, exist_ok=True)
for name, url in _ASSET_URLS.items():
path = os.path.join(directory, name)
if not os.path.exists(path):
try:
with urllib.request.urlopen(url, timeout=20) as resp:
data = resp.read()
with open(path, "wb") as f:
f.write(data)
except Exception:
pass # Endpoint returns 503 until downloaded
# React/Babel UMD runtime, vendored into the package — no CDN fetch, so the local
# dashboard stays offline- and privacy-first (parity with the Node/PHP SDKs).
# Versions kept in sync with the Node SDK: react 18.3.1, react-dom 18.3.1,
# @babel/standalone 7.29.7.
_ASSET_NAMES = ("react.js", "react-dom.js", "babel.js")
_ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")


def _make_handler(db):
Expand Down Expand Up @@ -1768,12 +1743,12 @@ def do_GET(self):
self._json(get_insights(db))
elif path.startswith("/assets/"):
name = path[len("/assets/"):]
asset_path = os.path.join(_asset_cache_dir(), name)
if name in _ASSET_URLS and os.path.exists(asset_path):
asset_path = os.path.join(_ASSET_DIR, name)
if name in _ASSET_NAMES and os.path.exists(asset_path):
with open(asset_path, "rb") as f:
self._respond(200, "application/javascript", f.read())
else:
self._respond(503, "text/plain", b"Asset downloading, retry in a moment")
self._respond(404, "text/plain", b"Not found")
else:
self._respond(404, "text/plain", b"Not found")

Expand All @@ -1793,7 +1768,6 @@ def _respond(self, status: int, content_type: str, body: bytes):


def start_dashboard(db, port: int):
_ensure_assets()
handler = _make_handler(db)
server = ThreadingHTTPServer(("127.0.0.1", port), handler)
server.daemon_threads = True # request handler threads don't block process exit
Expand Down
Loading
Loading