| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- """Anonymous telemetry service for BamBuddy."""
- import asyncio
- import logging
- import uuid
- from datetime import datetime, timedelta
- import httpx
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.config import APP_VERSION
- 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 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)
- 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,
- },
- )
- 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())
|