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.
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -71,6 +71,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ label: 'Переменные окружения', slug: 'configuration/env-vars' },
|
||||
{ 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: "Поддержка",
|
||||
devices: "Устройства",
|
||||
subscription_guides: "Connection guides",
|
||||
system: "Система",
|
||||
};
|
||||
return adminText(`settings_section_${id}`, {}, map[id] || id);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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