|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import logging |
| 4 | +import os |
| 5 | +import uuid |
| 6 | +from typing import Any, Final, NamedTuple |
| 7 | + |
| 8 | +import requests |
| 9 | +import tomli |
| 10 | +import tomli_w |
| 11 | +from xdg_base_dirs import xdg_config_home |
| 12 | + |
| 13 | +_LOGGER: Final = logging.getLogger(__name__) |
| 14 | +_TELEMETRY_ENDPOINT: Final = 'https://ojlk1fzi13.execute-api.us-east-1.amazonaws.com/dev/track' |
| 15 | + |
| 16 | +KPROFILE_CONFIG_DIR: Final = xdg_config_home() / 'kprofile' |
| 17 | +KPROFILE_CONFIG_FILE: Final = KPROFILE_CONFIG_DIR / 'config.toml' |
| 18 | +TELEMETRY_MESSAGE: Final = ( |
| 19 | + f'Telemetry: sending anonymous usage data. You can opt out by setting KPROFILE_TELEMETRY_DISABLED=true' |
| 20 | + f' or by setting consent=false in {KPROFILE_CONFIG_FILE}' |
| 21 | +) |
| 22 | + |
| 23 | + |
| 24 | +class TelemetryConfig(NamedTuple): |
| 25 | + user: str |
| 26 | + consent: bool |
| 27 | + |
| 28 | + @staticmethod |
| 29 | + def load() -> TelemetryConfig: |
| 30 | + """Load config from disk. |
| 31 | +
|
| 32 | + Raises: |
| 33 | + FileNotFoundError: If the config file does not exist. |
| 34 | + ValueError: If the user_id field is missing from the config file. |
| 35 | + """ |
| 36 | + if not KPROFILE_CONFIG_FILE.exists(): |
| 37 | + raise FileNotFoundError(f'Config not found: {KPROFILE_CONFIG_FILE}') |
| 38 | + |
| 39 | + with open(KPROFILE_CONFIG_FILE, 'rb') as f: |
| 40 | + data = tomli.load(f) |
| 41 | + |
| 42 | + user = data.get('user', {}) |
| 43 | + if 'user_id' not in user: |
| 44 | + raise ValueError(f'Missing user_id in config: {KPROFILE_CONFIG_FILE}') |
| 45 | + return TelemetryConfig(user=str(user['user_id']), consent=bool(user.get('consent', True))) |
| 46 | + |
| 47 | + def write(self) -> None: |
| 48 | + """Persist config to disk.""" |
| 49 | + KPROFILE_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) |
| 50 | + with open(KPROFILE_CONFIG_FILE, 'wb') as f: |
| 51 | + tomli_w.dump({'user': {'user_id': self.user, 'consent': self.consent}}, f) |
| 52 | + |
| 53 | + |
| 54 | +def _telemetry_enabled() -> bool: |
| 55 | + return os.getenv('KPROFILE_TELEMETRY_DISABLED', '').lower() != 'true' |
| 56 | + |
| 57 | + |
| 58 | +def emit_event(event: str, properties: dict[str, Any] | None = None) -> None: |
| 59 | + """Send a telemetry event to the proxy server. |
| 60 | +
|
| 61 | + Args: |
| 62 | + event: Event name to track. |
| 63 | + properties: Optional key/value metadata (e.g. ``{'version': '1.2.3'}``). |
| 64 | + """ |
| 65 | + if not _telemetry_enabled(): |
| 66 | + return |
| 67 | + |
| 68 | + try: |
| 69 | + config = TelemetryConfig.load() |
| 70 | + except FileNotFoundError: |
| 71 | + config = TelemetryConfig(user=str(uuid.uuid4()), consent=True) |
| 72 | + config.write() |
| 73 | + print(TELEMETRY_MESSAGE) |
| 74 | + except ValueError as e: |
| 75 | + _LOGGER.warning(f'Telemetry config is invalid: {KPROFILE_CONFIG_FILE}', exc_info=e) |
| 76 | + return |
| 77 | + |
| 78 | + if not config.consent: |
| 79 | + return |
| 80 | + |
| 81 | + try: |
| 82 | + requests.post( |
| 83 | + _TELEMETRY_ENDPOINT, |
| 84 | + json={'user_id': config.user, 'event': event, 'properties': properties or {}}, |
| 85 | + timeout=2, |
| 86 | + ) |
| 87 | + except requests.exceptions.ReadTimeout: |
| 88 | + _LOGGER.debug(f'Telemetry event timed out: {event}') |
| 89 | + except Exception as e: |
| 90 | + _LOGGER.warning(f'Telemetry event failed: {event}', exc_info=e) |
0 commit comments