API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
Python 60.9%
HTML 21%
TypeScript 7.3%
JavaScript 5.2%
CSS 4.8%
Shell 0.8%
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