feat(gui): câbler l'envoi de la télémétrie d'usage en fin de run

Le module usage_telemetry est maintenant réellement branché : la GUI V6
envoie les statistiques au portail après chaque run (les stats web
restaient vides sans cela).

- processing_runner : RunSummary porte une liste DocResult (ordinal,
  page_count via page_count_for, status, duration_ms, extension) — peuplée
  dans la boucle. Aucun nom/chemin de fichier.
- usage_telemetry : report_run_summary(summary, base_url, license_ref,
  machine_id, session, ...) construit le payload depuis le RunSummary et
  l'envoie (non bloquant). N'envoie RIEN sans license_ref. Spool JSONL si
  échec réseau.
- tab_usage : _finish() déclenche l'envoi en thread daemon (jamais bloquant
  pour l'UI ni le run).
- app : fournit le reporter à UsageTab avec le contexte licence (base_url du
  LicenseClient, license_ref via local_status, machine_id, app_version).

Tests : RunSummary.documents peuplé (0 chemin) ; report_run_summary (payload
correct, réseau KO → spool sans crash, pas d'envoi sans licence) ; _finish
appelle le reporter. 252 tests unit OK (0 régression), self-test OK.
V5/moteur/app_aivanov intacts, 0 dépendance. Aucun build/push sans GO Dom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:24:43 +02:00
parent d30f7b74ef
commit 1bbe70a911
7 changed files with 260 additions and 0 deletions

View File

@@ -158,6 +158,7 @@ class AnonymisationApp(ctk.CTk):
config_provider=lambda: self._config,
on_theme_change=self.set_theme,
current_theme=self._theme_name,
usage_reporter=self._report_usage,
)
if key == "cfg":
return ConfigTab(self._content, palette=p, state=self._config)
@@ -169,6 +170,45 @@ class AnonymisationApp(ctk.CTk):
license_client=self._license_client,
)
# -- télémétrie d'usage -----------------------------------------------
def _usage_session(self):
if getattr(self, "_usage_http_session", None) is None:
try:
import requests
self._usage_http_session = requests.Session()
except Exception:
self._usage_http_session = None
return self._usage_http_session
def _report_usage(self, summary) -> None:
"""Envoie la télémétrie d'usage en fin de run (non bloquant, best-effort).
N'envoie rien si aucune licence locale valide. Ne lève jamais.
"""
try:
from gui_v6 import __version__ as gui_version
from gui_v6 import usage_telemetry
from gui_v6.machine_id import default_machine_id
session = self._usage_session()
if session is None:
return
status = self._safe_local_status()
base_url = getattr(self._license_client, "_base_url", "") or "http://localhost"
usage_telemetry.report_run_summary(
summary,
base_url=base_url,
license_ref=getattr(status, "license_ref", None),
machine_id=default_machine_id(),
session=session,
app_name="gui_v6",
app_version=gui_version,
)
except Exception:
pass
def _show(self, key: str) -> None:
self._active = key
self._refresh_tabbar()

View File

@@ -16,11 +16,13 @@ arrêt coopératif (entre deux documents).
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional, Sequence
from gui_batch_paths import build_batch_output_dir, list_supported_documents
from gui_v6.usage_telemetry import page_count_for
# process_fn(doc_path, out_dir) -> dict de sortie (ignoré par le runner).
ProcessFn = Callable[[Path, Path], dict]
@@ -61,6 +63,20 @@ def discover_documents(input_path, extensions: Optional[Sequence[str]] = None) -
return []
@dataclass
class DocResult:
"""Détail anonymisé d'un document traité (pour la télémétrie d'usage).
RGPD : aucun nom ni chemin de fichier — uniquement des métadonnées.
"""
ordinal: int
page_count: Optional[int]
status: str # "success" | "failed"
duration_ms: Optional[int]
extension: Optional[str]
@dataclass
class RunSummary:
"""Résultat d'un run : compteurs et erreurs par document."""
@@ -70,6 +86,7 @@ class RunSummary:
failed: int = 0
stopped: bool = False
errors: list = field(default_factory=list) # list[tuple[str, str]] (nom, message)
documents: list = field(default_factory=list) # list[DocResult] (anonymisé)
@property
def ok(self) -> bool:
@@ -148,6 +165,11 @@ class ProcessingRunner:
break
if on_progress:
on_progress(index - 1, summary.total, doc.name)
# Détails anonymisés pour la télémétrie (jamais le nom/chemin).
extension = doc.suffix.lstrip(".").lower() or None
page_count = page_count_for(doc)
started = time.monotonic()
status = "success"
try:
if input_path.is_dir():
doc_out = build_batch_output_dir(root_dir, out_root, doc)
@@ -158,9 +180,19 @@ class ProcessingRunner:
summary.succeeded += 1
log(f"OK : {doc.name}")
except Exception as exc: # un échec n'interrompt pas le lot
status = "failed"
summary.failed += 1
summary.errors.append((doc.name, str(exc)))
log(f"ÉCHEC : {doc.name}{exc}")
summary.documents.append(
DocResult(
ordinal=index - 1,
page_count=page_count,
status=status,
duration_ms=int((time.monotonic() - started) * 1000),
extension=extension,
)
)
if on_progress:
on_progress(index, summary.total, doc.name)
return summary

