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:
@@ -73,3 +73,15 @@ FRONTEND_PORT=8082
|
|||||||
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||||
# Keep loopback for local proxy; add your proxy network if needed.
|
# Keep loopback for local proxy; add your proxy network if needed.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1
|
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
|
||||||
|
|||||||
@@ -588,6 +588,17 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
),
|
),
|
||||||
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
||||||
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
||||||
|
# ─── System ────────────────────────────────────────────────────
|
||||||
|
SettingField(
|
||||||
|
"TELEMETRY_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"system",
|
||||||
|
"Анонимная статистика установки",
|
||||||
|
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
|
||||||
|
"пользователей в виде диапазона. Без персональных данных, токенов и "
|
||||||
|
"доменов. Помогает понять число активных установок и какие версии "
|
||||||
|
"используются. Можно отключить здесь без перезапуска.",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -722,6 +733,7 @@ def manifest_payload() -> List[dict]:
|
|||||||
"backups": 9,
|
"backups": 9,
|
||||||
"devices": 10,
|
"devices": 10,
|
||||||
"subscription_guides": 10,
|
"subscription_guides": 10,
|
||||||
|
"system": 12,
|
||||||
}
|
}
|
||||||
exclusive_map = {
|
exclusive_map = {
|
||||||
key: opposite
|
key: opposite
|
||||||
|
|||||||
@@ -934,87 +934,12 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
|||||||
return cache["data"]
|
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:
|
def _resolve_app_version() -> str:
|
||||||
global _APP_VERSION_CACHE
|
# Single source of truth shared with the telemetry worker so the admin
|
||||||
if _APP_VERSION_CACHE:
|
# sidebar and the install beacon always report the same version.
|
||||||
return _APP_VERSION_CACHE
|
from bot.utils.app_version import resolve_app_version
|
||||||
|
|
||||||
env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip()
|
return resolve_app_version()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def _enforce_webapp_rate_limit(
|
async def _enforce_webapp_rate_limit(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -1100,6 +1100,37 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
LOG_SUPPORT: bool = Field(default=True, description="Send support ticket notifications")
|
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(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True
|
env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from bot.services.backup_worker import BackupWorker
|
|||||||
from bot.services.locale_override_service import load_locale_overrides
|
from bot.services.locale_override_service import load_locale_overrides
|
||||||
from bot.services.subscription_notification_worker import SubscriptionNotificationWorker
|
from bot.services.subscription_notification_worker import SubscriptionNotificationWorker
|
||||||
from bot.services.tariff_worker import TariffTrafficWorker
|
from bot.services.tariff_worker import TariffTrafficWorker
|
||||||
|
from bot.services.telemetry_worker import TelemetryWorker
|
||||||
from bot.utils.message_queue import init_queue_manager
|
from bot.utils.message_queue import init_queue_manager
|
||||||
from config.settings import get_settings
|
from config.settings import get_settings
|
||||||
|
|
||||||
@@ -209,6 +210,8 @@ async def main() -> None:
|
|||||||
)
|
)
|
||||||
backup_worker = BackupWorker(settings, bot, session_factory=session_factory)
|
backup_worker = BackupWorker(settings, bot, session_factory=session_factory)
|
||||||
tasks.append(asyncio.create_task(backup_worker.run(), name="BackupWorker"))
|
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)))
|
tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services)))
|
||||||
for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)):
|
for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)):
|
||||||
tasks.append(
|
tasks.append(
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export default defineConfig({
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Переменные окружения', slug: 'configuration/env-vars' },
|
{ label: 'Переменные окружения', slug: 'configuration/env-vars' },
|
||||||
{ label: 'Безопасность', slug: 'configuration/security' },
|
{ label: 'Безопасность', slug: 'configuration/security' },
|
||||||
|
{ label: 'Телеметрия', slug: 'configuration/telemetry' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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` | Интервал между сигналами. |
|
||||||
@@ -347,6 +347,7 @@
|
|||||||
support: "Поддержка",
|
support: "Поддержка",
|
||||||
devices: "Устройства",
|
devices: "Устройства",
|
||||||
subscription_guides: "Connection guides",
|
subscription_guides: "Connection guides",
|
||||||
|
system: "Система",
|
||||||
};
|
};
|
||||||
return adminText(`settings_section_${id}`, {}, map[id] || id);
|
return adminText(`settings_section_${id}`, {}, map[id] || id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1092,6 +1092,9 @@
|
|||||||
"admin_settings_section_backups": "Backups",
|
"admin_settings_section_backups": "Backups",
|
||||||
"admin_settings_section_devices": "Devices",
|
"admin_settings_section_devices": "Devices",
|
||||||
"admin_settings_section_support": "Support",
|
"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_common": "Common",
|
||||||
"admin_settings_subsection_checkout": "Checkout",
|
"admin_settings_subsection_checkout": "Checkout",
|
||||||
"admin_settings_subsection_remnawave": "Remnawave",
|
"admin_settings_subsection_remnawave": "Remnawave",
|
||||||
|
|||||||
@@ -1092,6 +1092,9 @@
|
|||||||
"admin_settings_section_backups": "Бэкапы",
|
"admin_settings_section_backups": "Бэкапы",
|
||||||
"admin_settings_section_devices": "Устройства",
|
"admin_settings_section_devices": "Устройства",
|
||||||
"admin_settings_section_support": "Поддержка",
|
"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_common": "Общие",
|
||||||
"admin_settings_subsection_checkout": "Оформление оплаты",
|
"admin_settings_subsection_checkout": "Оформление оплаты",
|
||||||
"admin_settings_subsection_remnawave": "Remnawave",
|
"admin_settings_subsection_remnawave": "Remnawave",
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user