telemetry.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. """Anonymous telemetry service for BamBuddy."""
  2. import asyncio
  3. import logging
  4. import uuid
  5. from datetime import datetime, timedelta
  6. import httpx
  7. from sqlalchemy import func, select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.core.config import APP_VERSION
  10. from backend.app.models.printer import Printer
  11. from backend.app.models.settings import Settings
  12. logger = logging.getLogger(__name__)
  13. # Default telemetry server URL (can be overridden via settings)
  14. DEFAULT_TELEMETRY_URL = "https://telemetry.bambuddy.cool"
  15. # How often to send heartbeats (once per day)
  16. HEARTBEAT_INTERVAL = timedelta(hours=24)
  17. _last_heartbeat: datetime | None = None
  18. async def get_or_create_installation_id(db: AsyncSession) -> str:
  19. """Get existing installation ID or create a new one."""
  20. result = await db.execute(select(Settings).where(Settings.key == "installation_id"))
  21. setting = result.scalar_one_or_none()
  22. if setting:
  23. return setting.value
  24. # Generate new UUID
  25. installation_id = str(uuid.uuid4())
  26. # Save to database
  27. new_setting = Settings(key="installation_id", value=installation_id)
  28. db.add(new_setting)
  29. await db.commit()
  30. logger.info(f"Generated new installation ID: {installation_id[:8]}...")
  31. return installation_id
  32. async def is_telemetry_enabled(db: AsyncSession) -> bool:
  33. """Check if telemetry is enabled (opt-out model)."""
  34. result = await db.execute(select(Settings).where(Settings.key == "telemetry_enabled"))
  35. setting = result.scalar_one_or_none()
  36. # Default to enabled (opt-out model)
  37. if not setting:
  38. return True
  39. return setting.value.lower() == "true"
  40. async def get_telemetry_url(db: AsyncSession) -> str:
  41. """Get telemetry server URL from settings."""
  42. result = await db.execute(select(Settings).where(Settings.key == "telemetry_url"))
  43. setting = result.scalar_one_or_none()
  44. return setting.value if setting else DEFAULT_TELEMETRY_URL
  45. async def get_printer_model_counts(db: AsyncSession) -> dict[str, int]:
  46. """Get count of each printer model configured in BamBuddy."""
  47. result = await db.execute(select(Printer.model, func.count(Printer.id)).group_by(Printer.model))
  48. counts = {}
  49. for model, count in result.all():
  50. # Normalize model name (handle None/empty)
  51. model_name = model if model else "Unknown"
  52. counts[model_name] = count
  53. return counts
  54. async def send_heartbeat(db: AsyncSession) -> bool:
  55. """Send anonymous heartbeat to telemetry server."""
  56. global _last_heartbeat
  57. try:
  58. # Check if telemetry is enabled
  59. if not await is_telemetry_enabled(db):
  60. logger.debug("Telemetry disabled, skipping heartbeat")
  61. return False
  62. # Rate limit: only send once per day
  63. if _last_heartbeat and datetime.now() - _last_heartbeat < HEARTBEAT_INTERVAL:
  64. logger.debug("Heartbeat already sent recently, skipping")
  65. return True
  66. installation_id = await get_or_create_installation_id(db)
  67. telemetry_url = await get_telemetry_url(db)
  68. printer_models = await get_printer_model_counts(db)
  69. async with httpx.AsyncClient(timeout=10.0) as client:
  70. response = await client.post(
  71. f"{telemetry_url}/heartbeat",
  72. json={
  73. "installation_id": installation_id,
  74. "version": APP_VERSION,
  75. "printer_models": printer_models,
  76. },
  77. )
  78. response.raise_for_status()
  79. _last_heartbeat = datetime.now()
  80. logger.info(f"Telemetry heartbeat sent to {telemetry_url}")
  81. return True
  82. except httpx.HTTPError as e:
  83. logger.debug(f"Telemetry heartbeat failed (network): {e}")
  84. return False
  85. except Exception as e:
  86. logger.debug(f"Telemetry heartbeat failed: {e}")
  87. return False
  88. async def start_telemetry_loop(get_session):
  89. """Background task to send periodic heartbeats."""
  90. # Wait a bit before first heartbeat to let app initialize
  91. await asyncio.sleep(30)
  92. while True:
  93. try:
  94. async with get_session() as db:
  95. await send_heartbeat(db)
  96. except Exception as e:
  97. logger.debug(f"Telemetry loop error: {e}")
  98. # Check daily
  99. await asyncio.sleep(HEARTBEAT_INTERVAL.total_seconds())