Source code for rekordbox_set_list_manager.services.telemetry

"""Anonymous opt-in telemetry — local structured event log with optional remote sink.

All telemetry is **opt-in, off by default**.  Nothing is written or sent until the
user enables "Send anonymous usage data" in Settings.

Local log
---------
Events are written as newline-delimited JSON to
``~/.cache/rekordbox_set_list_manager/events.jsonl``.  The file rotates (renamed to
``events.1.jsonl``) when it reaches ``_MAX_LOG_BYTES`` (5 MB).  Only one
rotation backup is kept.

Remote sink (optional)
-----------------------
If the environment variable ``SET_MANAGER_TELEMETRY_URL`` is set and non-empty,
batches of events are POSTed as ``application/x-ndjson`` to that URL in a
background thread.  Failures are silently ignored — telemetry must never affect
the main application.

Privacy
-------
No PII is collected.  File paths, playlist names, track titles, artist names,
and user identifiers are **never** recorded.
"""

from __future__ import annotations

import contextlib
import datetime
import json
import os
import threading
from pathlib import Path
from typing import Any

import platformdirs

from rekordbox_set_list_manager.utils.config import get as config_get

# ---------------------------------------------------------------------------
# Internal constants
# ---------------------------------------------------------------------------

_LOG_DIR = Path(platformdirs.user_cache_dir("rekordbox_set_list_manager"))
_LOG_PATH = _LOG_DIR / "events.jsonl"
_LOG_BACKUP = _LOG_DIR / "events.1.jsonl"
_MAX_LOG_BYTES = 5 * 1024 * 1024  # 5 MB

_CONFIG_KEY = "telemetry_enabled"


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


[docs] def is_enabled() -> bool: """Return True if the user has opted in to telemetry.""" return bool(config_get(_CONFIG_KEY, default=False))
[docs] def record(event: str, **fields: Any) -> None: # noqa: ANN401 """Record a telemetry event if opt-in is active. Parameters ---------- event: Short snake_case event name, e.g. ``"app_start"``. **fields: Additional scalar metadata — counts, enum values, booleans. No strings containing user content (paths, names, etc.). """ if not is_enabled(): return payload: dict[str, Any] = { "event": event, "ts": datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"), **fields, } _write_local(payload) _maybe_send_remote(payload)
# --------------------------------------------------------------------------- # Local logging # --------------------------------------------------------------------------- _write_lock = threading.Lock() def _write_local(payload: dict[str, Any]) -> None: with _write_lock, contextlib.suppress(Exception): _LOG_DIR.mkdir(parents=True, exist_ok=True) if _LOG_PATH.exists() and _LOG_PATH.stat().st_size >= _MAX_LOG_BYTES: _LOG_BACKUP.unlink(missing_ok=True) _LOG_PATH.rename(_LOG_BACKUP) with _LOG_PATH.open("a", encoding="utf-8") as fh: fh.write(json.dumps(payload) + "\n") # --------------------------------------------------------------------------- # Remote sink # --------------------------------------------------------------------------- def _maybe_send_remote(payload: dict[str, Any]) -> None: url = os.environ.get("SET_MANAGER_TELEMETRY_URL", "").strip() if not url: return # Fire-and-forget in a daemon thread — never block the UI. t = threading.Thread(target=_post, args=(url, payload), daemon=True) t.start() def _post(url: str, payload: dict[str, Any]) -> None: with contextlib.suppress(Exception): import urllib.request # noqa: PLC0415 data = (json.dumps(payload) + "\n").encode() req = urllib.request.Request( # noqa: S310 url, data=data, headers={"Content-Type": "application/x-ndjson"}, method="POST", ) with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310 _ = resp.read()