diff --git a/src/api/routes.py b/src/api/routes.py index cce2167..afda3ce 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -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 diff --git a/src/api/tracking.py b/src/api/tracking.py index 00d25c5..c952d6b 100644 --- a/src/api/tracking.py +++ b/src/api/tracking.py @@ -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 - _queue.join() + # 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) diff --git a/tests/api/test_tracking.py b/tests/api/test_tracking.py index dd0a662..44d9f54 100644 --- a/tests/api/test_tracking.py +++ b/tests/api/test_tracking.py @@ -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"