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, config_provider=lambda: self._config,
on_theme_change=self.set_theme, on_theme_change=self.set_theme,
current_theme=self._theme_name, current_theme=self._theme_name,
usage_reporter=self._report_usage,
) )
if key == "cfg": if key == "cfg":
return ConfigTab(self._content, palette=p, state=self._config) return ConfigTab(self._content, palette=p, state=self._config)
@@ -169,6 +170,45 @@ class AnonymisationApp(ctk.CTk):
license_client=self._license_client, 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: def _show(self, key: str) -> None:
self._active = key self._active = key
self._refresh_tabbar() self._refresh_tabbar()

View File

@@ -16,11 +16,13 @@ arrêt coopératif (entre deux documents).
from __future__ import annotations from __future__ import annotations
import threading import threading
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Callable, Optional, Sequence from typing import Callable, Optional, Sequence
from gui_batch_paths import build_batch_output_dir, list_supported_documents 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). # process_fn(doc_path, out_dir) -> dict de sortie (ignoré par le runner).
ProcessFn = Callable[[Path, Path], dict] ProcessFn = Callable[[Path, Path], dict]
@@ -61,6 +63,20 @@ def discover_documents(input_path, extensions: Optional[Sequence[str]] = None) -
return [] 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 @dataclass
class RunSummary: class RunSummary:
"""Résultat d'un run : compteurs et erreurs par document.""" """Résultat d'un run : compteurs et erreurs par document."""
@@ -70,6 +86,7 @@ class RunSummary:
failed: int = 0 failed: int = 0
stopped: bool = False stopped: bool = False
errors: list = field(default_factory=list) # list[tuple[str, str]] (nom, message) errors: list = field(default_factory=list) # list[tuple[str, str]] (nom, message)
documents: list = field(default_factory=list) # list[DocResult] (anonymisé)
@property @property
def ok(self) -> bool: def ok(self) -> bool:
@@ -148,6 +165,11 @@ class ProcessingRunner:
break break
if on_progress: if on_progress:
on_progress(index - 1, summary.total, doc.name) 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: try:
if input_path.is_dir(): if input_path.is_dir():
doc_out = build_batch_output_dir(root_dir, out_root, doc) doc_out = build_batch_output_dir(root_dir, out_root, doc)
@@ -158,9 +180,19 @@ class ProcessingRunner:
summary.succeeded += 1 summary.succeeded += 1
log(f"OK : {doc.name}") log(f"OK : {doc.name}")
except Exception as exc: # un échec n'interrompt pas le lot except Exception as exc: # un échec n'interrompt pas le lot
status = "failed"
summary.failed += 1 summary.failed += 1
summary.errors.append((doc.name, str(exc))) summary.errors.append((doc.name, str(exc)))
log(f"ÉCHEC : {doc.name}{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: if on_progress:
on_progress(index, summary.total, doc.name) on_progress(index, summary.total, doc.name)
return summary return summary

View File

@@ -43,6 +43,7 @@ class UsageTab(ctk.CTkFrame):
palette: dict | None = None, palette: dict | None = None,
on_theme_change=None, on_theme_change=None,
current_theme: str = theme_mod.DEFAULT_THEME, current_theme: str = theme_mod.DEFAULT_THEME,
usage_reporter=None,
**kwargs, **kwargs,
): ):
self._p = palette or theme_mod.get_palette(current_theme) self._p = palette or theme_mod.get_palette(current_theme)
@@ -52,6 +53,9 @@ class UsageTab(ctk.CTkFrame):
self._config_path = config_path self._config_path = config_path
self._on_theme_change = on_theme_change self._on_theme_change = on_theme_change
self._current_theme = current_theme 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._input_path: Path | None = None
self._output_dir: Path | None = None self._output_dir: Path | None = None
@@ -277,6 +281,21 @@ class UsageTab(ctk.CTkFrame):
self._progress.set(1.0) self._progress.set(1.0)
self._set_status(f"Terminé : {summary.succeeded} OK, {summary.failed} échec(s) sur {summary.total}.") self._set_status(f"Terminé : {summary.succeeded} OK, {summary.failed} échec(s) sur {summary.total}.")
self._show_results(summary) 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: def _show_results(self, summary) -> None:
p = self._p p = self._p

View File

@@ -123,6 +123,62 @@ class UsageTelemetryClient:
# --- file locale JSONL (rejeu best-effort des échecs) ----------------------- # --- 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: def spool_payload(path: Any, payload: dict) -> None:
"""Ajoute un payload à la file JSONL locale (ne lève pas).""" """Ajoute un payload à la file JSONL locale (ne lève pas)."""
try: try:

