"""Сервис записи в `user_events` — Feature 2 (login/IP audit) + база для Feature 3 (behavior analytics). `user_events` (migration `184_user_events.sql`) — unified append-only event log, admin-read-only. Запись события НИКОГДА не должна ронять реальный HTTP-запрос: любая ошибка (БД недоступна, сетевой сбой, схема разъехалась) ловится и логируется через `logger.warning`, без re-raise — вызывающий код (middleware / handler) продолжает работать так, как если бы аудит был выключен. """ from __future__ import annotations import asyncio import json import logging import threading from datetime import UTC, datetime from typing import Any from sqlalchemy import text from app.core.db import SessionLocal logger = logging.getLogger(__name__) def record_event( *, event_type: str, username: str, ip: str | None = None, user_agent: str | None = None, path: str | None = None, method: str | None = None, estimate_id: str | None = None, payload: dict[str, Any] | None = None, ) -> None: """Синхронно вставляет одну строку в `user_events`. Открывает СОБСТВЕННУЮ сессию (`SessionLocal()`), декаплённую от транзакции вызывающего запроса — событие коммитится независимо и переживает rollback основного хендлера (и наоборот: сбой записи события не трогает основную tx, т.к. она уже закоммичена/не связана с этой сессией). Никогда не поднимает исключение — вызывающий код (middleware / endpoint) не должен падать из-за проблем с аудит-логом. """ try: with SessionLocal() as db: db.execute( text( """ INSERT INTO user_events (event_type, username, ip_address, user_agent, path, method, estimate_id, payload) VALUES (:event_type, :username, CAST(:ip AS inet), :user_agent, :path, :method, CAST(:estimate_id AS uuid), CAST(:payload AS jsonb)) """ ), { "event_type": event_type, "username": username, "ip": ip, "user_agent": user_agent, "path": path, "method": method, "estimate_id": estimate_id, "payload": json.dumps(payload or {}, ensure_ascii=False), }, ) db.commit() except Exception: logger.warning( "user_events: failed to record event_type=%r username=%r path=%r", event_type, username, path, exc_info=True, ) def schedule_event(**kwargs: Any) -> None: """Fire-and-forget обёртка над `record_event` — никогда не блокирует запрос и никогда не поднимает исключение наружу. - Есть running event loop (обычный async FastAPI handler / middleware) → INSERT уводится в отдельный поток через `asyncio.to_thread`, вызывающая корутина не ждёт результата. Ошибки ловятся done-callback'ом и просто логируются (иначе asyncio ругался бы "Task exception was never retrieved"). - Нет running loop (sync-контекст: тесты, Celery task, скрипт) → вызывает `record_event(**kwargs)` синхронно inline (она сама никогда не raises). """ try: loop = asyncio.get_running_loop() except RuntimeError: record_event(**kwargs) return try: task = loop.create_task(asyncio.to_thread(record_event, **kwargs)) except Exception: logger.warning("user_events: failed to schedule background event", exc_info=True) return def _log_task_exception(t: asyncio.Task[None]) -> None: if t.cancelled(): return exc = t.exception() if exc is not None: logger.warning("user_events: background record_event failed: %s", exc) task.add_done_callback(_log_task_exception) # --------------------------------------------------------------------------- # In-process best-effort dedup для login-события. # --------------------------------------------------------------------------- _LOGIN_DEDUP_LOCK = threading.Lock() _LOGIN_DEDUP_SEEN: set[str] = set() _LOGIN_DEDUP_MAX = 50_000 def should_log_login(username: str, ip: str | None, user_agent: str | None) -> bool: """True максимум один раз в сутки на комбинацию (username, ip, user_agent) в рамках жизни процесса — держит объём `event_type='login'` строк на уровне ~одной записи на аккаунт+устройство в день, а не одной на каждый API-запрос. Best-effort/не персистентно: рестарт процесса (деплой, worker respawn) сбрасывает in-memory set, так что после рестарта первая комбинация дня залогируется заново — недо-дедуп, не потеря данных (для отчётов по user_events всё равно используется `DISTINCT (username, ip_address, user_agent, date_trunc('day', created_at))`, так что дубликаты не искажают метрики, лишь чуть увеличивают объём записи). """ day = datetime.now(UTC).strftime("%Y-%m-%d") key = f"{username}|{ip or ''}|{user_agent or ''}|{day}" with _LOGIN_DEDUP_LOCK: if key in _LOGIN_DEDUP_SEEN: return False if len(_LOGIN_DEDUP_SEEN) >= _LOGIN_DEDUP_MAX: _LOGIN_DEDUP_SEEN.clear() _LOGIN_DEDUP_SEEN.add(key) return True