Completely removed telemetry

This commit is contained in:
maziggy
2026-01-26 09:34:04 +01:00
parent d3ebbe503c
commit bb7e8398bc
12 changed files with 103 additions and 622 deletions
-1
View File
@@ -73,7 +73,6 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
"capture_finish_photo",
"spoolman_enabled",
"check_updates",
"telemetry_enabled",
"virtual_printer_enabled",
"ftp_retry_enabled",
"mqtt_enabled",
-4
View File
@@ -100,7 +100,6 @@ from backend.app.services.printer_manager import (
from backend.app.services.smart_plug_manager import smart_plug_manager
from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
from backend.app.services.tasmota import tasmota_service
from backend.app.services.telemetry import start_telemetry_loop
# Track active prints: {(printer_id, filename): archive_id}
_active_prints: dict[tuple[int, str], int] = {}
@@ -2071,9 +2070,6 @@ async def lifespan(app: FastAPI):
# Start printer runtime tracking
start_runtime_tracking()
# Start anonymous telemetry (opt-out via settings)
asyncio.create_task(start_telemetry_loop(async_session))
# Initialize virtual printer manager
from backend.app.services.virtual_printer import virtual_printer_manager
-4
View File
@@ -53,9 +53,6 @@ class AppSettings(BaseModel):
# Default printer for operations
default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
# Telemetry
telemetry_enabled: bool = Field(default=True, description="Send anonymous usage data to help improve BamBuddy")
# Virtual Printer
virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
@@ -141,7 +138,6 @@ class AppSettingsUpdate(BaseModel):
date_format: str | None = None
time_format: str | None = None
default_printer_id: int | None = None
telemetry_enabled: bool | None = None
virtual_printer_enabled: bool | None = None
virtual_printer_access_code: str | None = None
virtual_printer_mode: str | None = None
-133
View File
@@ -1,133 +0,0 @@
"""Anonymous telemetry service for BamBuddy."""
import asyncio
import logging
import uuid
from datetime import datetime, timedelta
import httpx
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import APP_VERSION
from backend.app.models.printer import Printer
from backend.app.models.settings import Settings
logger = logging.getLogger(__name__)
# Default telemetry server URL (can be overridden via settings)
DEFAULT_TELEMETRY_URL = "https://telemetry.bambuddy.cool"
# How often to send heartbeats (once per day)
HEARTBEAT_INTERVAL = timedelta(hours=24)
_last_heartbeat: datetime | None = None
async def get_or_create_installation_id(db: AsyncSession) -> str:
"""Get existing installation ID or create a new one."""
result = await db.execute(select(Settings).where(Settings.key == "installation_id"))
setting = result.scalar_one_or_none()
if setting:
return setting.value
# Generate new UUID
installation_id = str(uuid.uuid4())
# Save to database
new_setting = Settings(key="installation_id", value=installation_id)
db.add(new_setting)
await db.commit()
logger.info(f"Generated new installation ID: {installation_id[:8]}...")
return installation_id
async def is_telemetry_enabled(db: AsyncSession) -> bool:
"""Check if telemetry is enabled (opt-out model)."""
result = await db.execute(select(Settings).where(Settings.key == "telemetry_enabled"))
setting = result.scalar_one_or_none()
# Default to enabled (opt-out model)
if not setting:
return True
return setting.value.lower() == "true"
async def get_telemetry_url(db: AsyncSession) -> str:
"""Get telemetry server URL from settings."""
result = await db.execute(select(Settings).where(Settings.key == "telemetry_url"))
setting = result.scalar_one_or_none()
return setting.value if setting else DEFAULT_TELEMETRY_URL
async def get_printer_model_counts(db: AsyncSession) -> dict[str, int]:
"""Get count of each printer model configured in BamBuddy."""
result = await db.execute(select(Printer.model, func.count(Printer.id)).group_by(Printer.model))
counts = {}
for model, count in result.all():
# Normalize model name (handle None/empty)
model_name = model if model else "Unknown"
counts[model_name] = count
return counts
async def send_heartbeat(db: AsyncSession) -> bool:
"""Send anonymous heartbeat to telemetry server."""
global _last_heartbeat
try:
# Check if telemetry is enabled
if not await is_telemetry_enabled(db):
logger.debug("Telemetry disabled, skipping heartbeat")
return False
# Rate limit: only send once per day
if _last_heartbeat and datetime.now() - _last_heartbeat < HEARTBEAT_INTERVAL:
logger.debug("Heartbeat already sent recently, skipping")
return True
installation_id = await get_or_create_installation_id(db)
telemetry_url = await get_telemetry_url(db)
printer_models = await get_printer_model_counts(db)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{telemetry_url}/heartbeat",
json={
"installation_id": installation_id,
"version": APP_VERSION,
"printer_models": printer_models,
},
)
response.raise_for_status()
_last_heartbeat = datetime.now()
logger.info(f"Telemetry heartbeat sent to {telemetry_url}")
return True
except httpx.HTTPError as e:
logger.debug(f"Telemetry heartbeat failed (network): {e}")
return False
except Exception as e:
logger.debug(f"Telemetry heartbeat failed: {e}")
return False
async def start_telemetry_loop(get_session):
"""Background task to send periodic heartbeats."""
# Wait a bit before first heartbeat to let app initialize
await asyncio.sleep(30)
while True:
try:
async with get_session() as db:
await send_heartbeat(db)
except Exception as e:
logger.debug(f"Telemetry loop error: {e}")
# Check daily
await asyncio.sleep(HEARTBEAT_INTERVAL.total_seconds())
@@ -1,229 +0,0 @@
"""Unit tests for Telemetry service.
Tests the anonymous telemetry/stats collection functionality.
"""
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.models.settings import Settings
from backend.app.services.telemetry import (
DEFAULT_TELEMETRY_URL,
HEARTBEAT_INTERVAL,
_last_heartbeat,
get_or_create_installation_id,
get_telemetry_url,
is_telemetry_enabled,
send_heartbeat,
)
class TestTelemetryService:
"""Tests for telemetry service functions."""
# ========================================================================
# Installation ID Tests
# ========================================================================
@pytest.mark.asyncio
async def test_get_or_create_installation_id_creates_new(self, db_session):
"""Verify new installation ID is created when none exists."""
installation_id = await get_or_create_installation_id(db_session)
assert installation_id is not None
assert len(installation_id) == 36 # UUID format
assert "-" in installation_id
@pytest.mark.asyncio
async def test_get_or_create_installation_id_returns_existing(self, db_session):
"""Verify existing installation ID is returned."""
# Create an existing installation ID
existing_id = "test-uuid-1234-5678-abcd"
setting = Settings(key="installation_id", value=existing_id)
db_session.add(setting)
await db_session.commit()
result = await get_or_create_installation_id(db_session)
assert result == existing_id
@pytest.mark.asyncio
async def test_get_or_create_installation_id_persists(self, db_session):
"""Verify created installation ID persists in database."""
first_id = await get_or_create_installation_id(db_session)
second_id = await get_or_create_installation_id(db_session)
assert first_id == second_id
# ========================================================================
# Telemetry Enabled Tests
# ========================================================================
@pytest.mark.asyncio
async def test_is_telemetry_enabled_default_true(self, db_session):
"""Verify telemetry is enabled by default (opt-out model)."""
result = await is_telemetry_enabled(db_session)
assert result is True
@pytest.mark.asyncio
async def test_is_telemetry_enabled_explicit_true(self, db_session):
"""Verify telemetry enabled when explicitly set to true."""
setting = Settings(key="telemetry_enabled", value="true")
db_session.add(setting)
await db_session.commit()
result = await is_telemetry_enabled(db_session)
assert result is True
@pytest.mark.asyncio
async def test_is_telemetry_enabled_explicit_false(self, db_session):
"""Verify telemetry disabled when set to false."""
setting = Settings(key="telemetry_enabled", value="false")
db_session.add(setting)
await db_session.commit()
result = await is_telemetry_enabled(db_session)
assert result is False
@pytest.mark.asyncio
async def test_is_telemetry_enabled_case_insensitive(self, db_session):
"""Verify telemetry enabled check is case insensitive."""
setting = Settings(key="telemetry_enabled", value="TRUE")
db_session.add(setting)
await db_session.commit()
result = await is_telemetry_enabled(db_session)
assert result is True
# ========================================================================
# Telemetry URL Tests
# ========================================================================
@pytest.mark.asyncio
async def test_get_telemetry_url_default(self, db_session):
"""Verify default telemetry URL is returned when not configured."""
result = await get_telemetry_url(db_session)
assert result == DEFAULT_TELEMETRY_URL
@pytest.mark.asyncio
async def test_get_telemetry_url_custom(self, db_session):
"""Verify custom telemetry URL is returned when configured."""
custom_url = "https://custom.telemetry.example.com"
setting = Settings(key="telemetry_url", value=custom_url)
db_session.add(setting)
await db_session.commit()
result = await get_telemetry_url(db_session)
assert result == custom_url
# ========================================================================
# Send Heartbeat Tests
# ========================================================================
@pytest.mark.asyncio
async def test_send_heartbeat_when_disabled(self, db_session):
"""Verify heartbeat is not sent when telemetry is disabled."""
setting = Settings(key="telemetry_enabled", value="false")
db_session.add(setting)
await db_session.commit()
with patch("httpx.AsyncClient") as mock_client:
result = await send_heartbeat(db_session)
assert result is False
mock_client.assert_not_called()
@pytest.mark.asyncio
async def test_send_heartbeat_success(self, db_session, mock_httpx_client):
"""Verify heartbeat is sent successfully when enabled."""
# Reset the last heartbeat to allow sending
import backend.app.services.telemetry as telemetry_module
telemetry_module._last_heartbeat = None
result = await send_heartbeat(db_session)
assert result is True
@pytest.mark.asyncio
async def test_send_heartbeat_rate_limited(self, db_session):
"""Verify heartbeat is rate limited to once per day."""
import backend.app.services.telemetry as telemetry_module
# Set last heartbeat to recent time
telemetry_module._last_heartbeat = datetime.now()
with patch("httpx.AsyncClient") as mock_client:
result = await send_heartbeat(db_session)
# Should return True (already sent) without making HTTP request
assert result is True
mock_client.assert_not_called()
@pytest.mark.asyncio
async def test_send_heartbeat_handles_exceptions(self, db_session):
"""Verify heartbeat returns False on general exceptions."""
import backend.app.services.telemetry as telemetry_module
telemetry_module._last_heartbeat = None
# Test that the function handles exceptions gracefully by checking
# the code path - the actual telemetry URL may or may not be reachable
# The function should not raise exceptions to the caller
try:
result = await send_heartbeat(db_session)
# Result can be True (success) or False (failure) but should not raise
assert isinstance(result, bool)
except Exception as e:
pytest.fail(f"send_heartbeat should not raise exceptions: {e}")
@pytest.mark.asyncio
async def test_send_heartbeat_sends_correct_data(self, db_session):
"""Verify heartbeat sends correct payload."""
import backend.app.services.telemetry as telemetry_module
from backend.app.core.config import APP_VERSION
telemetry_module._last_heartbeat = None
captured_data = {}
with patch("httpx.AsyncClient") as mock_class:
mock_instance = AsyncMock()
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
async def capture_post(url, json=None):
captured_data["url"] = url
captured_data["json"] = json
return mock_response
mock_instance.post = capture_post
mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
mock_instance.__aexit__ = AsyncMock()
mock_class.return_value = mock_instance
await send_heartbeat(db_session)
assert "heartbeat" in captured_data["url"]
assert "installation_id" in captured_data["json"]
assert captured_data["json"]["version"] == APP_VERSION
class TestHeartbeatInterval:
"""Tests for heartbeat interval configuration."""
def test_heartbeat_interval_is_24_hours(self):
"""Verify heartbeat interval is set to 24 hours."""
assert timedelta(hours=24) == HEARTBEAT_INTERVAL
def test_default_telemetry_url(self):
"""Verify default telemetry URL is correct."""
assert DEFAULT_TELEMETRY_URL == "https://telemetry.bambuddy.cool"