View File

@@ -163,3 +163,29 @@ def test_no_double_run(tmp_path):
release.set() release.set()
worker.join(timeout=2) worker.join(timeout=2)
assert runner.is_running is False assert runner.is_running is False
# -- détails par document (télémétrie) -------------------------------------
def test_run_records_per_document_details(tmp_path):
_touch(tmp_path / "a.pdf")
_touch(tmp_path / "b.pdf")
def fake(doc, out):
if doc.name == "b.pdf":
raise RuntimeError("boom")
return {}
runner = ProcessingRunner(process_fn=fake, extensions=_EXTS)
summary = runner.run(tmp_path)
assert len(summary.documents) == 2
statuses = {doc.ordinal: doc.status for doc in summary.documents}
assert statuses == {0: "success", 1: "failed"}
for doc in summary.documents:
assert doc.extension == "pdf"
assert isinstance(doc.duration_ms, int)
# RGPD : aucun nom/chemin de fichier dans les détails
assert not hasattr(doc, "path")
assert not hasattr(doc, "filename")
assert not hasattr(doc, "name")

View File

@@ -164,3 +164,26 @@ def test_reglages_labels_renamed_and_profile_readable(ctk_root, tmp_path, monkey
tab._open_terms_table() tab._open_terms_table()
tab.update_idletasks() tab.update_idletasks()
tab.destroy() tab.destroy()
def test_usage_tab_finish_calls_reporter(ctk_root):
"""Câblage : la fin de run appelle le reporter de télémétrie (non bloquant)."""
import threading
from gui_v6.processing_runner import RunSummary
from gui_v6.tabs.tab_usage import UsageTab
called = threading.Event()
captured = {}
def reporter(summary):
captured["summary"] = summary
called.set()
tab = UsageTab(ctk_root, usage_reporter=reporter)
ctk_root.update_idletasks()
summary = RunSummary(total=1, succeeded=1)
tab._finish(summary)
assert called.wait(timeout=3.0) # reporter appelé en thread daemon
assert captured["summary"] is summary
tab.destroy()

View File

@@ -135,3 +135,67 @@ def test_flush_keeps_failures(tmp_path):
# l'échec reste en file pour un prochain essai # l'échec reste en file pour un prochain essai
assert spool.exists() assert spool.exists()
assert "a" in spool.read_text(encoding="utf-8") assert "a" in spool.read_text(encoding="utf-8")
# --- report_run_summary (câblage fin de run) --------------------------------
class _FakeDoc:
def __init__(self, ordinal, page_count, status, duration_ms=None, extension=None):
self.ordinal = ordinal
self.page_count = page_count
self.status = status
self.duration_ms = duration_ms
self.extension = extension
class _FakeSummary:
def __init__(self, documents):
self.documents = documents
def test_report_run_summary_builds_and_sends():
from gui_v6.usage_telemetry import report_run_summary
sess = _FakeSession(status_code=200)
summary = _FakeSummary([
_FakeDoc(0, 5, "success", extension="pdf"),
_FakeDoc(1, None, "failed"),
])
ok = report_run_summary(
summary, base_url="http://localhost:8088", license_ref="LIC-1",
machine_id="machine-0001", session=sess, app_version="6.0.0-g1",
)
assert ok is True
payload = sess.calls[0]["json"]
assert payload["license_ref"] == "LIC-1"
assert payload["app_name"] == "gui_v6"
assert payload["document_count"] == 2
assert payload["total_pages"] == 5
blob = json.dumps(payload, ensure_ascii=False).lower()
assert "filename" not in blob and "path" not in blob
def test_report_run_summary_no_send_without_license():
from gui_v6.usage_telemetry import report_run_summary
sess = _FakeSession(status_code=200)
summary = _FakeSummary([_FakeDoc(0, 1, "success")])
ok = report_run_summary(
summary, base_url="http://x", license_ref=None, machine_id="m1", session=sess
)
assert ok is False
assert sess.calls == [] # aucun appel réseau sans licence
def test_report_run_summary_network_down_spools(tmp_path):
from gui_v6.usage_telemetry import report_run_summary
sess = _FakeSession(raise_exc=OSError("down"))
summary = _FakeSummary([_FakeDoc(0, 1, "success")])
spool = tmp_path / "spool.jsonl"
ok = report_run_summary(
summary, base_url="http://x", license_ref="LIC-1", machine_id="m1",
session=sess, spool_path=spool,
)
assert ok is False # ne lève pas
assert spool.exists() # conservé pour rejeu