feat(agent_v1): log shipper — remontee auto des logs vers le serveur (gated OFF)
LogShipperHandler + LogShipper : buffer borne, flush par batch <= max, resilience 0-perte (rejeu sur echec), sender injectable. Flag RPA_LOG_SHIP_ENABLED (defaut off, activable par config.txt sans rebuild). Sanitizer client = identite (rempart PII = serveur, cf commit precedent). Wiring gated dans main.py. 8 tests TDD. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
317
agent_v0/agent_v1/network/log_shipper.py
Normal file
317
agent_v0/agent_v1/network/log_shipper.py
Normal file
@@ -0,0 +1,317 @@
|
||||
# agent_v1/network/log_shipper.py
|
||||
"""Remontée AUTOMATIQUE des logs du client Léa vers le serveur (push-log-DGX).
|
||||
|
||||
But : diagnostiquer les postes Windows clinique SANS AnyDesk. Les logs déjà
|
||||
écrits sur disque par `logging_setup.py` (rotation quotidienne, rétention 180 j,
|
||||
Règlement IA Art. 12) sont en plus poussés au serveur, rangés par `machine_id`,
|
||||
consultables au dashboard.
|
||||
|
||||
Serveur (déjà prêt — NE PAS toucher) :
|
||||
POST /api/v1/agents/logs
|
||||
body = {machine_id: str, logs: [{ts, level, logger, message}]}
|
||||
borne RPA_AGENT_LOGS_MAX_BATCH (défaut 1000) — 413 si dépassée.
|
||||
|
||||
Conception :
|
||||
- `LogShipperHandler(logging.Handler)` : sur `emit(record)`, formate au
|
||||
schéma EXACT `{ts, level, logger, message}`, applique un assainissement
|
||||
PII au message (défense en profondeur — la discipline `log_safe` à la
|
||||
source logue déjà des hashes/longueurs, pas du contenu brut), puis
|
||||
empile dans un buffer borné.
|
||||
- `LogShipper` : flush par BATCH (≤ max_batch) via un `sender` callable
|
||||
INJECTABLE `(machine_id, logs) -> bool`. Défaut = POST réel Bearer
|
||||
(pattern `streamer.py`).
|
||||
- Résilience (ZÉRO perte) : si `sender` renvoie False ou lève, les logs
|
||||
RESTENT dans le buffer et sont rejoués au flush suivant. Le fichier de
|
||||
log local reste de toute façon la source durable (survit au crash) ; le
|
||||
buffer RAM est un best-effort de remontée, volontairement NON persisté en
|
||||
SQLite (le `PersistentBuffer` est session/event-scoped — y mêler des logs
|
||||
polluerait la DB d'events). Borne mémoire = `max_buffer` (drop des plus
|
||||
VIEUX au-delà — un log récent vaut mieux qu'un vieux pour le diagnostic).
|
||||
|
||||
Pattern d'import PII : on tente `anonymize_text` (server_v1.pii_sanitizer,
|
||||
source de vérité des tokens typés) via le même import paresseux tolérant que
|
||||
`ui/messages.py`. Sur un vrai poste (sans server_v1), on retombe sur l'identité :
|
||||
acceptable car la PII de message est déjà neutralisée à la source par la
|
||||
discipline `log_safe`. Le sanitizer reste INJECTABLE pour les tests/évolutions.
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Callable, Deque, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Schéma d'une entrée de log poussée au serveur.
|
||||
# ts : epoch (float) — l'heure de l'évènement
|
||||
# level : nom du niveau ("INFO", "WARNING"...)
|
||||
# logger : nom du logger (record.name)
|
||||
# message : message formaté (args interpolés) ET assaini PII
|
||||
|
||||
# Défaut aligné sur la borne serveur RPA_AGENT_LOGS_MAX_BATCH (api_stream.py).
|
||||
DEFAULT_MAX_BATCH = 1000
|
||||
|
||||
# Borne mémoire du buffer : au-delà, on droppe les plus VIEUX (diagnostic =
|
||||
# on préfère les logs récents). Quelques milliers d'entrées = quelques Mo RAM.
|
||||
DEFAULT_MAX_BUFFER = 5000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assainissement PII du message (défense en profondeur)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_message_sanitizer(text: str) -> str:
|
||||
"""Sanitizer par défaut côté client = identité.
|
||||
|
||||
Le **rempart PII des logs est le SERVEUR** : `sanitize_log_entries`
|
||||
ré-assainit chaque message à la réception (`/api/v1/agents/logs`), via le
|
||||
même `anonymize_text` que les events. Tenter un import de `server_v1` côté
|
||||
poste à CHAQUE ligne de log est inutile (absent du bundle client) et coûteux
|
||||
(exception attrapée par emit). La discipline `log_safe` neutralise déjà la
|
||||
PII à la source. Reste INJECTABLE pour tests/évolutions.
|
||||
"""
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler — empile les LogRecords dans un buffer partagé
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LogShipperHandler(logging.Handler):
|
||||
"""Handler logging qui sérialise chaque record et l'empile pour envoi.
|
||||
|
||||
Ne fait AUCUN réseau : il alimente seulement le buffer du `LogShipper`.
|
||||
L'envoi est piloté par `LogShipper.flush()` (thread dédié périodique).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
buffer: Deque[Dict],
|
||||
lock: threading.Lock,
|
||||
message_sanitizer: Callable[[str], str],
|
||||
max_buffer: int = DEFAULT_MAX_BUFFER,
|
||||
level=logging.NOTSET,
|
||||
):
|
||||
super().__init__(level=level)
|
||||
self._buffer = buffer
|
||||
self._lock = lock
|
||||
self._sanitize = message_sanitizer
|
||||
self._max_buffer = max_buffer
|
||||
|
||||
def _format_record(self, record: logging.LogRecord) -> Dict:
|
||||
"""Construit l'entrée au schéma EXACT {ts, level, logger, message}.
|
||||
|
||||
`record.getMessage()` interpole les args (%s...). Le message est ensuite
|
||||
passé au sanitizer PII. Tolérant : un message non formatable ne doit pas
|
||||
faire perdre l'entrée.
|
||||
"""
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception:
|
||||
message = str(record.msg)
|
||||
try:
|
||||
message = self._sanitize(message)
|
||||
except Exception:
|
||||
# Le sanitizer ne doit jamais casser le logging.
|
||||
pass
|
||||
return {
|
||||
"ts": record.created,
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Sérialise et empile le record (best-effort, ne lève jamais)."""
|
||||
try:
|
||||
entry = self._format_record(record)
|
||||
with self._lock:
|
||||
# deque(maxlen) droppe automatiquement le plus VIEUX au-delà
|
||||
# de la borne — pas de croissance mémoire non bornée.
|
||||
self._buffer.append(entry)
|
||||
except Exception:
|
||||
# handleError respecte logging.raiseExceptions (silencieux en prod).
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shipper — flush périodique par batch via un sender injectable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LogShipper:
|
||||
"""Orchestre la remontée des logs : buffer + flush par batch.
|
||||
|
||||
Args:
|
||||
machine_id : identifiant du poste (config.MACHINE_ID en prod).
|
||||
sender : callable INJECTABLE `(machine_id, logs) -> bool`. True =
|
||||
accusé de réception serveur. Défaut = POST réel Bearer.
|
||||
max_batch : taille max d'un batch (≤ borne serveur). Défaut 1000.
|
||||
max_buffer : borne mémoire du buffer (drop des plus vieux au-delà).
|
||||
message_sanitizer : assainissement PII du message. Défaut = pii_sanitizer
|
||||
si disponible, sinon identité.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
machine_id: str,
|
||||
sender: Optional[Callable[[str, List[Dict]], bool]] = None,
|
||||
max_batch: int = DEFAULT_MAX_BATCH,
|
||||
max_buffer: int = DEFAULT_MAX_BUFFER,
|
||||
message_sanitizer: Optional[Callable[[str], str]] = None,
|
||||
flush_interval_s: float = 30.0,
|
||||
):
|
||||
self.machine_id = machine_id
|
||||
self.max_batch = max(1, int(max_batch))
|
||||
self.flush_interval_s = flush_interval_s
|
||||
self._sender = sender if sender is not None else self._default_sender
|
||||
self._sanitize = message_sanitizer or _default_message_sanitizer
|
||||
self._lock = threading.Lock()
|
||||
self._buffer: Deque[Dict] = deque(maxlen=max_buffer)
|
||||
self.handler = LogShipperHandler(
|
||||
buffer=self._buffer,
|
||||
lock=self._lock,
|
||||
message_sanitizer=self._sanitize,
|
||||
max_buffer=max_buffer,
|
||||
)
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Introspection (diagnostic / tests)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def peek_buffer(self) -> List[Dict]:
|
||||
"""Copie des entrées en attente (lecture seule, pour diagnostic/tests)."""
|
||||
with self._lock:
|
||||
return list(self._buffer)
|
||||
|
||||
def pending(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._buffer)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Flush — envoie le buffer par batches ≤ max_batch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def flush(self) -> int:
|
||||
"""Envoie le buffer par batches successifs. Retourne le nb de logs ACK.
|
||||
|
||||
Résilience ZÉRO perte : on retire un batch du buffer, on tente l'envoi.
|
||||
- Succès → les entrées sont définitivement consommées.
|
||||
- Échec (False ou exception) → on REMET les entrées en tête du buffer
|
||||
et on ARRÊTE la passe (serveur probablement down) ; rejeu au flush
|
||||
suivant. Les entrées non encore extraites restent en place.
|
||||
"""
|
||||
sent = 0
|
||||
while True:
|
||||
with self._lock:
|
||||
if not self._buffer:
|
||||
break
|
||||
batch: List[Dict] = []
|
||||
for _ in range(min(self.max_batch, len(self._buffer))):
|
||||
batch.append(self._buffer.popleft())
|
||||
|
||||
try:
|
||||
ok = self._sender(self.machine_id, batch)
|
||||
except Exception as e:
|
||||
ok = False
|
||||
logger.debug("Log shipper sender a levé : %s", e)
|
||||
|
||||
if ok:
|
||||
sent += len(batch)
|
||||
continue
|
||||
|
||||
# Échec : on remet le batch en tête (ordre préservé) et on arrête.
|
||||
with self._lock:
|
||||
self._buffer.extendleft(reversed(batch))
|
||||
break
|
||||
|
||||
return sent
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sender réel — POST Bearer (pattern streamer.py)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _auth_headers() -> dict:
|
||||
"""Headers Bearer (pattern streamer.py)."""
|
||||
try:
|
||||
from ..config import API_TOKEN
|
||||
except Exception:
|
||||
API_TOKEN = ""
|
||||
if API_TOKEN:
|
||||
return {"Authorization": f"Bearer {API_TOKEN}"}
|
||||
return {}
|
||||
|
||||
def _default_sender(self, machine_id: str, logs: List[Dict]) -> bool:
|
||||
"""POST réel vers /api/v1/agents/logs. True si HTTP 2xx.
|
||||
|
||||
Best-effort : tout échec réseau/serveur → False (logs conservés,
|
||||
rejoués). Aucune exception ne remonte au-delà du sender.
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
from ..config import SERVER_URL
|
||||
|
||||
url = f"{SERVER_URL}/agents/logs"
|
||||
resp = requests.post(
|
||||
url,
|
||||
json={"machine_id": machine_id, "logs": logs},
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
return bool(resp.ok)
|
||||
except Exception as e:
|
||||
logger.debug("Log shipper POST échoué : %s", e)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Boucle de flush périodique (thread daemon)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
"""Démarre le thread de flush périodique (idempotent)."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="lea-log-shipper"
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(
|
||||
"Log shipper démarré (machine_id=%s, intervalle=%.0fs, batch≤%d)",
|
||||
self.machine_id, self.flush_interval_s, self.max_batch,
|
||||
)
|
||||
|
||||
def stop(self, final_flush: bool = True) -> None:
|
||||
"""Arrête la boucle et tente un dernier flush (best-effort)."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
if final_flush:
|
||||
try:
|
||||
self.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
# Découpe l'attente pour réagir vite à stop().
|
||||
waited = 0.0
|
||||
step = 0.5
|
||||
while self._running and waited < self.flush_interval_s:
|
||||
time.sleep(step)
|
||||
waited += step
|
||||
if not self._running:
|
||||
break
|
||||
try:
|
||||
self.flush()
|
||||
except Exception as e:
|
||||
logger.debug("Log shipper flush loop : %s", e)
|
||||
Reference in New Issue
Block a user