| import logging |
| from datetime import datetime |
|
|
| from sqlalchemy import func, select |
| from sqlalchemy.orm import Session |
|
|
| from src.services.db import UsageEvent, get_session, new_id |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class UsageTrackingService: |
| """Usage event recording and querying. |
| |
| CRITICAL DESIGN RULE: |
| - record_event_safe() opens its OWN short-lived DB session. |
| It never commits, rollbacks, or closes the caller's request-scoped session. |
| If the insert fails, it logs the error and returns silently — the main |
| user request is never broken by a tracking failure. |
| - Counting/query methods (count_user_events, count_user_events_grouped) |
| use the injected request session (self.db) for read-only queries. This is |
| safe because they do not modify state. |
| """ |
|
|
| def __init__(self, db: Session): |
| self.db = db |
|
|
| @staticmethod |
| def record_event_safe( |
| *, |
| event_type: str, |
| user_id: str | None = None, |
| organization_id: str | None = None, |
| guest_session_id: str | None = None, |
| source: str | None = None, |
| metadata: dict | None = None, |
| status: str = "success", |
| ) -> None: |
| """Insert a usage event in an INDEPENDENT session. |
| |
| Opens a new short-lived Session, inserts, commits, closes. |
| On any failure: logs the error, does NOT raise, does NOT touch |
| the caller's transaction. |
| """ |
| try: |
| gen = get_session() |
| session = next(gen) |
| try: |
| event = UsageEvent( |
| id=new_id("evt"), |
| user_id=user_id, |
| organization_id=organization_id, |
| guest_session_id=guest_session_id, |
| event_type=event_type, |
| status=status, |
| source=source, |
| metadata_json=metadata, |
| ) |
| session.add(event) |
| session.commit() |
| except Exception: |
| session.rollback() |
| raise |
| finally: |
| session.close() |
| except Exception: |
| logger.exception("Usage tracking failed (event_type=%s)", event_type) |
|
|
| def count_user_events( |
| self, |
| user_id: str, |
| event_type: str, |
| *, |
| start_at: datetime | None = None, |
| end_before: datetime | None = None, |
| ) -> int: |
| """Count successful events for a single user and type.""" |
| stmt = select(func.count(UsageEvent.id)).where( |
| UsageEvent.user_id == user_id, |
| UsageEvent.event_type == event_type, |
| UsageEvent.status == "success", |
| ) |
| if start_at is not None: |
| stmt = stmt.where(UsageEvent.created_at >= start_at) |
| if end_before is not None: |
| stmt = stmt.where(UsageEvent.created_at < end_before) |
| return int(self.db.scalar(stmt) or 0) |
|
|
| def count_user_events_grouped( |
| self, |
| user_id: str, |
| event_types: list[str], |
| *, |
| start_at: datetime | None = None, |
| end_before: datetime | None = None, |
| ) -> dict[str, int]: |
| """Count events grouped by event_type for a single user. |
| Only counts status == 'success'. |
| """ |
| stmt = ( |
| select(UsageEvent.event_type, func.count(UsageEvent.id)) |
| .where( |
| UsageEvent.user_id == user_id, |
| UsageEvent.event_type.in_(event_types), |
| UsageEvent.status == "success", |
| ) |
| .group_by(UsageEvent.event_type) |
| ) |
| if start_at is not None: |
| stmt = stmt.where(UsageEvent.created_at >= start_at) |
| if end_before is not None: |
| stmt = stmt.where(UsageEvent.created_at < end_before) |
| rows = self.db.execute(stmt).all() |
| result = {et: 0 for et in event_types} |
| for event_type_val, count in rows: |
| result[event_type_val] = int(count) |
| return result |
|
|