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:
@@ -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