API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
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