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
This commit is contained in:
3252a8
2026-06-01 14:14:02 +03:00
parent 5bb1400917
commit a0ea2261f4
13 changed files with 546 additions and 79 deletions
@@ -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
+4 -79
View File
@@ -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(
+212
View File
@@ -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)
+126
View File
@@ -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()