velxio/backend/app/services/metrics.py

192 lines
5.4 KiB
Python

"""Usage metrics collector.
All write paths land here. Each function writes one UsageEvent row and
updates the corresponding aggregate counters on User / Project in the
same transaction so dashboards can read O(1) without aggregation.
Events are best-effort: failures must never break the user-facing
endpoint, so callers should wrap calls in `try/except` and log.
"""
import json
import logging
from datetime import datetime, timezone
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.project import Project
from app.models.usage_event import UsageEvent
from app.models.user import User
from app.utils.boards import board_family_from_fqbn
from app.utils.geo import country_from_request
logger = logging.getLogger(__name__)
def _now() -> datetime:
return datetime.now(timezone.utc)
async def _project_for_update(db: AsyncSession, project_id: str | None) -> Project | None:
if not project_id:
return None
result = await db.execute(select(Project).where(Project.id == project_id))
return result.scalar_one_or_none()
def _touch_user(user: User | None, country: str | None) -> None:
"""Update last_active_at and last_country on the user (in-place)."""
if user is None:
return
user.last_active_at = _now()
if country:
user.last_country = country
async def record_compile(
db: AsyncSession,
*,
user: User | None,
project_id: str | None,
board_fqbn: str | None,
success: bool,
duration_ms: int | None = None,
error_kind: str | None = None,
extra: dict | None = None,
request: Request | None = None,
) -> None:
"""Record a compile attempt (success or failure)."""
try:
country = country_from_request(request)
family = board_family_from_fqbn(board_fqbn)
event = UsageEvent(
user_id=user.id if user else None,
project_id=project_id,
event_type="compile" if success else "compile_error",
board_fqbn=board_fqbn,
board_family=family,
duration_ms=duration_ms,
error_kind=error_kind,
country=country,
metadata_json=json.dumps(extra) if extra else None,
)
db.add(event)
if user is not None:
user.total_compiles += 1
if not success:
user.total_compile_errors += 1
_touch_user(user, country)
project = await _project_for_update(db, project_id)
if project is not None:
project.compile_count += 1
if not success:
project.compile_error_count += 1
project.last_compiled_at = _now()
await db.commit()
except Exception:
logger.exception("record_compile failed (swallowed)")
await db.rollback()
async def record_run(
db: AsyncSession,
*,
user: User | None,
project_id: str | None,
board_fqbn: str | None,
extra: dict | None = None,
request: Request | None = None,
) -> None:
"""Record a simulation run (Run button press)."""
try:
country = country_from_request(request)
family = board_family_from_fqbn(board_fqbn)
event = UsageEvent(
user_id=user.id if user else None,
project_id=project_id,
event_type="run",
board_fqbn=board_fqbn,
board_family=family,
country=country,
metadata_json=json.dumps(extra) if extra else None,
)
db.add(event)
if user is not None:
user.total_runs += 1
_touch_user(user, country)
project = await _project_for_update(db, project_id)
if project is not None:
project.run_count += 1
project.last_run_at = _now()
await db.commit()
except Exception:
logger.exception("record_run failed (swallowed)")
await db.rollback()
async def record_save(
db: AsyncSession,
*,
user: User | None,
project: Project,
is_create: bool = False,
request: Request | None = None,
) -> None:
"""Record a project create or update.
`project` must already be persisted (committed). For updates we bump
`update_count` here. For creates we just log the event.
"""
try:
country = country_from_request(request)
event = UsageEvent(
user_id=user.id if user else None,
project_id=project.id,
event_type="create" if is_create else "save",
board_fqbn=None,
board_family=None,
country=country,
)
db.add(event)
_touch_user(user, country)
if not is_create:
project.update_count += 1
await db.commit()
except Exception:
logger.exception("record_save failed (swallowed)")
await db.rollback()
async def record_project_open(
db: AsyncSession,
*,
user: User | None,
project_id: str,
request: Request | None = None,
) -> None:
"""Record a project view (different from owner edit)."""
try:
country = country_from_request(request)
event = UsageEvent(
user_id=user.id if user else None,
project_id=project_id,
event_type="project_open",
country=country,
)
db.add(event)
_touch_user(user, country)
await db.commit()
except Exception:
logger.exception("record_project_open failed (swallowed)")
await db.rollback()