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
+12
View File
@@ -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
+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()
+31
View File
@@ -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
)
+3
View File
@@ -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(
+1
View File
@@ -71,6 +71,7 @@ export default defineConfig({
items: [
{ label: 'Переменные окружения', slug: 'configuration/env-vars' },
{ label: 'Безопасность', slug: 'configuration/security' },
{ label: 'Телеметрия', slug: 'configuration/telemetry' },
],
},
{
+46
View File
@@ -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);
}
+3
View File
@@ -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",
+3
View File
@@ -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",
+92
View File
@@ -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