telemetry.py 3.7 KB

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