mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
Completely removed telemetry
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user