From 8eb8cf99998cacc77d4bd288a8e00b30ddcb53f5 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Tue, 30 Jun 2026 10:39:25 +0200 Subject: [PATCH] feat(gui): client diagnostics non bloquant + spool best-effort (E3) Co-Authored-By: Claude Opus 4.8 (1M context) --- gui_v6/diagnostics.py | 111 ++++++++++++++++++++++++++ tests/unit/test_gui_v6_diagnostics.py | 64 +++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/gui_v6/diagnostics.py b/gui_v6/diagnostics.py index d27d63f..e9dccf5 100644 --- a/gui_v6/diagnostics.py +++ b/gui_v6/diagnostics.py @@ -77,3 +77,114 @@ def build_diagnostics_payload( "failed_count": failed, "items": clean_items, } + + +class DiagnosticsClient: + """Envoie un payload diagnostic au portail. Non bloquant : capture toute erreur.""" + + def __init__( + self, + base_url: str, + session: Any, + timeout: float = 4.0, + logger: Optional[Callable[[str], None]] = None, + ) -> None: + self._url = base_url.rstrip("/") + REPORT_PATH + self._session = session + self._timeout = timeout + self._log = logger or (lambda _msg: None) + + def report(self, payload: dict) -> bool: + try: + resp = self._session.post(self._url, json=payload, timeout=self._timeout) + status = getattr(resp, "status_code", 0) + ok = 200 <= int(status) < 300 + if not ok: + self._log(f"diagnostics report refusé (HTTP {status})") + return ok + except Exception as exc: # réseau absent, timeout, etc. + self._log(f"diagnostics report échec (non bloquant) : {exc}") + return False + + +def report_run_diagnostics( + 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, + duration_ms: Optional[int] = 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("diagnostics ignorés : aucune licence locale valide") + return False + payload = build_diagnostics_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, + duration_ms=duration_ms, + items=items_from_summary(summary), + ) + client = DiagnosticsClient(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: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + with p.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(payload, ensure_ascii=False) + "\n") + except Exception: + pass + + +def flush_spool(path: Any, client: "DiagnosticsClient") -> int: + """Tente d'envoyer chaque payload en file ; conserve ceux qui échouent. + + Retourne le nombre de payloads envoyés. Ne lève jamais. + """ + p = Path(path) + if not p.exists(): + return 0 + try: + lines = [ln for ln in p.read_text(encoding="utf-8").splitlines() if ln.strip()] + except Exception: + return 0 + remaining: list[str] = [] + sent = 0 + for line in lines: + try: + payload = json.loads(line) + except Exception: + continue + if client.report(payload): + sent += 1 + else: + remaining.append(line) + try: + if remaining: + p.write_text("\n".join(remaining) + "\n", encoding="utf-8") + else: + p.unlink(missing_ok=True) + except Exception: + pass + return sent diff --git a/tests/unit/test_gui_v6_diagnostics.py b/tests/unit/test_gui_v6_diagnostics.py index 452a09c..ba89773 100644 --- a/tests/unit/test_gui_v6_diagnostics.py +++ b/tests/unit/test_gui_v6_diagnostics.py @@ -44,3 +44,67 @@ def test_build_payload_counts_and_no_pii_leak(): assert forbidden not in blob, f"fuite RGPD : {forbidden}" for item in payload["items"]: assert set(item) <= {"ordinal", "status", "error_type", "error_code", "duration_ms"} + + +class _FakeResp: + def __init__(self, status_code): + self.status_code = status_code + + +class _FakeSession: + def __init__(self, status_code=200, raise_exc=None): + self.status_code = status_code + self.raise_exc = raise_exc + self.calls = [] + + def post(self, url, json=None, timeout=None): + self.calls.append((url, json, timeout)) + if self.raise_exc: + raise self.raise_exc + return _FakeResp(self.status_code) + + +def test_client_report_ok_on_2xx(): + sess = _FakeSession(status_code=200) + client = diagnostics.DiagnosticsClient("https://app.aivanov.eu/", session=sess) + assert client.report({"run_id": "r"}) is True + assert sess.calls[0][0] == "https://app.aivanov.eu/api/v1/diagnostics/report" + + +def test_client_report_false_on_network_error_without_raising(): + sess = _FakeSession(raise_exc=RuntimeError("no network")) + client = diagnostics.DiagnosticsClient("https://app.aivanov.eu", session=sess) + assert client.report({"run_id": "r"}) is False # ne lève pas + + +def test_report_run_diagnostics_no_send_without_license(tmp_path): + sess = _FakeSession() + ok = diagnostics.report_run_diagnostics( + SimpleNamespace(documents=[]), base_url="https://app.aivanov.eu", + license_ref=None, machine_id="m" * 12, session=sess, + spool_path=tmp_path / "spool.jsonl", + ) + assert ok is False and sess.calls == [] + + +def test_report_run_diagnostics_network_down_spools(tmp_path): + sess = _FakeSession(raise_exc=RuntimeError("down")) + spool = tmp_path / "spool.jsonl" + summary = SimpleNamespace(documents=[_doc(ordinal=0, status="failed", + error_type="ValueError", error_code="processing_error")]) + ok = diagnostics.report_run_diagnostics( + summary, base_url="https://app.aivanov.eu", license_ref="LIC-1", + machine_id="m" * 12, session=sess, spool_path=spool, + ) + assert ok is False and spool.exists() + line = json.loads(spool.read_text(encoding="utf-8").splitlines()[0]) + assert line["failed_count"] == 1 + + +def test_flush_spool_sends_and_clears(tmp_path): + spool = tmp_path / "spool.jsonl" + diagnostics.spool_payload(spool, {"run_id": "r1"}) + diagnostics.spool_payload(spool, {"run_id": "r2"}) + sent = diagnostics.flush_spool(spool, diagnostics.DiagnosticsClient( + "https://app.aivanov.eu", session=_FakeSession(status_code=200))) + assert sent == 2 and not spool.exists()