From a0ea2261f4f12256b71734c2252872100600dc09 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 14:14:02 +0300 Subject: [PATCH] feat: anonymous opt-out install telemetry beacon Add a once-a-day anonymous heartbeat (PostHog) so maintainers can see active installs and version/OS breakdowns. Self-hosted friendly: opt out via TELEMETRY_ENABLED in .env or the Admin -> System toggle (applied without a restart), or by clearing the endpoint/key. - Share version resolution in bot/utils/app_version.py so the admin sidebar and the beacon report the same build version - TelemetryWorker sends an opaque install id plus coarse facts only (version, OS/arch, python, locale, enabled providers, user-count range); never tokens, domains or user data - Register the worker in main_worker.py behind a Redis single-flight lock - Expose TELEMETRY_* settings and an Admin -> System manifest toggle - Document the payload and opt-out in docs/configuration/telemetry.md - Cover bucketing, payload shape and anonymity with tests --- .env.example | 12 + .../bot/app/web/admin_settings_manifest.py | 12 + backend/bot/app/web/webapp/assets.py | 83 +------ backend/bot/services/telemetry_worker.py | 212 ++++++++++++++++++ backend/bot/utils/app_version.py | 126 +++++++++++ backend/config/settings.py | 31 +++ backend/main_worker.py | 3 + docs-site/astro.config.mjs | 1 + docs/configuration/telemetry.md | 46 ++++ .../src/admin/sections/SettingsSection.svelte | 1 + locales/en.json | 3 + locales/ru.json | 3 + tests/test_telemetry_worker.py | 92 ++++++++ 13 files changed, 546 insertions(+), 79 deletions(-) create mode 100644 backend/bot/services/telemetry_worker.py create mode 100644 backend/bot/utils/app_version.py create mode 100644 docs/configuration/telemetry.md create mode 100644 tests/test_telemetry_worker.py diff --git a/.env.example b/.env.example index a8adbbe..63920b6 100644 --- a/.env.example +++ b/.env.example @@ -73,3 +73,15 @@ FRONTEND_PORT=8082 # Reverse proxy IPs/CIDRs trusted for X-Forwarded-For. # Keep loopback for local proxy; add your proxy network if needed. TRUSTED_PROXIES=127.0.0.1,::1 + +# ─── Anonymous install telemetry (opt-out) ────────────────────────────── +# Once a day the worker sends a single anonymous "heartbeat" so the project +# maintainer can see how many installs are active and which versions/OSes are +# used. It contains an opaque random install id and coarse facts only: +# version, OS/arch, Python version, language, enabled payment providers and a +# user-count RANGE (e.g. "51-200"). No bot token, domain, user data or any +# personal information is ever sent. Full details: docs/configuration/telemetry.md +# +# Set to False to disable, or toggle it any time in Admin -> System -> +# "Anonymous install analytics" (applies without a restart). +TELEMETRY_ENABLED=True diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 006ce91..9c86789 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -588,6 +588,17 @@ SETTINGS_MANIFEST: List[SettingField] = [ ), SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"), SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"), + # ─── System ──────────────────────────────────────────────────── + SettingField( + "TELEMETRY_ENABLED", + "bool", + "system", + "Анонимная статистика установки", + "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число " + "пользователей в виде диапазона. Без персональных данных, токенов и " + "доменов. Помогает понять число активных установок и какие версии " + "используются. Можно отключить здесь без перезапуска.", + ), ] @@ -722,6 +733,7 @@ def manifest_payload() -> List[dict]: "backups": 9, "devices": 10, "subscription_guides": 10, + "system": 12, } exclusive_map = { key: opposite diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index 3a656d4..cd53216 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -934,87 +934,12 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: return cache["data"] -def _run_git_command(*args: str) -> str: - repo_root = APP_ROOT - try: - result = subprocess.run( - ["git", *args], - cwd=repo_root, - check=True, - capture_output=True, - text=True, - timeout=1.5, - ) - except (OSError, subprocess.SubprocessError): - return "" - return result.stdout.strip() - - -def _normalize_version_branch(raw_branch: str) -> str: - branch = str(raw_branch or "").strip() - for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"): - if branch.startswith(prefix): - branch = branch[len(prefix) :] - break - if branch == "HEAD": - return "" - return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48] - - -def _resolve_version_branch() -> str: - for env_name in ( - "REMNAWAVE_MINISHOP_BRANCH", - "GIT_BRANCH", - "BRANCH_NAME", - "GITHUB_REF_NAME", - "CI_COMMIT_REF_NAME", - ): - branch = _normalize_version_branch(os.getenv(env_name, "")) - if branch: - return branch - return _normalize_version_branch( - _run_git_command("branch", "--show-current") - or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD") - ) - - -def _format_app_version(tag: str, sha: str, branch: str) -> str: - branch_suffix = "" if not branch or branch == "main" else f"-{branch}" - if tag and sha: - return f"{tag}{branch_suffix}+g{sha}" - if sha: - return f"dev{branch_suffix}+g{sha}" - if tag: - return f"{tag}{branch_suffix}" - return f"dev{branch_suffix}+unknown" - - def _resolve_app_version() -> str: - global _APP_VERSION_CACHE - if _APP_VERSION_CACHE: - return _APP_VERSION_CACHE + # Single source of truth shared with the telemetry worker so the admin + # sidebar and the install beacon always report the same version. + from bot.utils.app_version import resolve_app_version - env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip() - if env_version: - _APP_VERSION_CACHE = env_version - return env_version - - build_version_path = APP_ROOT / ".build-version" - try: - build_version = build_version_path.read_text(encoding="utf-8").strip() - except OSError: - build_version = "" - if build_version: - _APP_VERSION_CACHE = build_version - return build_version - - tag = _run_git_command("describe", "--tags", "--abbrev=0") - sha = _run_git_command("rev-parse", "--short", "HEAD") - branch = _resolve_version_branch() - version = _format_app_version(tag, sha, branch) - - _APP_VERSION_CACHE = version - return version + return resolve_app_version() async def _enforce_webapp_rate_limit( diff --git a/backend/bot/services/telemetry_worker.py b/backend/bot/services/telemetry_worker.py new file mode 100644 index 0000000..c6b6b54 --- /dev/null +++ b/backend/bot/services/telemetry_worker.py @@ -0,0 +1,212 @@ +"""Anonymous install telemetry beacon (self-hosted friendly, opt-out). + +Once per ``TELEMETRY_INTERVAL_HOURS`` the worker sends a single obfuscation-free +but fully anonymous "heartbeat" to a PostHog ingestion endpoint so the project +maintainer can see how many installs are active and which versions/OSes are in +use. No personal data, bot tokens, domains or user identities are sent — only +an opaque per-install UUID plus coarse environment facts. + +Operators can opt out in three independent ways, any of which stops the beacon: + * ``TELEMETRY_ENABLED=false`` in ``.env`` + * the *System → Anonymous install analytics* toggle in the web admin (stored + as a DB override and re-read every tick, so no restart is required) + * leaving ``TELEMETRY_ENDPOINT`` / ``TELEMETRY_API_KEY`` empty in the image + +Delivery is strictly fire-and-forget: every failure is swallowed so telemetry +can never delay, block or crash the worker. +""" + +from __future__ import annotations + +import asyncio +import logging +import platform +import uuid +from typing import Any, Dict, List + +import aiohttp +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.infra.redis import redis_lock +from bot.utils.app_version import resolve_app_version, resolve_app_version_tag +from config.settings import Settings +from db.dal import app_settings_dal, user_dal + +logger = logging.getLogger(__name__) + +INSTALLATION_ID_KEY = "TELEMETRY_INSTALLATION_ID" +TELEMETRY_ENABLED_KEY = "TELEMETRY_ENABLED" +HEARTBEAT_EVENT = "installation_heartbeat" +INITIAL_DELAY_SECONDS = 300 +HTTP_TIMEOUT_SECONDS = 10 + +# Report the user count as a coarse range so individual installs stay anonymous +# and the property keeps a low cardinality for breakdowns. +_USER_BUCKETS = ( + (0, "0"), + (10, "1-10"), + (50, "11-50"), + (200, "51-200"), + (1000, "201-1000"), + (5000, "1001-5000"), +) + + +def _bucket_users(count: int) -> str: + for upper, label in _USER_BUCKETS: + if count <= upper: + return label + return "5000+" + + +class TelemetryWorker: + def __init__(self, settings: Settings, session_factory: sessionmaker): + self.settings = settings + self.session_factory = session_factory + self._stopped = asyncio.Event() + + def stop(self) -> None: + self._stopped.set() + + def _delivery_configured(self) -> bool: + return bool( + str(self.settings.TELEMETRY_ENDPOINT or "").strip() + and str(self.settings.TELEMETRY_API_KEY or "").strip() + ) + + async def run(self) -> None: + if not self._delivery_configured(): + logger.info("Telemetry endpoint/key not configured; anonymous beacon disabled") + return + logger.info( + "Anonymous install telemetry is ON (endpoint=%s, every %sh). " + "It sends an opaque install id, version, OS and a user-count range — " + "no personal data. Opt out via TELEMETRY_ENABLED=false or " + "Admin -> System -> Anonymous install analytics. " + "See docs/configuration/telemetry.md.", + self.settings.TELEMETRY_ENDPOINT, + self.settings.TELEMETRY_INTERVAL_HOURS, + ) + await self._sleep(INITIAL_DELAY_SECONDS) + while not self._stopped.is_set(): + try: + await self._beacon_tick() + except Exception: + logger.exception("Telemetry beacon tick failed") + await self._sleep(self._interval_seconds()) + + def _interval_seconds(self) -> int: + return max(1, int(self.settings.TELEMETRY_INTERVAL_HOURS or 24)) * 3600 + + async def _sleep(self, seconds: float) -> None: + try: + await asyncio.wait_for(self._stopped.wait(), timeout=seconds) + except asyncio.TimeoutError: + pass + + async def _beacon_tick(self) -> None: + # A short-lived lock keeps a single beacon per interval even when the + # worker is scaled to several replicas. Without Redis the lock yields + # True, which is correct for the common single-worker deployment. + async with redis_lock( + self.settings, + "telemetry-beacon", + ttl_seconds=max(60, self._interval_seconds() // 2), + ) as acquired: + if not acquired: + return + async with self.session_factory() as session: + if not await self._is_enabled(session): + return + installation_id = await self._get_or_create_installation_id(session) + payload = await self._build_payload(session, installation_id) + await session.commit() + await self._send(payload) + + async def _is_enabled(self, session: AsyncSession) -> bool: + # The web admin writes the toggle as a DB override. The worker process + # does not apply overrides onto its in-memory Settings, so read it + # straight from the table; the env default applies when unset. + present, value = await app_settings_dal.get_override_value(session, TELEMETRY_ENABLED_KEY) + if present: + return bool(value) + return bool(self.settings.TELEMETRY_ENABLED) + + async def _get_or_create_installation_id(self, session: AsyncSession) -> str: + present, value = await app_settings_dal.get_override_value(session, INSTALLATION_ID_KEY) + if present and value: + return str(value) + installation_id = str(uuid.uuid4()) + await app_settings_dal.upsert_override( + session, + key=INSTALLATION_ID_KEY, + value=installation_id, + updated_by=None, + ) + return installation_id + + def _enabled_payment_providers(self) -> List[str]: + try: + from bot.payment_providers import iter_provider_specs + + providers = [ + str(spec.id) + for spec in iter_provider_specs() + if spec.is_effectively_enabled(self.settings) + ] + return sorted(set(providers)) + except Exception: + logger.debug("Telemetry: failed to enumerate payment providers", exc_info=True) + return [] + + async def _build_payload(self, session: AsyncSession, installation_id: str) -> Dict[str, Any]: + try: + user_count = await user_dal.count_all_users(session) + except Exception: + logger.debug("Telemetry: failed to count users", exc_info=True) + user_count = 0 + + version = resolve_app_version() + version_tag = resolve_app_version_tag() + # Person properties (``$set``) snapshot the latest state per install, so + # "version breakdown" in PostHog is a person-property breakdown. + person_props = { + "app_version": version, + "app_version_tag": version_tag, + "os": platform.system().lower() or "unknown", + "arch": platform.machine().lower() or "unknown", + "python_version": platform.python_version(), + "locale": str(self.settings.DEFAULT_LANGUAGE or ""), + "users_bucket": _bucket_users(int(user_count or 0)), + "webapp_enabled": bool(self.settings.WEBAPP_ENABLED), + "panel_configured": bool(str(self.settings.PANEL_API_URL or "").strip()), + "payment_providers": self._enabled_payment_providers(), + } + properties = { + **person_props, + "$lib": "remnawave-minishop", + "$lib_version": version, + "$set": person_props, + } + return { + "api_key": str(self.settings.TELEMETRY_API_KEY or "").strip(), + "event": HEARTBEAT_EVENT, + "distinct_id": installation_id, + "properties": properties, + } + + async def _send(self, payload: Dict[str, Any]) -> None: + url = str(self.settings.TELEMETRY_ENDPOINT or "").strip().rstrip("/") + "/capture/" + timeout = aiohttp.ClientTimeout(total=HTTP_TIMEOUT_SECONDS) + try: + async with aiohttp.ClientSession(timeout=timeout) as http: + async with http.post(url, json=payload) as resp: + if resp.status >= 400: + body = (await resp.text())[:200] + logger.warning("Telemetry beacon rejected: HTTP %s %s", resp.status, body) + else: + logger.debug("Telemetry beacon delivered (HTTP %s)", resp.status) + except Exception: + # Never let telemetry surface as an error to operators. + logger.debug("Telemetry beacon delivery failed", exc_info=True) diff --git a/backend/bot/utils/app_version.py b/backend/bot/utils/app_version.py new file mode 100644 index 0000000..3695062 --- /dev/null +++ b/backend/bot/utils/app_version.py @@ -0,0 +1,126 @@ +"""Single source of truth for the application version string. + +Resolution order mirrors the Dockerfile build chain so the dev checkout and +the runtime container agree on the value: + + REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe`` + > ``dev+unknown`` + +The same value powers the admin sidebar (web process) and the anonymous +telemetry beacon (worker process), so "active installs" and version +breakdowns line up across both. +""" + +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path +from typing import Optional + +# ``/app`` in the container (parent of ``/app/backend``); repo root in dev. +# Matches where the Dockerfile drops .build-version / .build-tag / .build-commit. +APP_ROOT = Path(__file__).resolve().parents[3] + +_APP_VERSION_CACHE: Optional[str] = None + + +def _run_git_command(*args: str) -> str: + try: + result = subprocess.run( + ["git", *args], + cwd=APP_ROOT, + check=True, + capture_output=True, + text=True, + timeout=1.5, + ) + except (OSError, subprocess.SubprocessError): + return "" + return result.stdout.strip() + + +def _normalize_version_branch(raw_branch: str) -> str: + branch = str(raw_branch or "").strip() + for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"): + if branch.startswith(prefix): + branch = branch[len(prefix) :] + break + if branch in ("", "HEAD"): + return "" + return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48] + + +def _resolve_version_branch() -> str: + for env_name in ( + "REMNAWAVE_MINISHOP_BRANCH", + "GIT_BRANCH", + "BRANCH_NAME", + "GITHUB_REF_NAME", + "CI_COMMIT_REF_NAME", + ): + branch = _normalize_version_branch(os.getenv(env_name, "")) + if branch: + return branch + return _normalize_version_branch( + _run_git_command("branch", "--show-current") + or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD") + ) + + +def _format_app_version(tag: str, sha: str, branch: str) -> str: + branch_suffix = "" if not branch or branch == "main" else f"-{branch}" + if tag and sha: + return f"{tag}{branch_suffix}+g{sha}" + if sha: + return f"dev{branch_suffix}+g{sha}" + if tag: + return f"{tag}{branch_suffix}" + return f"dev{branch_suffix}+unknown" + + +def _read_build_file(name: str) -> str: + try: + return (APP_ROOT / name).read_text(encoding="utf-8").strip() + except OSError: + return "" + + +def resolve_app_version() -> str: + """Full version string (cached), e.g. ``v3.4.6+gabc1234``.""" + global _APP_VERSION_CACHE + if _APP_VERSION_CACHE: + return _APP_VERSION_CACHE + + env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip() + if env_version: + _APP_VERSION_CACHE = env_version + return env_version + + build_version = _read_build_file(".build-version") + if build_version: + _APP_VERSION_CACHE = build_version + return build_version + + tag = _run_git_command("describe", "--tags", "--abbrev=0") + sha = _run_git_command("rev-parse", "--short", "HEAD") + branch = _resolve_version_branch() + version = _format_app_version(tag, sha, branch) + _APP_VERSION_CACHE = version + return version + + +def resolve_app_version_tag() -> str: + """Clean release tag for low-cardinality breakdowns, e.g. ``v3.4.6``. + + Prefers the build-time ``.build-tag`` artifact, then a live ``git + describe``; falls back to the full version string when no tag is known. + """ + build_tag = _read_build_file(".build-tag") + if build_tag and build_tag != "unknown": + return build_tag + tag = _run_git_command("describe", "--tags", "--abbrev=0") + if tag: + return tag + return resolve_app_version() diff --git a/backend/config/settings.py b/backend/config/settings.py index a349829..23bbfd6 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1100,6 +1100,37 @@ class Settings(BaseSettings): ) LOG_SUPPORT: bool = Field(default=True, description="Send support ticket notifications") + # Anonymous install telemetry (self-hosted friendly, opt-out). + TELEMETRY_ENABLED: bool = Field( + default=True, + description=( + "Send an anonymous daily install heartbeat (version, OS, locale, " + "user-count range). No personal data. Opt out here, via the web " + "admin, or by clearing TELEMETRY_ENDPOINT/TELEMETRY_API_KEY." + ), + ) + TELEMETRY_ENDPOINT: str = Field( + default="https://eu.i.posthog.com", + description="PostHog ingestion host. Empty disables telemetry.", + ) + TELEMETRY_API_KEY: str = Field( + default="phc_sRiAbbrjhyYPfsgBwSZyLvujDXBLaDpmWKt6paGmCCMm", + description=( + "PostHog project API key (phc_...). Safe to ship in the image: it is " + "a write-only ingest key. Empty disables telemetry." + ), + ) + TELEMETRY_INTERVAL_HOURS: int = Field(default=24) + + @property + def telemetry_configured(self) -> bool: + """True when telemetry is enabled and has a delivery target.""" + return bool( + self.TELEMETRY_ENABLED + and str(self.TELEMETRY_ENDPOINT or "").strip() + and str(self.TELEMETRY_API_KEY or "").strip() + ) + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True ) diff --git a/backend/main_worker.py b/backend/main_worker.py index 547d7fc..27cd019 100644 --- a/backend/main_worker.py +++ b/backend/main_worker.py @@ -27,6 +27,7 @@ from bot.services.backup_worker import BackupWorker from bot.services.locale_override_service import load_locale_overrides from bot.services.subscription_notification_worker import SubscriptionNotificationWorker from bot.services.tariff_worker import TariffTrafficWorker +from bot.services.telemetry_worker import TelemetryWorker from bot.utils.message_queue import init_queue_manager from config.settings import get_settings @@ -209,6 +210,8 @@ async def main() -> None: ) backup_worker = BackupWorker(settings, bot, session_factory=session_factory) tasks.append(asyncio.create_task(backup_worker.run(), name="BackupWorker")) + telemetry_worker = TelemetryWorker(settings, session_factory) + tasks.append(asyncio.create_task(telemetry_worker.run(), name="TelemetryWorker")) tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services))) for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)): tasks.append( diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index aa6c3e1..5f1b806 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -71,6 +71,7 @@ export default defineConfig({ items: [ { label: 'Переменные окружения', slug: 'configuration/env-vars' }, { label: 'Безопасность', slug: 'configuration/security' }, + { label: 'Телеметрия', slug: 'configuration/telemetry' }, ], }, { diff --git a/docs/configuration/telemetry.md b/docs/configuration/telemetry.md new file mode 100644 index 0000000..64fafbb --- /dev/null +++ b/docs/configuration/telemetry.md @@ -0,0 +1,46 @@ +# Анонимная телеметрия установок + +Чтобы понимать, сколько инсталляций активно и какие версии используются, бот может раз в сутки отправлять один **полностью обезличенный** «heartbeat». Телеметрия задумана как self-hosted friendly: её легко выключить, она не содержит персональных данных и не мешает работе бота. + +## Что отправляется + +Каждый сигнал — это случайный непривязанный идентификатор установки плюс грубые факты об окружении: + +| Поле | Пример | Назначение | +| --- | --- | --- | +| `installation_id` | `f47ac10b-...` (UUIDv4) | Случайный идентификатор установки. Генерируется один раз, хранится в БД. Не выводится из токена, домена или ID администраторов. | +| `app_version` | `v3.4.6+gabc1234` | Полная версия сборки. | +| `app_version_tag` | `v3.4.6` | Релизный тег для разбивки по версиям. | +| `os` / `arch` | `linux` / `x86_64` | Платформа. | +| `python_version` | `3.12.7` | Версия рантайма. | +| `locale` | `ru` | Язык по умолчанию. | +| `payment_providers` | `["stars", "yookassa"]` | Идентификаторы включённых платёжных провайдеров (без ключей и секретов). | +| `users_bucket` | `51-200` | Число пользователей в виде **диапазона**, не точное значение. | +| `webapp_enabled` / `panel_configured` | `true` | Флаги конфигурации. | + +Время приёма (`last_seen`) проставляет коллектор. «Активные установки» = уникальные `installation_id`, от которых сигнал приходил за последние ~48 часов; «разбивка по версиям» = последняя версия на каждую установку. + +## Чего там нет + +Никогда не отправляются: токен бота, домены, URL вебхуков, ключи платёжных систем и Remnawave, ID или данные пользователей, точное число пользователей, какой-либо контент. + +## Как выключить + +Достаточно любого из способов: + +- **`.env`**: `TELEMETRY_ENABLED=False`, затем перезапуск. +- **Веб-админка**: `Admin → System → «Анонимная статистика установки»`. Переключатель применяется без перезапуска (читается из БД на каждом тике). +- **Сборка/образ**: оставить пустыми `TELEMETRY_ENDPOINT` или `TELEMETRY_API_KEY` — без точки доставки беакон не запускается. + +## Доставка + +Беакон шлёт `POST {TELEMETRY_ENDPOINT}/capture/` в формате PostHog (`{api_key, event, distinct_id, properties}`). Доставка строго fire-and-forget: таймаут 10 секунд, любые ошибки проглатываются и логируются на уровне `debug` — телеметрия не может задержать или уронить воркер. При нескольких репликах воркера за интервал отправляет только одна (через Redis-lock). + +## Переменные окружения + +| Переменная | По умолчанию | Назначение | +| --- | --- | --- | +| `TELEMETRY_ENABLED` | `True` | Главный переключатель (opt-out). Дублируется тогглом в админке. | +| `TELEMETRY_ENDPOINT` | `https://eu.i.posthog.com` | Хост приёма PostHog. Пусто — телеметрия выключена. | +| `TELEMETRY_API_KEY` | пусто | Project API key PostHog (`phc_...`). Это write-only ключ ingest, его безопасно зашивать в образ. Пусто — телеметрия выключена. | +| `TELEMETRY_INTERVAL_HOURS` | `24` | Интервал между сигналами. | diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte index 62bddcd..3443016 100644 --- a/frontend/src/admin/sections/SettingsSection.svelte +++ b/frontend/src/admin/sections/SettingsSection.svelte @@ -347,6 +347,7 @@ support: "Поддержка", devices: "Устройства", subscription_guides: "Connection guides", + system: "Система", }; return adminText(`settings_section_${id}`, {}, map[id] || id); } diff --git a/locales/en.json b/locales/en.json index 6acb519..ab61a77 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1092,6 +1092,9 @@ "admin_settings_section_backups": "Backups", "admin_settings_section_devices": "Devices", "admin_settings_section_support": "Support", + "admin_settings_section_system": "System", + "admin_settings_field_telemetry_enabled_label": "Anonymous install analytics", + "admin_settings_field_telemetry_enabled_description": "Sends one anonymous heartbeat per day (version, OS, locale, user-count range). No personal data, tokens or domains. Helps gauge how many installs are active and which versions are in use. Toggling this off takes effect without a restart.", "admin_settings_subsection_common": "Common", "admin_settings_subsection_checkout": "Checkout", "admin_settings_subsection_remnawave": "Remnawave", diff --git a/locales/ru.json b/locales/ru.json index f35aa3a..b4d5224 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1092,6 +1092,9 @@ "admin_settings_section_backups": "Бэкапы", "admin_settings_section_devices": "Устройства", "admin_settings_section_support": "Поддержка", + "admin_settings_section_system": "Система", + "admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки", + "admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.", "admin_settings_subsection_common": "Общие", "admin_settings_subsection_checkout": "Оформление оплаты", "admin_settings_subsection_remnawave": "Remnawave", diff --git a/tests/test_telemetry_worker.py b/tests/test_telemetry_worker.py new file mode 100644 index 0000000..138d75e --- /dev/null +++ b/tests/test_telemetry_worker.py @@ -0,0 +1,92 @@ +"""Telemetry beacon: payload shape, anonymity and bucketing. + +These tests never touch the network or a database: ``_build_payload`` is given +a ``None`` session (the user-count lookup degrades to 0) and a fixed install id, +so we can assert the PostHog-shaped envelope and that no secrets leak. +""" + +import asyncio +import json + +import pytest + +from bot.services.telemetry_worker import HEARTBEAT_EVENT, TelemetryWorker, _bucket_users +from config.settings import Settings + + +@pytest.fixture +def settings(monkeypatch) -> Settings: + # Provide the minimal required env so Settings() validates without a .env, + # keeping the test self-contained in CI. + monkeypatch.setenv("BOT_TOKEN", "1234567890:AA_secret_bot_token_value") + monkeypatch.setenv("POSTGRES_USER", "u") + monkeypatch.setenv("POSTGRES_PASSWORD", "Sup3rSecretDbPassw0rd") + monkeypatch.setenv("POSTGRES_DB", "d") + monkeypatch.setenv("ADMIN_IDS", "1") + return Settings() + + +def test_bucket_users_boundaries(): + assert _bucket_users(0) == "0" + assert _bucket_users(1) == "1-10" + assert _bucket_users(10) == "1-10" + assert _bucket_users(11) == "11-50" + assert _bucket_users(50) == "11-50" + assert _bucket_users(200) == "51-200" + assert _bucket_users(1000) == "201-1000" + assert _bucket_users(5000) == "1001-5000" + assert _bucket_users(5001) == "5000+" + assert _bucket_users(99999) == "5000+" + + +def test_build_payload_shape(settings): + worker = TelemetryWorker(settings, None) + payload = asyncio.run(worker._build_payload(None, "install-123")) + + assert payload["event"] == HEARTBEAT_EVENT + assert payload["distinct_id"] == "install-123" + assert payload["api_key"] == settings.TELEMETRY_API_KEY.strip() + + props = payload["properties"] + for key in ( + "app_version", + "app_version_tag", + "os", + "arch", + "python_version", + "locale", + "users_bucket", + "payment_providers", + "webapp_enabled", + "panel_configured", + ): + assert key in props, f"missing property: {key}" + + assert isinstance(props["payment_providers"], list) + # No DB session -> user count degrades to the smallest bucket. + assert props["users_bucket"] == "0" + # Person properties mirror the event properties so PostHog breakdowns work. + assert props["$set"]["app_version"] == props["app_version"] + assert props["$lib"] == "remnawave-minishop" + + +def test_payload_contains_no_secrets_or_pii(settings): + worker = TelemetryWorker(settings, None) + payload = asyncio.run(worker._build_payload(None, "install-123")) + blob = json.dumps(payload) + + # The bot token and DB password must never appear in the beacon. + assert settings.BOT_TOKEN not in blob + assert settings.POSTGRES_PASSWORD not in blob + + +def test_delivery_disabled_without_endpoint_or_key(settings, monkeypatch): + worker = TelemetryWorker(settings, None) + assert worker._delivery_configured() is True + + monkeypatch.setattr(settings, "TELEMETRY_API_KEY", "", raising=False) + assert worker._delivery_configured() is False + + monkeypatch.setattr(settings, "TELEMETRY_API_KEY", "phc_x", raising=False) + monkeypatch.setattr(settings, "TELEMETRY_ENDPOINT", "", raising=False) + assert worker._delivery_configured() is False