View File

@@ -43,6 +43,7 @@ class UsageTab(ctk.CTkFrame):
palette: dict | None = None,
on_theme_change=None,
current_theme: str = theme_mod.DEFAULT_THEME,
usage_reporter=None,
**kwargs,
):
self._p = palette or theme_mod.get_palette(current_theme)
@@ -52,6 +53,9 @@ class UsageTab(ctk.CTkFrame):
self._config_path = config_path
self._on_theme_change = on_theme_change
self._current_theme = current_theme
# Callback(summary) appelé en fin de run pour la télémétrie d'usage
# (envoi non bloquant, injecté par l'app avec le contexte licence).
self._usage_reporter = usage_reporter
self._input_path: Path | None = None
self._output_dir: Path | None = None
@@ -277,6 +281,21 @@ class UsageTab(ctk.CTkFrame):
self._progress.set(1.0)
self._set_status(f"Terminé : {summary.succeeded} OK, {summary.failed} échec(s) sur {summary.total}.")
self._show_results(summary)
self._send_usage_telemetry(summary)
def _send_usage_telemetry(self, summary) -> None:
"""Envoie la télémétrie d'usage en fin de run, sans bloquer l'UI ni le run."""
reporter = self._usage_reporter
if reporter is None:
return
def work():
try:
reporter(summary)
except Exception:
pass # un échec de télémétrie ne doit jamais remonter
threading.Thread(target=work, daemon=True).start()
def _show_results(self, summary) -> None:
p = self._p

View File

@@ -123,6 +123,62 @@ class UsageTelemetryClient:
# --- file locale JSONL (rejeu best-effort des échecs) -----------------------
def documents_from_summary(summary: Any) -> list[dict]:
"""Extrait la liste de documents (RGPD-safe) d'un ``RunSummary``.
Ne lit que les attributs autorisés ; aucun nom/chemin n'est récupéré.
"""
docs: list[dict] = []
for item in getattr(summary, "documents", None) or []:
docs.append(
{
"ordinal": getattr(item, "ordinal", 0),
"page_count": getattr(item, "page_count", None),
"status": getattr(item, "status", "success"),
"duration_ms": getattr(item, "duration_ms", None),
"extension": getattr(item, "extension", None),
}
)
return docs
def report_run_summary(
summary: Any,
*,
base_url: str,
license_ref: Optional[str],
machine_id: Optional[str],
session: Any,
app_name: str = "gui_v6",
app_version: Optional[str] = None,
run_id: Optional[str] = None,
spool_path: Any = None,
logger: Optional[Callable[[str], None]] = None,
) -> bool:
"""Construit le payload depuis un ``RunSummary`` et l'envoie (non bloquant).
N'envoie RIEN si ``license_ref`` est absent. En cas d'échec réseau, spoole le
payload (si ``spool_path``) pour un rejeu ultérieur. Ne lève jamais.
"""
log = logger or (lambda _msg: None)
if not license_ref:
log("télémétrie ignorée : aucune licence locale valide")
return False
payload = build_usage_payload(
run_id=run_id or new_run_id(),
app_name=app_name,
app_version=app_version,
license_ref=license_ref,
machine_id=machine_id,
documents=documents_from_summary(summary),
)
client = UsageTelemetryClient(base_url, session=session, logger=log)
ok = client.report(payload)
if not ok and spool_path is not None:
spool_payload(spool_path, payload)
return ok
def spool_payload(path: Any, payload: dict) -> None:
"""Ajoute un payload à la file JSONL locale (ne lève pas)."""
try: