telemetry.py 3.7 KB

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