feat(gui): client diagnostics non bloquant + spool best-effort (E3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,3 +77,114 @@ def build_diagnostics_payload(
|
|||||||
"failed_count": failed,
|
"failed_count": failed,
|
||||||
"items": clean_items,
|
"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
|
||||||
|
|||||||
@@ -44,3 +44,67 @@ def test_build_payload_counts_and_no_pii_leak():
|
|||||||
assert forbidden not in blob, f"fuite RGPD : {forbidden}"
|
assert forbidden not in blob, f"fuite RGPD : {forbidden}"
|
||||||
for item in payload["items"]:
|
for item in payload["items"]:
|
||||||
assert set(item) <= {"ordinal", "status", "error_type", "error_code", "duration_ms"}
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user