Skip to content

Commit 874a384

Browse files
authored
Upstream anonymous telemetry module (#4909)
Add `pyk.telemetry` module to centralize anonymous usage tracking. Downstream tools (`kontrol`, `komet`, etc.) can import `emit_event` instead of duplicating this logic in each project. - Stores a persistent anonymous user ID in `~/.config/kprofile/config.toml` - Opt-out via `KPROFILE_TELEMETRY_DISABLED=true` or `consent=false` in the config file - Consent notice printed once, on first run - Callers pass tool-specific metadata (e.g. `{'version': '1.2.3'}`) via `properties` - Added `requests`, `tomli-w`, and `types-requests` dependencies
1 parent 2624d37 commit 874a384

3 files changed

Lines changed: 545 additions & 425 deletions

File tree

pyk/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ dependencies = [
1818
"psutil>=5.9.5,<6",
1919
"pybind11>=2.10.3,<3",
2020
"pytest",
21+
"requests>=2.32.3",
2122
"textual>=0.27.0",
2223
"tomli>=2.0.1,<3",
24+
"tomli-w>=1.2.0",
2325
"xdg-base-dirs>=6.0.1,<7",
2426
]
2527

@@ -59,6 +61,7 @@ dev = [
5961
"pytest-timeout",
6062
"pyupgrade",
6163
"types-psutil>=5.9.5.10,<6",
64+
"types-requests>=2.33.0.20260408",
6265
]
6366

6467
[tool.hatch.metadata]

pyk/src/pyk/telemetry.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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

Comments
 (0)