SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%
5.6 KB · 174 lines python
Raw Blame History
1# ============================================2# Projet   : API-KA3# Fichier  : src/api/reqstats.py4# Node     : m3u96b5# Author   : Simon-Pierre Boucher6# Contact  : contact@spboucher.ai7# Date     : 2026-08-178# ============================================9"""Journalisation légère des requêtes API vers la table ``api_requests``.1011Conçu pour ne JAMAIS ralentir le chemin de requête : le middleware appelle12``record()`` (un simple ``deque.append``, O(1), sans I/O) et une tâche de13fond (démarrée dans le lifespan de l'app) vide le tampon en lot toutes les14``FLUSH_INTERVAL`` secondes via un thread, hors boucle d'événements.1516Rétention : ``RETENTION_DAYS`` jours — une purge s'exécute au plus une fois17par jour lors d'un flush. Alimente exclusivement la page /stats.18"""1920from __future__ import annotations2122import asyncio23import datetime24import threading25from collections import deque2627from src.config import SERVICES28from src.utils.logger import get_logger2930FLUSH_INTERVAL = 5.0  # secondes entre deux vidages du tampon31MAX_BUFFER = 10_000  # garde-fou mémoire : au-delà, les plus anciens sont perdus32RETENTION_DAYS = 903334_buffer: deque[dict] = deque(maxlen=MAX_BUFFER)35_lock = threading.Lock()36_last_purge: datetime.date | None = None3738# Chemins statiques connus, conservés tels quels dans la colonne ``endpoint``.39_KNOWN_PATHS = frozenset(40    {41        "/",42        "/health",43        "/contact",44        "/stats",45        "/docs",46        "/redoc",47        "/openapi.json",48        "/favicon.svg",49        "/apple-touch-icon.png",50        "/og.png",51        "/api/v1/runs",52        "/api/stats",53        "/api/stats/dashboard",54        "/api/stats/report",55        "/api/stats/ecosystem-report",56    }57)58_SERVICE_SUBROUTES = frozenset({"latest", "stats"})5960# Familles de routes API repliées sous « <préfixe>/* » (auth, agent, iOS,61# monitoring) : cardinalité bornée sans les noyer dans « (autre) ».62_API_FAMILIES = ("/api/auth", "/api/agent", "/api/ios", "/api/monitoring")636465def normalize_endpoint(path: str) -> str:66    """Replie un chemin de requête vers un endpoint à cardinalité bornée.6768    Les paramètres de route (dates) sont remplacés par des gabarits, les69    assets statiques /ka/* sont agrégés et les chemins inconnus (scans de70    bots, 404) sont regroupés sous « (autre) ».71    """72    path = path.rstrip("/") or "/"73    if path in _KNOWN_PATHS:74        return path75    if path.startswith("/ka/"):76        return "/ka/*"77    if path.startswith("/api/v1/"):78        parts = path.split("/")  # ['', 'api', 'v1', service, ...]79        service = parts[3] if len(parts) > 3 else ""80        if service in SERVICES:81            if len(parts) == 4:82                return f"/api/v1/{service}"83            sub = parts[4]84            if sub == "date":85                return f"/api/v1/{service}/date/{{date}}"86            if sub in _SERVICE_SUBROUTES and len(parts) == 5:87                return f"/api/v1/{service}/{sub}"88        return "/api/v1/(autre)"89    for family in _API_FAMILIES:90        if path == family or path.startswith(family + "/"):91            return f"{family}/*"92    return "(autre)"939495def record(96    ts: datetime.datetime, method: str, path: str, status: int, duration_ms: float97) -> None:98    """Empile une requête dans le tampon mémoire (non bloquant, jamais d'I/O)."""99    entry = {100        "ts": ts,101        "method": method,102        "endpoint": normalize_endpoint(path),103        "status": int(status),104        "duration_ms": round(float(duration_ms), 2),105    }106    with _lock:107        _buffer.append(entry)108109110def _drain() -> list[dict]:111    """Vide le tampon et retourne son contenu."""112    with _lock:113        entries = list(_buffer)114        _buffer.clear()115    return entries116117118def flush() -> int:119    """Insère en lot le contenu du tampon dans ``api_requests`` (synchrone).120121    Appelée depuis un thread par la tâche de fond ; purge les lignes plus122    vieilles que ``RETENTION_DAYS`` jours au plus une fois par jour.123    """124    global _last_purge125    entries = _drain()126    if not entries:127        _maybe_purge()128        return 0129    try:130        from src.database.db import session_scope131        from src.database.models import ApiRequest132133        with session_scope() as session:134            session.bulk_insert_mappings(ApiRequest.__mapper__, entries)135    except Exception:  # pragma: no cover — la stat ne doit jamais casser l'API136        get_logger("apika.reqstats").exception("Échec du flush api_requests")137        return 0138    _maybe_purge()139    return len(entries)140141142def _maybe_purge() -> None:143    """Purge les requêtes plus vieilles que RETENTION_DAYS (1 fois/jour max)."""144    global _last_purge145    today = datetime.datetime.now(tz=datetime.UTC).date()146    if _last_purge == today:147        return148    _last_purge = today149    try:150        from sqlalchemy import delete151152        from src.database.db import session_scope153        from src.database.models import ApiRequest154155        cutoff = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(156            days=RETENTION_DAYS157        )158        with session_scope() as session:159            session.execute(delete(ApiRequest).where(ApiRequest.ts < cutoff))160    except Exception:  # pragma: no cover161        get_logger("apika.reqstats").exception("Échec de la purge api_requests")162163164async def flusher_task() -> None:165    """Tâche de fond : flush périodique du tampon, hors event loop (thread)."""166    try:167        while True:168            await asyncio.sleep(FLUSH_INTERVAL)169            await asyncio.to_thread(flush)170    except asyncio.CancelledError:171        # Dernier flush au shutdown pour ne rien perdre.172        await asyncio.to_thread(flush)173        raise174