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:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user