# agent_v1/network/streamer.py """ Streaming temps réel pour Agent V1. Exploite la fibre pour envoyer les événements au fur et à mesure. Endpoints serveur (api_stream.py, port 5005) : POST /api/v1/traces/stream/register — enregistrer la session POST /api/v1/traces/stream/event — événement temps réel POST /api/v1/traces/stream/image — screenshot (full ou crop) POST /api/v1/traces/stream/finalize — clôturer et construire le workflow Robustesse (P0-2) : - Retry avec backoff exponentiel (1s/2s/4s, max 3 tentatives) - Health-check périodique (30s) pour recovery du flag _server_available - Compression JPEG qualité 85 pour les images (réduction ~5-10x) - Backpressure : queue bornée (maxsize=100), drop des heartbeat si pleine """ import io import logging import queue import threading import time import requests from PIL import Image from ..config import API_TOKEN, STREAMING_ENDPOINT logger = logging.getLogger(__name__) # Paramètres de retry MAX_RETRIES = 3 RETRY_DELAYS = [1.0, 2.0, 4.0] # Backoff exponentiel # Paramètres de health-check HEALTH_CHECK_INTERVAL_S = 30 # Paramètres de compression JPEG_QUALITY = 85 # Taille max de la queue (backpressure) QUEUE_MAX_SIZE = 100 # Types d'événements à ne jamais dropper PRIORITY_EVENT_TYPES = {"click", "key", "scroll", "action", "screenshot"} class TraceStreamer: def __init__(self, session_id: str, machine_id: str = "default"): self.session_id = session_id self.machine_id = machine_id # Identifiant machine pour le multi-machine self.queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) self.running = False self._thread = None self._health_thread = None self._server_available = True # Désactivé après trop d'échecs @staticmethod def _auth_headers() -> dict: """Headers d'authentification Bearer pour les requêtes API.""" if API_TOKEN: return {"Authorization": f"Bearer {API_TOKEN}"} return {} def start(self): """Démarrer le streaming et enregistrer la session côté serveur.""" self.running = True self._register_session() # Thread principal d'envoi self._thread = threading.Thread(target=self._stream_loop, daemon=True) self._thread.start() # Thread de health-check pour recovery self._health_thread = threading.Thread( target=self._health_check_loop, daemon=True ) self._health_thread.start() logger.info(f"Streamer pour {self.session_id} démarré") def stop(self): """Arrêter le streaming et finaliser la session côté serveur. Attend que la queue se vide (max 30s) avant de finaliser, pour que toutes les images soient envoyées au serveur. """ self.running = False # Attendre que la queue se vide (les images doivent être envoyées) if self._thread: drain_start = time.time() while not self.queue.empty() and (time.time() - drain_start) < 30: time.sleep(0.5) if not self.queue.empty(): logger.warning( f"Queue non vide après 30s ({self.queue.qsize()} items restants)" ) self._thread.join(timeout=5.0) if self._health_thread: self._health_thread.join(timeout=2.0) self._finalize_session() logger.info(f"Streamer pour {self.session_id} arrêté") def push_event(self, event_data: dict): """Enfile un événement pour envoi immédiat. Si la queue est pleine (backpressure), les heartbeat sont droppés tandis que les événements utilisateur (click, key, scroll, action) et screenshots sont toujours conservés. """ self._enqueue_with_backpressure("event", event_data) def push_image(self, image_path: str, screenshot_id: str): """Enfile une image pour envoi asynchrone.""" if not image_path: return # Ignorer les chemins vides (heartbeat sans changement) self._enqueue_with_backpressure("image", (image_path, screenshot_id)) # ========================================================================= # Backpressure — gestion de la queue bornée # ========================================================================= def _enqueue_with_backpressure(self, item_type: str, data): """Ajouter un item à la queue avec gestion du backpressure. Quand la queue est pleine : - Les événements prioritaires (click, key, action, screenshot) sont ajoutés en bloquant brièvement (0.5s) - Les heartbeat sont silencieusement droppés """ is_priority = self._is_priority_item(item_type, data) try: self.queue.put_nowait((item_type, data)) except queue.Full: if is_priority: # Événement prioritaire : on attend un peu pour l'ajouter try: self.queue.put((item_type, data), timeout=0.5) except queue.Full: logger.warning( f"Queue pleine — événement prioritaire droppé " f"(type={item_type})" ) else: # Heartbeat ou événement non-critique : on drop silencieusement logger.debug( f"Queue pleine — heartbeat/non-prioritaire droppé " f"(type={item_type})" ) def _is_priority_item(self, item_type: str, data) -> bool: """Vérifie si un item est prioritaire (ne doit pas être droppé). Les images sont toujours prioritaires. Pour les événements, on regarde le type d'événement (click, key, scroll, action). """ if item_type == "image": return True if item_type == "event" and isinstance(data, dict): event_type = data.get("type", "").lower() return event_type in PRIORITY_EVENT_TYPES return False # ========================================================================= # Boucle d'envoi # ========================================================================= def _stream_loop(self): """Boucle d'envoi asynchrone (thread daemon).""" consecutive_failures = 0 while self.running or not self.queue.empty(): try: item_type, data = self.queue.get(timeout=0.5) success = False if item_type == "event": success = self._send_with_retry(self._send_event, data) elif item_type == "image": success = self._send_with_retry(self._send_image, *data) self.queue.task_done() if success: consecutive_failures = 0 else: consecutive_failures += 1 if consecutive_failures >= 10: logger.warning( "10 échecs consécutifs — serveur marqué indisponible" ) self._server_available = False consecutive_failures = 0 except queue.Empty: continue except Exception as e: logger.error(f"Erreur Streaming Loop: {e}") # ========================================================================= # Retry avec backoff exponentiel # ========================================================================= def _send_with_retry(self, send_fn, *args) -> bool: """Tente l'envoi avec retry et backoff exponentiel. 3 tentatives max avec délais de 1s, 2s, 4s entre chaque. Retourne True si l'envoi a réussi, False sinon. """ # Première tentative (sans délai) if send_fn(*args): return True # Retries avec backoff for attempt, delay in enumerate(RETRY_DELAYS, start=1): if not self.running: # On arrête les retries si le streamer est en cours d'arrêt break logger.debug( f"Retry {attempt}/{MAX_RETRIES} dans {delay}s..." ) time.sleep(delay) if send_fn(*args): logger.debug(f"Retry {attempt} réussi") return True logger.debug(f"Envoi échoué après {MAX_RETRIES} retries") return False # ========================================================================= # Health-check périodique pour recovery # ========================================================================= def _health_check_loop(self): """Vérifie périodiquement si le serveur est redevenu disponible. Toutes les 30s, tente un GET /stats. Si le serveur répond, remet _server_available = True et ré-enregistre la session. """ while self.running: time.sleep(HEALTH_CHECK_INTERVAL_S) if not self.running: break if self._server_available: # Serveur déjà disponible, rien à faire continue # Tenter un health-check try: resp = requests.get( f"{STREAMING_ENDPOINT}/stats", headers=self._auth_headers(), timeout=3, ) if resp.ok: logger.info( "Health-check OK — serveur redevenu disponible, " "ré-enregistrement de la session" ) self._server_available = True self._register_session() except Exception: logger.debug("Health-check échoué — serveur toujours indisponible") # ========================================================================= # Compression JPEG # ========================================================================= def _compress_image_to_jpeg(self, path: str) -> tuple: """Compresse une image (PNG ou autre) en JPEG qualité 85 en mémoire. Retourne un tuple (bytes_io, content_type, filename_suffix). Si la compression échoue, renvoie le fichier original en PNG. """ try: img = Image.open(path) # Convertir en RGB si nécessaire (JPEG ne supporte pas l'alpha) if img.mode in ("RGBA", "LA", "P"): img = img.convert("RGB") buf = io.BytesIO() img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True) buf.seek(0) return buf, "image/jpeg", ".jpg" except FileNotFoundError: # Fichier introuvable — propager l'erreur (pas de fallback possible) logger.warning(f"Fichier image introuvable pour compression : {path}") raise except Exception as e: logger.warning(f"Compression JPEG échouée, envoi PNG brut: {e}") return None, None, None # ========================================================================= # Envois HTTP # ========================================================================= def _register_session(self): """Enregistrer la session auprès du serveur (avec identifiant machine).""" try: resp = requests.post( f"{STREAMING_ENDPOINT}/register", params={ "session_id": self.session_id, "machine_id": self.machine_id, }, headers=self._auth_headers(), timeout=3, ) if resp.ok: logger.info( f"Session {self.session_id} enregistrée sur le serveur " f"(machine={self.machine_id})" ) self._server_available = True else: logger.warning(f"Enregistrement session échoué: {resp.status_code}") except Exception as e: logger.debug(f"Serveur indisponible pour register: {e}") self._server_available = False def _finalize_session(self): """Finaliser la session (construction du workflow côté serveur). IMPORTANT : tente TOUJOURS l'envoi, indépendamment de _server_available. C'est la dernière chance de sauver les données de la session. """ try: resp = requests.post( f"{STREAMING_ENDPOINT}/finalize", params={ "session_id": self.session_id, "machine_id": self.machine_id, }, headers=self._auth_headers(), timeout=30, # Le build workflow peut prendre du temps ) if resp.ok: result = resp.json() logger.info(f"Session finalisée: {result}") else: logger.warning(f"Finalisation échouée: {resp.status_code}") except Exception as e: logger.debug(f"Finalisation échouée: {e}") def _send_event(self, event: dict) -> bool: """Envoyer un événement au serveur (avec identifiant machine).""" if not self._server_available: return False try: payload = { "session_id": self.session_id, "timestamp": time.time(), "event": event, "machine_id": self.machine_id, } resp = requests.post( f"{STREAMING_ENDPOINT}/event", json=payload, headers=self._auth_headers(), timeout=2, ) return resp.ok except Exception as e: logger.debug(f"Streaming Event échoué: {e}") return False def _send_image(self, path: str, shot_id: str) -> bool: """Envoyer un screenshot au serveur, compressé en JPEG. Utilise un context manager pour le fallback PNG afin d'éviter les fuites de descripteurs de fichier. """ if not self._server_available: return False try: # Tenter la compression JPEG (réduction ~5-10x vs PNG) jpeg_buf, content_type, suffix = self._compress_image_to_jpeg(path) params = { "session_id": self.session_id, "shot_id": shot_id, "machine_id": self.machine_id, } if jpeg_buf is not None: # Envoi du JPEG compressé (BytesIO, pas de fuite possible) files = { "file": (f"{shot_id}{suffix}", jpeg_buf, content_type) } resp = requests.post( f"{STREAMING_ENDPOINT}/image", files=files, params=params, headers=self._auth_headers(), timeout=5, ) return resp.ok else: # Fallback : envoi PNG original avec context manager with open(path, "rb") as f: files = { "file": (f"{shot_id}.png", f, "image/png") } resp = requests.post( f"{STREAMING_ENDPOINT}/image", files=files, params=params, headers=self._auth_headers(), timeout=5, ) return resp.ok except Exception as e: logger.debug(f"Streaming Image échoué: {e}") return False