API: envoi Matomo fire-and-forget en async (#78)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-05-13 14:34:52 +02:00
parent 912507b1d9
commit 985af0fdc7
3 changed files with 105 additions and 4 deletions
+7
View File
@@ -68,6 +68,13 @@ def _track_consumption(response):
token_id = getattr(g, "token_id", None)
if token_id is not None:
tracking.enqueue_counter_update(token_id)
tracking.enqueue_matomo_event(
token_id=token_id,
path=request.path,
query_string=request.query_string.decode("utf-8", errors="replace"),
status_code=response.status_code,
user_agent=request.headers.get("User-Agent", ""),
)
return response
+54 -3
View File
@@ -1,14 +1,16 @@
import os
import queue
import threading
from typing import Optional
import httpx
from src.api import tokens_db
from src.utils import logger
_STOP_SENTINEL = object()
_queue: Optional[queue.Queue] = None
_worker_thread: Optional[threading.Thread] = None
_db_path: Optional[str] = None
def _worker_loop(q: queue.Queue, db_path: str) -> None:
@@ -28,15 +30,55 @@ def _worker_loop(q: queue.Queue, db_path: str) -> None:
token_id,
exc_info=True,
)
elif kind == "matomo":
try:
_post_matomo(**payload)
except Exception: # noqa: BLE001
logger.warning("tracking: échec envoi Matomo", exc_info=True)
finally:
q.task_done()
def _post_matomo(url: str, params: dict) -> None:
"""POST fire-and-forget vers la Tracking API Matomo. Mockable en test."""
httpx.post(url, data=params, timeout=5.0)
def enqueue_matomo_event(
token_id: int,
path: str,
query_string: str,
status_code: int,
user_agent: str,
) -> None:
if _queue is None:
return
if os.getenv("MATOMO_TRACKING_ENABLED", "false").lower() != "true":
return
url = os.getenv("MATOMO_URL")
site_id = os.getenv("MATOMO_SITE_ID")
if not url or not site_id:
return
full_url = f"https://decp.info{path}"
if query_string:
full_url += f"?{query_string}"
params = {
"idsite": site_id,
"rec": "1",
"url": full_url,
"action_name": f"API {path}",
"uid": f"token-{token_id}",
"dimension1": str(token_id),
"dimension2": str(status_code),
"ua": user_agent,
}
_queue.put(("matomo", {"url": url, "params": params}))
def start_worker(db_path: str) -> None:
global _queue, _worker_thread, _db_path
global _queue, _worker_thread
if _worker_thread is not None and _worker_thread.is_alive():
return
_db_path = db_path
_queue = queue.Queue()
_worker_thread = threading.Thread(
target=_worker_loop, args=(_queue, db_path), daemon=True
@@ -64,4 +106,13 @@ def flush(timeout: float = 2.0) -> None:
"""Attend que la queue soit drainée. Utile en test."""
if _queue is None:
return
# Use a sentinel approach to properly honour the timeout
done_event = threading.Event()
def _wait():
_queue.join()
done_event.set()
t = threading.Thread(target=_wait, daemon=True)
t.start()
done_event.wait(timeout=timeout)
+43
View File
@@ -34,3 +34,46 @@ def test_after_request_hook_increments_counter_async(api_client, valid_token_hea
rows = tokens_db.list_tokens(db_path)
assert rows[0]["count_total"] == 1
def test_matomo_disabled_skips_call(monkeypatch, api_client, valid_token_header):
monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "false")
client, _ = api_client
from src.api import tracking
called = []
monkeypatch.setattr(
tracking,
"_post_matomo",
lambda **kw: called.append(kw),
)
client.get("/api/v1/health")
tracking.flush(timeout=2.0)
assert called == []
def test_matomo_enabled_posts_event(monkeypatch, api_client, valid_token_header):
monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "true")
monkeypatch.setenv("MATOMO_URL", "https://matomo.example/matomo.php")
monkeypatch.setenv("MATOMO_SITE_ID", "42")
from src.api import tracking
captured = []
monkeypatch.setattr(
tracking,
"_post_matomo",
lambda **kw: captured.append(kw),
)
client, _ = api_client
client.get("/api/v1/schema", headers=valid_token_header)
tracking.flush(timeout=2.0)
assert len(captured) == 1
call = captured[0]
assert call["params"]["idsite"] == "42"
assert call["params"]["rec"] == "1"
assert "token-" in call["params"]["uid"]
assert call["params"]["dimension2"] == "200"