SPB Git

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

5.2 KB · 165 lines python
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/dashboard",53        "/api/stats/report",54    }55)56_SERVICE_SUBROUTES = frozenset({"latest", "stats"})575859def normalize_endpoint(path: str) -> str:60    """Replie un chemin de requête vers un endpoint à cardinalité bornée.6162    Les paramètres de route (dates) sont remplacés par des gabarits, les63    assets statiques /ka/* sont agrégés et les chemins inconnus (scans de64    bots, 404) sont regroupés sous « (autre) ».65    """66    path = path.rstrip("/") or "/"67    if path in _KNOWN_PATHS:68        return path69    if path.startswith("/ka/"):70        return "/ka/*"71    if path.startswith("/api/v1/"):72        parts = path.split("/")  # ['', 'api', 'v1', service, ...]73        service = parts[3] if len(parts) > 3 else ""74        if service in SERVICES:75            if len(parts) == 4:76                return f"/api/v1/{service}"77            sub = parts[4]78            if sub == "date":79                return f"/api/v1/{service}/date/{{date}}"80            if sub in _SERVICE_SUBROUTES and len(parts) == 5:81                return f"/api/v1/{service}/{sub}"82        return "/api/v1/(autre)"83    return "(autre)"848586def record(87    ts: datetime.datetime, method: str, path: str, status: int, duration_ms: float88) -> None:89    """Empile une requête dans le tampon mémoire (non bloquant, jamais d'I/O)."""90    entry = {91        "ts": ts,92        "method": method,93        "endpoint": normalize_endpoint(path),94        "status": int(status),95        "duration_ms": round(float(duration_ms), 2),96    }97    with _lock:98        _buffer.append(entry)99100101def _drain() -> list[dict]:102    """Vide le tampon et retourne son contenu."""103    with _lock:104        entries = list(_buffer)105        _buffer.clear()106    return entries107108109def flush() -> int:110    """Insère en lot le contenu du tampon dans ``api_requests`` (synchrone).111112    Appelée depuis un thread par la tâche de fond ; purge les lignes plus113    vieilles que ``RETENTION_DAYS`` jours au plus une fois par jour.114    """115    global _last_purge116    entries = _drain()117    if not entries:118        _maybe_purge()119        return 0120    try:121        from src.database.db import session_scope122        from src.database.models import ApiRequest123124        with session_scope() as session:125            session.bulk_insert_mappings(ApiRequest.__mapper__, entries)126    except Exception:  # pragma: no cover — la stat ne doit jamais casser l'API127        get_logger("apika.reqstats").exception("Échec du flush api_requests")128        return 0129    _maybe_purge()130    return len(entries)131132133def _maybe_purge() -> None:134    """Purge les requêtes plus vieilles que RETENTION_DAYS (1 fois/jour max)."""135    global _last_purge136    today = datetime.datetime.now(tz=datetime.UTC).date()137    if _last_purge == today:138        return139    _last_purge = today140    try:141        from sqlalchemy import delete142143        from src.database.db import session_scope144        from src.database.models import ApiRequest145146        cutoff = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(147            days=RETENTION_DAYS148        )149        with session_scope() as session:150            session.execute(delete(ApiRequest).where(ApiRequest.ts < cutoff))151    except Exception:  # pragma: no cover152        get_logger("apika.reqstats").exception("Échec de la purge api_requests")153154155async def flusher_task() -> None:156    """Tâche de fond : flush périodique du tampon, hors event loop (thread)."""157    try:158        while True:159            await asyncio.sleep(FLUSH_INTERVAL)160            await asyncio.to_thread(flush)161    except asyncio.CancelledError:162        # Dernier flush au shutdown pour ne rien perdre.163        await asyncio.to_thread(flush)164        raise165
Files · 792661b70946e1e0f47211608f696c2270459551
Menu

Theme