From 1bbe70a9115a569003b5f2190e2d0520984131dd Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Mon, 15 Jun 2026 21:24:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui):=20c=C3=A2bler=20l'envoi=20de=20la=20?= =?UTF-8?q?t=C3=A9l=C3=A9m=C3=A9trie=20d'usage=20en=20fin=20de=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- gui_v6/app.py | 40 +++++++++++++ gui_v6/processing_runner.py | 32 +++++++++++ gui_v6/tabs/tab_usage.py | 19 ++++++ gui_v6/usage_telemetry.py | 56 ++++++++++++++++++ tests/unit/test_gui_v6_processing_runner.py | 26 +++++++++ tests/unit/test_gui_v6_profiles.py | 23 ++++++++ tests/unit/test_gui_v6_usage_telemetry.py | 64 +++++++++++++++++++++ 7 files changed, 260 insertions(+) diff --git a/gui_v6/app.py b/gui_v6/app.py index cf88750..db61a68 100644 --- a/gui_v6/app.py +++ b/gui_v6/app.py @@ -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() diff --git a/gui_v6/processing_runner.py b/gui_v6/processing_runner.py index 061ab9a..fd1794a 100644 --- a/gui_v6/processing_runner.py +++ b/gui_v6/processing_runner.py @@ -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 diff --git a/gui_v6/tabs/tab_usage.py b/gui_v6/tabs/tab_usage.py index 41ec6ee..7b3bd7b 100644 --- a/gui_v6/tabs/tab_usage.py +++ b/gui_v6/tabs/tab_usage.py @@ -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 diff --git a/gui_v6/usage_telemetry.py b/gui_v6/usage_telemetry.py index 4c1b70b..a7a7f1e 100644 --- a/gui_v6/usage_telemetry.py +++ b/gui_v6/usage_telemetry.py @@ -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: diff --git a/tests/unit/test_gui_v6_processing_runner.py b/tests/unit/test_gui_v6_processing_runner.py index 99a3ff5..78d2e1d 100644 --- a/tests/unit/test_gui_v6_processing_runner.py +++ b/tests/unit/test_gui_v6_processing_runner.py @@ -163,3 +163,29 @@ def test_no_double_run(tmp_path): release.set() worker.join(timeout=2) 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") diff --git a/tests/unit/test_gui_v6_profiles.py b/tests/unit/test_gui_v6_profiles.py index df6b2fd..10982da 100644 --- a/tests/unit/test_gui_v6_profiles.py +++ b/tests/unit/test_gui_v6_profiles.py @@ -164,3 +164,26 @@ def test_reglages_labels_renamed_and_profile_readable(ctk_root, tmp_path, monkey tab._open_terms_table() tab.update_idletasks() 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() diff --git a/tests/unit/test_gui_v6_usage_telemetry.py b/tests/unit/test_gui_v6_usage_telemetry.py index ed9d880..6ee7943 100644 --- a/tests/unit/test_gui_v6_usage_telemetry.py +++ b/tests/unit/test_gui_v6_usage_telemetry.py @@ -135,3 +135,67 @@ def test_flush_keeps_failures(tmp_path): # l'échec reste en file pour un prochain essai assert spool.exists() 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