Files
Api-Gateway/app/admin/stats.py
T
Samuel AmarandClaude Haiku 4.5 77d7a50fa9 Initial commit: API gateway with admin console
- FastAPI async gateway with httpx proxying to multiple upstreams
- SQLite database with SQLAlchemy ORM
- Admin console: manage services, users, API keys, endpoint access
- Per-key, per-endpoint granular access control
- OpenAPI document sync and caching (5-minute TTL)
- Request/response logging with full transaction inspection
- In-memory rate limiting (per-key, fixed-window)
- Tiered log retention (7d payloads, 90d rows, incremental vacuum)
- TLS verification toggle per service (for self-signed certificates)
- Service connectivity validation with automatic endpoint refresh
- Request browser with filters and deep-link inspection
- Docker setup with persistent volume
- Modal forms for create/edit flows

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-29 14:00:22 +02:00

238 lines
7.9 KiB
Python

"""JSON endpoints backing the monitoring charts.
All endpoints accept:
hours — window size (1..720)
service_id — restrict to one service
user_id — restrict to keys owned by one user
key_id — restrict to one API key
Time series use adaptive buckets: 5 min (<=6 h), 1 h (<=48 h), 1 day beyond.
SQLite-specific date functions are used (see README).
"""
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import Integer, func
from sqlalchemy.orm import Query as OrmQuery, Session
from app.admin.deps import current_user
from app.database import get_db
from app.models import ApiKey, Endpoint, RequestLog, Service, User
router = APIRouter(prefix="/admin/api", dependencies=[Depends(current_user)])
class Filters:
def __init__(
self,
hours: int = Query(24, ge=1, le=720),
service_id: int | None = Query(None),
user_id: int | None = Query(None),
key_id: int | None = Query(None),
):
self.hours = hours
self.since = datetime.now(timezone.utc) - timedelta(hours=hours)
self.service_id = service_id
self.user_id = user_id
self.key_id = key_id
def apply(self, q: OrmQuery, joined_key: bool = False) -> OrmQuery:
"""Apply window + filters. Pass joined_key when the query already
joins ApiKey so we don't join twice."""
q = q.filter(RequestLog.timestamp >= self.since)
if self.service_id:
q = q.filter(RequestLog.service_id == self.service_id)
if self.key_id:
q = q.filter(RequestLog.api_key_id == self.key_id)
if self.user_id:
if not joined_key:
q = q.join(ApiKey, RequestLog.api_key_id == ApiKey.id)
q = q.filter(ApiKey.user_id == self.user_id)
return q
def _bucket_seconds(hours: int) -> int:
if hours <= 6:
return 300
if hours <= 48:
return 3600
return 86400
def _label_format(hours: int, bucket: int) -> str:
if bucket < 3600:
return "%H:%M"
if bucket == 3600:
return "%H:00" if hours <= 24 else "%m-%d %H:00"
return "%Y-%m-%d"
def _bucket_expr(bucket: int):
# epoch - (epoch % bucket) floors to the bucket start; plain `/` must be
# avoided because SQLAlchemy renders it as true (float) division.
epoch = func.cast(func.strftime("%s", RequestLog.timestamp), Integer)
return epoch - (epoch % bucket)
def _series(f: Filters, db: Session, *aggregates):
"""Group the filtered logs into time buckets and fill gaps with None.
Returns (labels, [values-per-aggregate])."""
bucket = _bucket_seconds(f.hours)
fmt = _label_format(f.hours, bucket)
expr = _bucket_expr(bucket).label("bucket")
rows = (
f.apply(db.query(expr, *aggregates))
.group_by("bucket").order_by("bucket").all()
)
found = {int(r[0]): r for r in rows}
start = int(f.since.timestamp()) // bucket * bucket
now = int(time.time())
labels: list[str] = []
series: list[list] = [[] for _ in aggregates]
for t in range(start, now + 1, bucket):
labels.append(datetime.fromtimestamp(t, timezone.utc).strftime(fmt))
row = found.get(t)
for i in range(len(aggregates)):
series[i].append(row[i + 1] if row else None)
return labels, series
@router.get("/stats/summary")
def summary(f: Filters = Depends(), db: Session = Depends(get_db)):
total = f.apply(db.query(func.count(RequestLog.id))).scalar() or 0
errors = f.apply(db.query(func.count(RequestLog.id))).filter(
RequestLog.status_code >= 500).scalar() or 0
avg_latency = f.apply(db.query(func.avg(RequestLog.latency_ms))).scalar() or 0
return {
"total_requests": total,
"error_count": errors,
"error_rate": round(errors / total * 100, 2) if total else 0,
"avg_latency_ms": round(avg_latency, 1),
"active_services": db.query(Service).filter(Service.is_active).count(),
"active_keys": db.query(ApiKey).filter(ApiKey.is_active).count(),
}
@router.get("/stats/timeseries")
def timeseries(f: Filters = Depends(), db: Session = Depends(get_db)):
labels, (ok, errors) = _series(
f, db,
func.sum(func.iif(RequestLog.status_code < 500, 1, 0)),
func.sum(func.iif(RequestLog.status_code >= 500, 1, 0)),
)
return {
"labels": labels,
"ok": [int(v) if v is not None else 0 for v in ok],
"errors": [int(v) if v is not None else 0 for v in errors],
}
@router.get("/stats/latency-timeseries")
def latency_timeseries(f: Filters = Depends(), db: Session = Depends(get_db)):
labels, (avg_ms,) = _series(f, db, func.avg(RequestLog.latency_ms))
return {
"labels": labels,
"avg_ms": [round(v, 1) if v is not None else None for v in avg_ms],
}
@router.get("/stats/by-service")
def by_service(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(Service.name, func.count(RequestLog.id).label("count"))
.join(RequestLog, RequestLog.service_id == Service.id)
)
rows = (
f.apply(q)
.group_by(Service.id)
.order_by(func.count(RequestLog.id).desc())
.all()
)
return {"labels": [r.name for r in rows], "counts": [r.count for r in rows]}
@router.get("/stats/by-endpoint")
def by_endpoint(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(Service.slug, Endpoint.method, Endpoint.path,
func.count(RequestLog.id).label("count"))
.select_from(RequestLog)
.join(Endpoint, RequestLog.endpoint_id == Endpoint.id)
.join(Service, Endpoint.service_id == Service.id)
)
rows = (
f.apply(q)
.group_by(Endpoint.id)
.order_by(func.count(RequestLog.id).desc())
.limit(15)
.all()
)
return {
"labels": [f"{r.slug}: {r.method} {r.path}" for r in rows],
"counts": [r.count for r in rows],
}
@router.get("/stats/by-status")
def by_status(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(RequestLog.status_code, func.count(RequestLog.id))
)
rows = (
f.apply(q)
.group_by(RequestLog.status_code)
.order_by(RequestLog.status_code)
.all()
)
return {"labels": [str(r[0]) for r in rows], "counts": [r[1] for r in rows]}
@router.get("/stats/latency-by-service")
def latency_by_service(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(Service.name, func.avg(RequestLog.latency_ms).label("avg_ms"))
.join(RequestLog, RequestLog.service_id == Service.id)
)
rows = (
f.apply(q)
.group_by(Service.id)
.order_by(func.avg(RequestLog.latency_ms).desc())
.all()
)
return {"labels": [r.name for r in rows], "avg_ms": [round(r.avg_ms, 1) for r in rows]}
@router.get("/stats/top-keys")
def top_keys(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(ApiKey.name, func.count(RequestLog.id).label("count"))
.join(RequestLog, RequestLog.api_key_id == ApiKey.id)
)
rows = (
f.apply(q, joined_key=True)
.group_by(ApiKey.id)
.order_by(func.count(RequestLog.id).desc())
.limit(10)
.all()
)
return {"labels": [r.name for r in rows], "counts": [r.count for r in rows]}
@router.get("/stats/by-user")
def by_user(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(User.username, func.count(RequestLog.id).label("count"))
.select_from(RequestLog)
.join(ApiKey, RequestLog.api_key_id == ApiKey.id)
.join(User, ApiKey.user_id == User.id)
)
rows = (
f.apply(q, joined_key=True)
.group_by(User.id)
.order_by(func.count(RequestLog.id).desc())
.limit(15)
.all()
)
return {"labels": [r.username for r in rows], "counts": [r.count for r in rows]}