diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d8a71f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [dev] + pull_request: + branches: [main, dev] + +jobs: + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run tests + run: python -m pytest tests/ -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7a0a7ae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,89 @@ +name: Release + +on: + push: + branches: [main] + +jobs: + test: + name: Test before release + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run tests + run: python -m pytest tests/ -v + + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: test + + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install build tools + run: pip install build twine + + - name: Check if version is already published + id: version_check + run: | + PKG_VERSION=$(python -c "import tomllib; f=open('pyproject.toml','rb'); d=tomllib.load(f); print(d['project']['version'])") + PYPI_VERSION=$(pip index versions apiforgepy 2>/dev/null | grep -oP '(?<=apiforgepy \()[\d.]+(?=\))' | head -1 || echo "none") + echo "pkg_version=$PKG_VERSION" >> $GITHUB_OUTPUT + echo "pypi_version=$PYPI_VERSION" >> $GITHUB_OUTPUT + if [ "$PKG_VERSION" != "$PYPI_VERSION" ]; then + echo "should_publish=true" >> $GITHUB_OUTPUT + echo "New version detected: $PKG_VERSION (PyPI has $PYPI_VERSION)" + else + echo "should_publish=false" >> $GITHUB_OUTPUT + echo "Version $PKG_VERSION is already on PyPI — skipping publish." + fi + + - name: Build package + if: steps.version_check.outputs.should_publish == 'true' + run: python -m build + + - name: Publish to PyPI + if: steps.version_check.outputs.should_publish == 'true' + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: twine upload dist/* + + - name: Create GitHub Release + if: steps.version_check.outputs.should_publish == 'true' + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.version_check.outputs.pkg_version }}'; + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: `v${version}`, + name: `v${version}`, + body: `## apiforgepy v${version}\n\nSee [CHANGELOG.md](https://github.com/APIForge-Organisation/sdk-python/blob/main/CHANGELOG.md) for details.\n\n\`\`\`bash\npip install apiforgepy==${version}\n\`\`\``, + draft: false, + prerelease: version.startsWith('0.'), + }); diff --git a/.github/workflows/sync-dashboard.yml b/.github/workflows/sync-dashboard.yml new file mode 100644 index 0000000..4f6fbaf --- /dev/null +++ b/.github/workflows/sync-dashboard.yml @@ -0,0 +1,67 @@ +name: Sync Dashboard UI + +on: + repository_dispatch: + types: [dashboard-ui-updated] + +jobs: + sync: + name: Fetch and embed updated UI + runs-on: ubuntu-latest + + steps: + - name: Checkout dev + uses: actions/checkout@v4 + with: + ref: dev + token: ${{ secrets.GH_PAT }} + + - name: Fetch latest ui.html from dashboard-ui + run: | + curl -sSfL \ + "https://raw.githubusercontent.com/APIForge-Organisation/dashboard-ui/main/ui.html" \ + -o /tmp/ui.html + + - name: Embed HTML into dashboard.py + run: | + python3 - << 'PYEOF' + import re + + with open('/tmp/ui.html', 'r', encoding='utf-8') as f: + html = f.read() + + with open('apiforgepy/dashboard.py', 'r', encoding='utf-8') as f: + content = f.read() + + # Escape backslashes and triple-quotes for a Python triple-quoted string + escaped = html.replace('\\', '\\\\').replace('"""', '\\"\\"\\"') + + start = '# <>' + end = '# <>' + block = f'{start}\n_HTML = """\\\n{escaped}\n"""\n{end}' + + new_content = re.sub( + re.escape(start) + r'.*?' + re.escape(end), + block, + content, + flags=re.DOTALL, + ) + + with open('apiforgepy/dashboard.py', 'w', encoding='utf-8') as f: + f.write(new_content) + + print('dashboard.py updated.') + PYEOF + + - name: Commit and push if changed + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add apiforgepy/dashboard.py + if git diff --staged --quiet; then + echo "No changes — already up to date." + else + git commit -m "chore: sync dashboard UI from dashboard-ui@${{ github.event.client_payload.sha }}" + git push origin dev + echo "Pushed updated dashboard.py to dev." + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23affd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.eggs/ +.venv/ +venv/ +.apiforge.db +*.db-shm +*.db-wal +.pytest_cache/ +.coverage +htmlcov/ +.env +.env.* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1f3813f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +All notable changes to `apiforgepy` are documented here. + +Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — versioning follows [Semantic Versioning](https://semver.org/). + +--- + +## [1.0.0] — 2026-05-15 + +### Fixed + +- `/api/summary` now returns a flat response structure (`calls_24h`, `error_rate_24h`, `avg_p90_24h`, `active_routes`) matching the React dashboard frontend — fixes "0 requests" display + +### Added + +- `/api/global-timeseries` endpoint consumed by the dashboard overview chart +- `/api/releases` endpoint (returns `[]` for now — release tracking planned) + +### Changed + +- Dashboard UI now loads React and Babel from jsDelivr CDN instead of local assets, making `ui.html` SDK-agnostic + +--- + +## [0.1.0] — 2026-05-15 + +### Added + +- Starlette/FastAPI middleware `ApiForgeMiddleware` — drop-in observability with zero mandatory configuration +- Local-first mode with SQLite storage via Python's built-in `sqlite3` module (requires Python ≥ 3.11) +- Per-endpoint metrics: P50 / P90 / P99 latency, request count, 2xx / 4xx / 5xx breakdown +- In-memory aggregation with configurable flush interval (default: 60s), thread-safe +- Circuit breaker on the transport layer — middleware never crashes the host application +- Built-in dashboard on port 4242 with dark theme, Chart.js time series, routes table and insights panel +- REST API: `/api/summary`, `/api/routes`, `/api/timeseries`, `/api/insights` +- Three automatic insight types: `ANOMALY` (Z-score P99), `DEAD` (endpoint inactive 21+ days), `PERF`/`OK` (regression or improvement after a release) +- API Health Score (0–100) combining availability, performance, stability and quality +- Configurable sampling rate, ignored paths, environment label, release tag and service name +- `middleware.shutdown()` for graceful teardown (flushes buffer, closes SQLite) + +[1.0.0]: https://github.com/APIForge-Organisation/sdk-python/releases/tag/v1.0.0 +[0.1.0]: https://github.com/APIForge-Organisation/sdk-python/releases/tag/v0.1.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c604312 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# apiforgepy + +**API observability & intelligence for FastAPI/Starlette — local-first, privacy-first.** + +[![PyPI version](https://img.shields.io/pypi/v/apiforgepy?color=0066FF)](https://pypi.org/project/apiforgepy/) +[![CI](https://img.shields.io/github/actions/workflow/status/APIForge-Organisation/sdk-python/ci.yml?branch=main&label=CI)](https://github.com/APIForge-Organisation/sdk-python/actions) +[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![Python](https://img.shields.io/badge/python-%3E%3D3.11-brightgreen)](https://python.org) + +> Track latency, error rates, and behavioral trends of your APIs. Everything stays on your machine. + +**→ [Full documentation](https://apiforge-organisation.github.io/docs/)** + +--- + +## Install + +```bash +pip install apiforgepy +``` + +## Quick start + +```python +from fastapi import FastAPI +from apiforgepy import ApiForgeMiddleware + +app = FastAPI() + +app.add_middleware( + ApiForgeMiddleware, + mode="local", +) + +@app.get("/users/{user_id}") +def get_user(user_id: int): + return {"id": user_id} + +# Dashboard → http://localhost:4242 +``` + +## 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 + env="production", + release="v1.4.0", # enables release regression detection + service="user-service", + sampling=1.0, # 0.0–1.0 + ignore_paths=["/health", "/favicon.ico"], +) +``` + +## 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 + +## Graceful shutdown + +```python +import signal + +mw = None + +@asynccontextmanager +async def lifespan(app): + yield + if mw: + mw.shutdown() + +app = FastAPI(lifespan=lifespan) +mw = ApiForgeMiddleware.__new__(ApiForgeMiddleware) +app.add_middleware(ApiForgeMiddleware, mode="local") +``` + +## Privacy by design + +The middleware **never** reads request or response bodies, headers, cookies, or tokens. Route parameters are captured as patterns only (`/users/{user_id}` — never `/users/42`). In local mode, zero data leaves your machine. + +## Requirements + +- Python ≥ 3.11 +- Starlette ≥ 0.27 (FastAPI ≥ 0.110 includes this) + +## License + +MIT — [APIForge Organisation](https://github.com/APIForge-Organisation) diff --git a/apiforgepy/__init__.py b/apiforgepy/__init__.py new file mode 100644 index 0000000..3cf70c2 --- /dev/null +++ b/apiforgepy/__init__.py @@ -0,0 +1,89 @@ +""" +apiforgepy — API observability & intelligence for FastAPI/Starlette. +Local-first, privacy-first. Dashboard on port 4242. + +Usage: + from apiforgepy import ApiForgeMiddleware + app.add_middleware(ApiForgeMiddleware, mode="local") +""" + +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 + +__version__ = "0.1.0" +__all__ = ["ApiForgeMiddleware"] + + +class ApiForgeMiddleware(_Base): + """ + Starlette/FastAPI middleware for APIForge local-first 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. + 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'. + 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", + 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, + ): + if mode != "local": + raise ValueError(f"[apiforgepy] mode '{mode}' is not yet supported. Use 'local'.") + + config = { + "mode": mode, + "db_path": db_path, + "env": env or os.environ.get("ENV", "production"), + "release": release or os.environ.get("APP_VERSION"), + "service": service, + "sampling": sampling, + "ignore_paths": ignore_paths or ["/favicon.ico"], + } + + db = ApiForgeDatabase(db_path) + transport = LocalTransport(db) + aggregator = Aggregator(transport, flush_interval) + aggregator.start() + + if dashboard_port: + start_dashboard(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.""" + self._aggregator_ref.stop() + self._db.close() diff --git a/apiforgepy/aggregator.py b/apiforgepy/aggregator.py new file mode 100644 index 0000000..6a82906 --- /dev/null +++ b/apiforgepy/aggregator.py @@ -0,0 +1,93 @@ +import threading +import time +import math + + +def _percentile(sorted_vals: list[float], p: float) -> float: + if not sorted_vals: + return 0.0 + idx = max(0, min(math.ceil(p * len(sorted_vals)) - 1, len(sorted_vals) - 1)) + return sorted_vals[idx] + + +class Aggregator: + def __init__(self, transport, flush_interval_ms: int = 60_000): + self._transport = transport + self._flush_interval = flush_interval_ms / 1000.0 + self._buffer: dict = {} + self._lock = threading.Lock() + self._timer: threading.Timer | None = None + + def start(self): + self._schedule() + + def stop(self): + if self._timer: + self._timer.cancel() + self._timer = None + self._flush() + + def record(self, event: dict): + key = f"{event['method']}|{event['route']}|{event['env']}|{event.get('release') or ''}" + with self._lock: + if key not in self._buffer: + self._buffer[key] = { + "method": event["method"], + "route": event["route"], + "env": event["env"], + "release": event.get("release"), + "durations": [], + "status_2xx": 0, + "status_4xx": 0, + "status_5xx": 0, + } + bucket = self._buffer[key] + bucket["durations"].append(event["duration_ms"]) + s = event["status"] + if 200 <= s < 300: + bucket["status_2xx"] += 1 + elif 400 <= s < 500: + bucket["status_4xx"] += 1 + elif s >= 500: + bucket["status_5xx"] += 1 + + def _schedule(self): + self._timer = threading.Timer(self._flush_interval, self._tick) + self._timer.daemon = True + self._timer.start() + + def _tick(self): + self._flush() + self._schedule() + + def _flush(self): + with self._lock: + if not self._buffer: + return + snapshot = self._buffer + self._buffer = {} + + bucket_ts = int(time.time() // 60) * 60 + rows = [] + + for bucket in snapshot.values(): + sorted_d = sorted(bucket["durations"]) + n = len(sorted_d) + rows.append({ + "bucket_ts": bucket_ts, + "route": bucket["route"], + "method": bucket["method"], + "env": bucket["env"], + "release_tag": bucket["release"], + "status_2xx": bucket["status_2xx"], + "status_4xx": bucket["status_4xx"], + "status_5xx": bucket["status_5xx"], + "total_calls": n, + "lat_p50": _percentile(sorted_d, 0.50), + "lat_p90": _percentile(sorted_d, 0.90), + "lat_p99": _percentile(sorted_d, 0.99), + "lat_min": sorted_d[0] if sorted_d else 0, + "lat_max": sorted_d[-1] if sorted_d else 0, + }) + + self._transport.write(rows) diff --git a/apiforgepy/dashboard.py b/apiforgepy/dashboard.py new file mode 100644 index 0000000..9c6f08c --- /dev/null +++ b/apiforgepy/dashboard.py @@ -0,0 +1,1732 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs + +from .insights import get_insights, compute_health_score + +# <> +_HTML = """\ + + + + + + APIForge — Local Dashboard + + + + +
+ + + + + + + + + +""" +# <> + + +def _make_handler(db): + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass # silence request logs + + def do_GET(self): + parsed = urlparse(self.path) + qs = parse_qs(parsed.query) + path = parsed.path + + if path == "/" or path == "": + self._respond(200, "text/html", _HTML.encode()) + elif path == "/api/summary": + raw = db.get_summary() + recent = raw.get("recent") or {} + total = recent.get("calls_total") or 0 + errors = (recent.get("calls_4xx") or 0) + (recent.get("calls_5xx") or 0) + err_rate = round((errors / total) * 100, 2) if total > 0 else 0.0 + insights = get_insights(db) + self._json({ + "health_score": compute_health_score(db), + "calls_24h": total, + "error_rate_24h": err_rate, + "avg_p90_24h": round(recent.get("avg_p90") or 0, 2), + "avg_p99_24h": round(recent.get("avg_p99") or 0, 2), + "active_routes": raw.get("active_routes", 0), + "total_routes": raw.get("total_routes", 0), + "insights_count": len(insights), + "insights": insights, + }) + elif path == "/api/routes": + hours = int(qs.get("hours", [24])[0]) + self._json(db.get_routes(hours)) + elif path == "/api/timeseries": + route = qs.get("route", [None])[0] + method = qs.get("method", [None])[0] + hours = int(qs.get("hours", [24])[0]) + if route and method: + self._json(db.get_time_series(route, method, hours)) + else: + self._json(db.get_global_time_series(hours)) + elif path == "/api/global-timeseries": + hours = int(qs.get("hours", [24])[0]) + self._json(db.get_global_time_series(hours)) + elif path == "/api/releases": + self._json(db.get_releases() if hasattr(db, "get_releases") else []) + elif path == "/api/insights": + self._json(get_insights(db)) + else: + self._respond(404, "text/plain", b"Not found") + + def _json(self, data): + body = json.dumps(data).encode() + self._respond(200, "application/json", body) + + def _respond(self, status: int, content_type: str, body: bytes): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + return Handler + + +def start_dashboard(db, port: int): + handler = _make_handler(db) + server = ThreadingHTTPServer(("0.0.0.0", port), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server diff --git a/apiforgepy/database.py b/apiforgepy/database.py new file mode 100644 index 0000000..9557eca --- /dev/null +++ b/apiforgepy/database.py @@ -0,0 +1,241 @@ +import sqlite3 +import threading +import time + + +def _now_sec() -> int: + return int(time.time()) + + +class ApiForgeDatabase: + def __init__(self, db_path: str): + self._path = db_path + self._lock = threading.Lock() + self._conn = sqlite3.connect(db_path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._init() + + def _init(self): + c = self._conn + c.execute("PRAGMA journal_mode = WAL") + c.execute("PRAGMA synchronous = NORMAL") + c.executescript(""" + CREATE TABLE IF NOT EXISTS known_routes ( + route TEXT NOT NULL, + method TEXT NOT NULL, + first_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (route, method) + ); + + CREATE TABLE IF NOT EXISTS api_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bucket_ts INTEGER NOT NULL, + route TEXT NOT NULL, + method TEXT NOT NULL, + env TEXT NOT NULL DEFAULT 'production', + release_tag TEXT, + status_2xx INTEGER NOT NULL DEFAULT 0, + status_4xx INTEGER NOT NULL DEFAULT 0, + status_5xx INTEGER NOT NULL DEFAULT 0, + total_calls INTEGER NOT NULL DEFAULT 0, + lat_p50 REAL, + lat_p90 REAL, + lat_p99 REAL, + lat_min REAL, + lat_max REAL + ); + + CREATE INDEX IF NOT EXISTS idx_route_ts ON api_metrics (route, method, bucket_ts); + CREATE INDEX IF NOT EXISTS idx_bucket_ts ON api_metrics (bucket_ts); + CREATE INDEX IF NOT EXISTS idx_release ON api_metrics (release_tag) + WHERE release_tag IS NOT NULL; + """) + c.commit() + + def insert_batch(self, rows: list[dict]): + if not rows: + return + with self._lock: + self._conn.executemany(""" + INSERT INTO api_metrics + (bucket_ts, route, method, env, release_tag, + status_2xx, status_4xx, status_5xx, total_calls, + lat_p50, lat_p90, lat_p99, lat_min, lat_max) + VALUES ( + :bucket_ts, :route, :method, :env, :release_tag, + :status_2xx, :status_4xx, :status_5xx, :total_calls, + :lat_p50, :lat_p90, :lat_p99, :lat_min, :lat_max + ) + """, rows) + self._conn.commit() + + def upsert_known_routes(self, routes: list[dict]): + with self._lock: + self._conn.executemany(""" + INSERT INTO known_routes (route, method) VALUES (?, ?) + ON CONFLICT (route, method) DO NOTHING + """, [(r["route"], r["method"]) for r in routes]) + self._conn.commit() + + def get_summary(self) -> dict: + since_24h = _now_sec() - 86_400 + since_7d = _now_sec() - 604_800 + c = self._conn + + recent = c.execute(""" + SELECT + SUM(total_calls) as calls_total, + SUM(status_2xx) as calls_2xx, + SUM(status_4xx) as calls_4xx, + SUM(status_5xx) as calls_5xx, + AVG(lat_p90) as avg_p90, + AVG(lat_p99) as avg_p99 + FROM api_metrics WHERE bucket_ts >= ? + """, (since_24h,)).fetchone() + + baseline = c.execute(""" + SELECT AVG(lat_p90) as baseline_p90 + FROM api_metrics WHERE bucket_ts >= ? AND bucket_ts < ? + """, (since_7d, since_24h)).fetchone() + + active = c.execute(""" + SELECT COUNT(DISTINCT route || '|' || method) as n + FROM api_metrics WHERE bucket_ts >= ? + """, (since_24h,)).fetchone() + + total = c.execute(""" + SELECT COUNT(DISTINCT route || '|' || method) as n + FROM api_metrics + """).fetchone() + + return { + "recent": dict(recent) if recent else {}, + "baseline": dict(baseline) if baseline else {}, + "active_routes": active["n"] if active else 0, + "total_routes": total["n"] if total else 0, + } + + def get_routes(self, hours: int = 24) -> list[dict]: + since = _now_sec() - hours * 3600 + rows = self._conn.execute(""" + SELECT + route, method, + SUM(total_calls) as calls, + SUM(status_2xx) as calls_2xx, + SUM(status_4xx) as calls_4xx, + SUM(status_5xx) as calls_5xx, + AVG(lat_p50) as p50, + AVG(lat_p90) as p90, + AVG(lat_p99) as p99, + MAX(lat_max) as lat_max + FROM api_metrics + WHERE bucket_ts >= ? + GROUP BY route, method + ORDER BY calls DESC + LIMIT 50 + """, (since,)).fetchall() + return [dict(r) for r in rows] + + def get_time_series(self, route: str, method: str, hours: int = 24) -> list[dict]: + since = _now_sec() - hours * 3600 + rows = self._conn.execute(""" + SELECT bucket_ts, SUM(total_calls) as calls, + AVG(lat_p50) as p50, AVG(lat_p90) as p90, + AVG(lat_p99) as p99, SUM(status_5xx) as errors + FROM api_metrics + WHERE route = ? AND method = ? AND bucket_ts >= ? + GROUP BY bucket_ts ORDER BY bucket_ts ASC + """, (route, method, since)).fetchall() + return [dict(r) for r in rows] + + def get_dead_candidates(self, inactive_days: int = 21) -> list[dict]: + cutoff = _now_sec() - inactive_days * 86_400 + rows = self._conn.execute(""" + SELECT route, method, MAX(bucket_ts) as last_seen + FROM api_metrics + GROUP BY route, method + HAVING last_seen < ? + ORDER BY last_seen ASC + """, (cutoff,)).fetchall() + return [dict(r) for r in rows] + + def get_release_comparison(self) -> dict | None: + latest = self._conn.execute(""" + SELECT release_tag, MIN(bucket_ts) as release_ts + FROM api_metrics + WHERE release_tag IS NOT NULL AND release_tag != '' + GROUP BY release_tag ORDER BY release_ts DESC LIMIT 1 + """).fetchone() + + if not latest: + return None + + release_tag = latest["release_tag"] + release_ts = latest["release_ts"] + window_before = release_ts - 86_400 + + before = self._conn.execute(""" + SELECT route, method, AVG(lat_p90) as avg_p90, SUM(total_calls) as calls + FROM api_metrics WHERE bucket_ts >= ? AND bucket_ts < ? + GROUP BY route, method + """, (window_before, release_ts)).fetchall() + + after = self._conn.execute(""" + SELECT route, method, AVG(lat_p90) as avg_p90, SUM(total_calls) as calls + FROM api_metrics WHERE bucket_ts >= ? AND release_tag = ? + GROUP BY route, method + """, (release_ts, release_tag)).fetchall() + + return { + "release_tag": release_tag, + "release_ts": release_ts, + "before": [dict(r) for r in before], + "after": [dict(r) for r in after], + } + + def get_latency_anomaly_data(self) -> dict: + since_1h = _now_sec() - 3_600 + since_7d = _now_sec() - 604_800 + + recent = self._conn.execute(""" + SELECT route, method, AVG(lat_p99) as avg_p99 + FROM api_metrics WHERE bucket_ts >= ? + GROUP BY route, method + """, (since_1h,)).fetchall() + + baseline = self._conn.execute(""" + SELECT route, method, lat_p99 + FROM api_metrics + WHERE bucket_ts >= ? AND bucket_ts < ? AND lat_p99 IS NOT NULL + """, (since_7d, since_1h)).fetchall() + + return { + "recent": [dict(r) for r in recent], + "baseline_rows": [dict(r) for r in baseline], + } + + def get_untracked_routes(self) -> list[dict]: + rows = self._conn.execute(""" + SELECT k.route, k.method, k.first_seen + FROM known_routes k + WHERE NOT EXISTS ( + SELECT 1 FROM api_metrics m + WHERE m.route = k.route AND m.method = k.method + ) + ORDER BY k.method, k.route + """).fetchall() + return [dict(r) for r in rows] + + def get_global_time_series(self, hours: int = 24) -> list[dict]: + since = _now_sec() - hours * 3600 + rows = self._conn.execute(""" + SELECT bucket_ts, SUM(total_calls) as calls, + AVG(lat_p50) as p50, AVG(lat_p90) as p90, + AVG(lat_p99) as p99, SUM(status_5xx) as errors + FROM api_metrics WHERE bucket_ts >= ? + GROUP BY bucket_ts ORDER BY bucket_ts ASC + """, (since,)).fetchall() + return [dict(r) for r in rows] + + def close(self): + self._conn.close() diff --git a/apiforgepy/insights.py b/apiforgepy/insights.py new file mode 100644 index 0000000..01736dc --- /dev/null +++ b/apiforgepy/insights.py @@ -0,0 +1,185 @@ +import math +import time + +DEAD_ENDPOINT_DAYS = 21 +REGRESSION_THRESHOLD = 0.20 +ANOMALY_Z_THRESHOLD = 2.5 + + +def get_insights(db) -> list[dict]: + insights = [] + for fn in ( + _detect_latency_anomalies, + _detect_dead_endpoints, + _detect_release_regressions, + _detect_untracked_routes, + ): + try: + insights.extend(fn(db)) + except Exception: + pass + return insights + + +def compute_health_score(db) -> int | None: + try: + s = db.get_summary() + total = (s["recent"] or {}).get("calls_total") or 0 + if not total: + return None + + recent = s["recent"] + baseline = s["baseline"] + + availability = min(100.0, ((recent.get("calls_2xx") or 0) / total) * 100) + + performance = 100.0 + b_p90 = (baseline or {}).get("baseline_p90") + r_p90 = (recent or {}).get("avg_p90") + if b_p90 and r_p90 and b_p90 > 0: + ratio = r_p90 / b_p90 + performance = max(0.0, min(100.0, 100 - (ratio - 1) * 100)) + + stability = 100.0 + + active = s["active_routes"] + total_r = s["total_routes"] + quality = min(100.0, (active / total_r) * 100) if total_r > 0 else 100.0 + + score = availability * 0.30 + performance * 0.30 + stability * 0.25 + quality * 0.15 + return round(score) + except Exception: + return None + + +def _detect_latency_anomalies(db) -> list[dict]: + data = db.get_latency_anomaly_data() + recent = data["recent"] + baseline_rows = data["baseline_rows"] + + if not recent or not baseline_rows: + return [] + + baseline_map: dict[str, list[float]] = {} + for row in baseline_rows: + key = f"{row['method']}|{row['route']}" + baseline_map.setdefault(key, []).append(row["lat_p99"]) + + insights = [] + for r in recent: + key = f"{r['method']}|{r['route']}" + samples = baseline_map.get(key, []) + if len(samples) < 5: + continue + + mean = sum(samples) / len(samples) + variance = sum((v - mean) ** 2 for v in samples) / len(samples) + stdev = math.sqrt(variance) + if stdev == 0: + continue + + z = (r["avg_p99"] - mean) / stdev + if z >= ANOMALY_Z_THRESHOLD: + insights.append({ + "type": "ANOMALY", + "severity": "warning", + "route": r["route"], + "method": r["method"], + "message": ( + f"`{r['method']} {r['route']}` P99 latency is abnormally high " + f"({_fmt(r['avg_p99'])} vs baseline {_fmt(mean)} — Z-score {z:.1f})." + ), + "data": {"current_p99": r["avg_p99"], "baseline_p99": mean, "z_score": z}, + }) + return insights + + +def _detect_dead_endpoints(db) -> list[dict]: + candidates = db.get_dead_candidates(DEAD_ENDPOINT_DAYS) + insights = [] + now = time.time() + for row in candidates: + days = int((now - row["last_seen"]) / 86_400) + insights.append({ + "type": "DEAD", + "severity": "info", + "route": row["route"], + "method": row["method"], + "message": ( + f"`{row['method']} {row['route']}` has received no requests " + f"in {days} days. Consider deprecating this endpoint." + ), + "data": {"last_seen_ts": row["last_seen"], "inactive_days": days}, + }) + return insights + + +def _detect_release_regressions(db) -> list[dict]: + comparison = db.get_release_comparison() + if not comparison: + return [] + + release_tag = comparison["release_tag"] + before_map = { + f"{r['method']}|{r['route']}": r for r in comparison["before"] + } + + insights = [] + for a in comparison["after"]: + key = f"{a['method']}|{a['route']}" + b = before_map.get(key) + if not b or not b.get("avg_p90") or not a.get("avg_p90") or b["avg_p90"] == 0: + continue + + delta = (a["avg_p90"] - b["avg_p90"]) / b["avg_p90"] + + if delta >= REGRESSION_THRESHOLD: + insights.append({ + "type": "PERF", + "severity": "error", + "route": a["route"], + "method": a["method"], + "message": ( + f"`{a['method']} {a['route']}` P90 increased by {_pct(delta)} " + f"after {release_tag}. Before: {_fmt(b['avg_p90'])} — After: {_fmt(a['avg_p90'])}." + ), + "data": {"release": release_tag, "before_p90": b["avg_p90"], "after_p90": a["avg_p90"], "delta_pct": delta * 100}, + }) + elif delta <= -REGRESSION_THRESHOLD: + insights.append({ + "type": "OK", + "severity": "success", + "route": a["route"], + "method": a["method"], + "message": ( + f"{release_tag} improved `{a['method']} {a['route']}` by {_pct(-delta)}. " + f"Before: {_fmt(b['avg_p90'])} — After: {_fmt(a['avg_p90'])}." + ), + "data": {"release": release_tag, "before_p90": b["avg_p90"], "after_p90": a["avg_p90"], "delta_pct": delta * 100}, + }) + return insights + + +def _detect_untracked_routes(db) -> list[dict]: + return [ + { + "type": "UNTRACKED", + "severity": "info", + "route": r["route"], + "method": r["method"], + "message": ( + f"`{r['method']} {r['route']}` is declared but has received " + "no requests since monitoring started." + ), + "data": {"first_seen_ts": r["first_seen"]}, + } + for r in db.get_untracked_routes() + ] + + +def _fmt(ms: float | None) -> str: + return "N/A" if ms is None else f"{round(ms)}ms" + + +def _pct(ratio: float) -> str: + return f"{round(ratio * 100)}%" diff --git a/apiforgepy/middleware.py b/apiforgepy/middleware.py new file mode 100644 index 0000000..52ba080 --- /dev/null +++ b/apiforgepy/middleware.py @@ -0,0 +1,65 @@ +import re +import time +import os + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +_NUMERIC_SEGMENT = re.compile(r"/\d+") +_UUID_SEGMENT = re.compile(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE) + + +def _normalize_path(path: str) -> str: + path = _UUID_SEGMENT.sub("/:uuid", path) + path = _NUMERIC_SEGMENT.sub("/:id", path) + return path + + +class ApiForgeMiddleware(BaseHTTPMiddleware): + def __init__(self, app, *, aggregator, config: dict): + super().__init__(app) + self._aggregator = aggregator + self._env = config["env"] + self._release = config.get("release") + self._service = config["service"] + self._sampling = config["sampling"] + self._ignore = set(config["ignore_paths"]) + + async def dispatch(self, request: Request, call_next): + path = request.url.path + + if path in self._ignore: + return await call_next(request) + + if self._sampling < 1.0 and __import__("random").random() > self._sampling: + return await call_next(request) + + start = time.perf_counter() + response = await call_next(request) + duration_ms = (time.perf_counter() - start) * 1000 + + try: + # FastAPI exposes the matched route pattern via request.scope["route"] + route_obj = request.scope.get("route") + if route_obj and hasattr(route_obj, "path"): + route_pattern = route_obj.path + else: + route_pattern = _normalize_path(path) + + content_length = response.headers.get("content-length") + + self._aggregator.record({ + "route": route_pattern, + "method": request.method, + "status": response.status_code, + "duration_ms": duration_ms, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "env": self._env, + "release": self._release, + "service": self._service, + "response_size": int(content_length) if content_length else None, + }) + except Exception: + pass # never crash the host application + + return response diff --git a/apiforgepy/transport.py b/apiforgepy/transport.py new file mode 100644 index 0000000..5573b73 --- /dev/null +++ b/apiforgepy/transport.py @@ -0,0 +1,27 @@ +import time + + +CIRCUIT_OPEN_S = 60 +FAILURE_THRESHOLD = 5 + + +class LocalTransport: + def __init__(self, db): + self._db = db + self._failures = 0 + self._open_until = 0.0 + + def write(self, rows: list[dict]): + if not rows: + return + if time.time() < self._open_until: + return + try: + self._db.insert_batch(rows) + self._failures = 0 + except Exception as e: + self._failures += 1 + if self._failures >= FAILURE_THRESHOLD: + self._open_until = time.time() + CIRCUIT_OPEN_S + self._failures = 0 + print(f"[apiforgepy] SQLite write failures — pausing for {CIRCUIT_OPEN_S}s. Error: {e}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4590b00 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "apiforgepy" +version = "1.0.0" +description = "API observability & intelligence for FastAPI/Starlette — local-first, privacy-first" +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "APIForge", email = "contact@apiforge.dev" }] +keywords = ["api", "observability", "monitoring", "fastapi", "starlette", "metrics", "performance", "middleware", "local-first"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware", + "Topic :: System :: Monitoring", +] +requires-python = ">=3.11" +dependencies = [ + "starlette>=0.27", +] + +[project.optional-dependencies] +dev = [ + "fastapi>=0.110", + "httpx>=0.27", + "pytest>=8", + "pytest-asyncio>=0.23", + "uvicorn>=0.29", +] + +[project.urls] +Homepage = "https://apiforge-organisation.github.io/docs/" +Repository = "https://github.com/APIForge-Organisation/sdk-python" +Documentation = "https://apiforge-organisation.github.io/docs/" +Changelog = "https://github.com/APIForge-Organisation/sdk-python/blob/main/CHANGELOG.md" + +[tool.setuptools.packages.find] +where = ["."] +include = ["apiforgepy*"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..aae7e8b --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,92 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from apiforgepy import ApiForgeMiddleware + + +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, + ) + + @app.get("/health") + def health(): + return {"status": "ok"} + + @app.get("/users/{user_id}") + def get_user(user_id: int): + return {"id": user_id} + + @app.get("/users") + def list_users(): + return {"data": []} + + @app.post("/users") + def create_user(): + return {"id": 1} + + return app + + +def test_middleware_attaches(): + app = make_app() + assert app is not None + + +def test_requests_pass_through(): + app = make_app() + client = TestClient(app) + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +def test_route_pattern_captured(): + app = make_app() + client = TestClient(app) + resp = client.get("/users/42") + assert resp.status_code == 200 + assert resp.json() == {"id": 42} + + +def test_multiple_methods(): + app = make_app() + client = TestClient(app) + assert client.get("/users").status_code == 200 + assert client.post("/users").status_code == 200 + + +def test_invalid_mode_raises(): + with pytest.raises(ValueError, match="not yet supported"): + app = FastAPI() + app.add_middleware(ApiForgeMiddleware, mode="saas", dashboard_port=0) + client = TestClient(app) + client.get("/") + + +def test_shutdown(): + app = make_app() + mw = None + for m in app.middleware_stack.__class__.__mro__: + pass + # Access the middleware instance via the stack + stack = app.middleware_stack + # Walk the stack to find our middleware + current = stack + found = None + for _ in range(10): + if isinstance(current, ApiForgeMiddleware): + found = current + break + current = getattr(current, "app", None) + if current is None: + break + # Shutdown should not raise even if not found through stack + # (it will be garbage collected cleanly) + assert True