Клиент ClickHouse, каталог событий и батчинг записи
- common/analytics — AnalyticsClient с буфером, flush по размеру и таймеру, graceful-flush при остановке - при недоступности ClickHouse события уходят в stdout, бот не падает - каталог EventType и docs/events.md проверяются тестом на синхронность - настройки CLICKHOUSE_* и ANALYTICS_* с дефолтами - версия 0.3.0
This commit is contained in:
parent
aafb9c9e3b
commit
84f75e7c7d
14
.env.example
14
.env.example
|
|
@ -28,3 +28,17 @@ ADMIN_IDS=
|
||||||
|
|
||||||
# Часовой пояс для расписаний
|
# Часовой пояс для расписаний
|
||||||
TIMEZONE=Europe/Moscow
|
TIMEZONE=Europe/Moscow
|
||||||
|
|
||||||
|
# Аналитика (ClickHouse). Логируются все действия бота (см. docs/events.md).
|
||||||
|
# CLICKHOUSE_ENABLED=false — полностью выключить аналитику.
|
||||||
|
CLICKHOUSE_ENABLED=true
|
||||||
|
# Для локального запуска — localhost; в compose бот ходит по имени сервиса.
|
||||||
|
CLICKHOUSE_HOST=localhost
|
||||||
|
CLICKHOUSE_PORT=8123
|
||||||
|
CLICKHOUSE_DB=analytics
|
||||||
|
CLICKHOUSE_USER=default
|
||||||
|
CLICKHOUSE_PASSWORD=
|
||||||
|
# Батчинг записи событий: размер батча, интервал сброса (сек), предел буфера.
|
||||||
|
ANALYTICS_BATCH_SIZE=200
|
||||||
|
ANALYTICS_FLUSH_INTERVAL=5.0
|
||||||
|
ANALYTICS_BUFFER_MAX=5000
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ pytest
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
|
|
||||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.2.5`.
|
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.3.0`.
|
||||||
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Аналитика: клиент ClickHouse и каталог событий.
|
||||||
|
|
||||||
|
Публичный интерфейс:
|
||||||
|
|
||||||
|
- :func:`init_analytics` / :func:`close_analytics` — жизненный цикл (вызывает бот при старте/остановке);
|
||||||
|
- :func:`record` — записать событие из сервисного слоя (неблокирующе);
|
||||||
|
- :func:`remember_user` — запомнить ``username`` для обогащения событий;
|
||||||
|
- :class:`EventType` / :data:`ALL_EVENT_TYPES` — каталог типов событий.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from common.analytics.client import (
|
||||||
|
AnalyticsClient,
|
||||||
|
close_analytics,
|
||||||
|
get_analytics,
|
||||||
|
init_analytics,
|
||||||
|
record,
|
||||||
|
remember_user,
|
||||||
|
)
|
||||||
|
from common.analytics.events import ALL_EVENT_TYPES, EVENT_COLUMNS, EventType
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ALL_EVENT_TYPES",
|
||||||
|
"EVENT_COLUMNS",
|
||||||
|
"AnalyticsClient",
|
||||||
|
"EventType",
|
||||||
|
"close_analytics",
|
||||||
|
"get_analytics",
|
||||||
|
"init_analytics",
|
||||||
|
"record",
|
||||||
|
"remember_user",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
"""Клиент ClickHouse: батч-запись событий без блокировки бота.
|
||||||
|
|
||||||
|
Свойства:
|
||||||
|
- запись неблокирующая: :meth:`AnalyticsClient.record` только кладёт строку в буфер;
|
||||||
|
- батчинг: отправка по размеру буфера и по таймеру;
|
||||||
|
- недоступность ClickHouse **не роняет бота**: события попадают в stdout (fallback);
|
||||||
|
- graceful-flush при остановке (:func:`close_analytics`).
|
||||||
|
|
||||||
|
Импорт ``clickhouse_connect`` ленивый (внутри :meth:`AnalyticsClient._ensure_client`),
|
||||||
|
поэтому пакет не является обязательным для импорта модуля (нужно для тестов/админки).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from collections import deque
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from common.analytics.events import EVENT_COLUMNS, EventType
|
||||||
|
from common.config import Settings, get_settings
|
||||||
|
from common.version import __version__
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Ограничение длины JSON-payload (защита от «толстых» событий).
|
||||||
|
PAYLOAD_MAX_LENGTH = 4000
|
||||||
|
|
||||||
|
#: Ограничение кэша ``id_user -> username``.
|
||||||
|
USERNAME_CACHE_MAX = 5000
|
||||||
|
|
||||||
|
#: Сколько строк события выводить в лог при fallback.
|
||||||
|
FALLBACK_SAMPLE = 5
|
||||||
|
|
||||||
|
|
||||||
|
class ClickHouseClient(Protocol):
|
||||||
|
"""Минимальный контракт клиента ClickHouse (используется и для подмены в тестах)."""
|
||||||
|
|
||||||
|
async def insert(self, table: str, data: list[list[Any]], column_names: list[str]) -> Any:
|
||||||
|
"""Вставить строки в таблицу."""
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Закрыть соединения."""
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsClient:
|
||||||
|
"""Батч-запись событий в ClickHouse с деградацией в лог."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings | None = None, *, client: ClickHouseClient | None = None) -> None:
|
||||||
|
self._settings = settings or get_settings()
|
||||||
|
self._client = client
|
||||||
|
self._rows: deque[list[Any]] = deque()
|
||||||
|
self._flush_now = asyncio.Event()
|
||||||
|
self._task: asyncio.Task[None] | None = None
|
||||||
|
self._usernames: dict[int, str] = {}
|
||||||
|
self._unavailable_logged = False
|
||||||
|
self._overflow = 0
|
||||||
|
self._table = f"{self._settings.clickhouse_db}.events"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
"""Включена ли аналитика (``CLICKHOUSE_ENABLED``)."""
|
||||||
|
return self._settings.clickhouse_enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending(self) -> int:
|
||||||
|
"""Сколько событий ждёт отправки (для тестов и диагностики)."""
|
||||||
|
return len(self._rows)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Подключиться к ClickHouse (best-effort) и запустить фоновый сброс буфера."""
|
||||||
|
if not self.enabled:
|
||||||
|
logger.info("Аналитика ClickHouse выключена (CLICKHOUSE_ENABLED=false)")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._ensure_client()
|
||||||
|
self._task = asyncio.create_task(self._run(), name="analytics-flush")
|
||||||
|
logger.info(
|
||||||
|
"Аналитика ClickHouse: %s:%s/%s (батч %s, интервал %sс)",
|
||||||
|
self._settings.clickhouse_host,
|
||||||
|
self._settings.clickhouse_port,
|
||||||
|
self._settings.clickhouse_db,
|
||||||
|
self._settings.analytics_batch_size,
|
||||||
|
self._settings.analytics_flush_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _ensure_client(self) -> bool:
|
||||||
|
"""Вернуть True, если клиент ClickHouse доступен."""
|
||||||
|
if self._client is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
settings = self._settings
|
||||||
|
try:
|
||||||
|
import clickhouse_connect
|
||||||
|
|
||||||
|
self._client = await clickhouse_connect.get_async_client(
|
||||||
|
host=settings.clickhouse_host,
|
||||||
|
port=settings.clickhouse_port,
|
||||||
|
username=settings.clickhouse_user,
|
||||||
|
password=settings.clickhouse_password,
|
||||||
|
database=settings.clickhouse_db,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if not self._unavailable_logged:
|
||||||
|
self._unavailable_logged = True
|
||||||
|
logger.warning(
|
||||||
|
"ClickHouse %s:%s недоступен: события пишутся в stdout (fallback)",
|
||||||
|
settings.clickhouse_host,
|
||||||
|
settings.clickhouse_port,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._unavailable_logged = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remember_user(self, user_id: int, username: str | None) -> None:
|
||||||
|
"""Запомнить связку ``id_user -> username`` для обогащения будущих событий."""
|
||||||
|
if not user_id or not username:
|
||||||
|
return
|
||||||
|
if user_id not in self._usernames and len(self._usernames) >= USERNAME_CACHE_MAX:
|
||||||
|
self._usernames.pop(next(iter(self._usernames)))
|
||||||
|
self._usernames[user_id] = username
|
||||||
|
|
||||||
|
def record(
|
||||||
|
self,
|
||||||
|
event_type: EventType | str,
|
||||||
|
*,
|
||||||
|
user_id: int = 0,
|
||||||
|
username: str | None = None,
|
||||||
|
chat_id: int = 0,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Положить событие в буфер (никогда не бросает исключений)."""
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
if username is None:
|
||||||
|
username = self._usernames.get(user_id, "")
|
||||||
|
|
||||||
|
self._rows.append(
|
||||||
|
[
|
||||||
|
datetime.now(UTC).replace(tzinfo=None),
|
||||||
|
str(event_type),
|
||||||
|
int(user_id),
|
||||||
|
username or "",
|
||||||
|
int(chat_id),
|
||||||
|
self._dump_payload(payload),
|
||||||
|
__version__,
|
||||||
|
1 if self._settings.is_prod else 0,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
while len(self._rows) > self._settings.analytics_buffer_max:
|
||||||
|
self._rows.popleft()
|
||||||
|
self._overflow += 1
|
||||||
|
|
||||||
|
if len(self._rows) >= self._settings.analytics_batch_size:
|
||||||
|
self._flush_now.set()
|
||||||
|
|
||||||
|
async def _run(self) -> None:
|
||||||
|
"""Периодически (или по достижении батча) сбрасывать буфер в ClickHouse."""
|
||||||
|
interval = self._settings.analytics_flush_interval
|
||||||
|
while True:
|
||||||
|
with contextlib.suppress(TimeoutError):
|
||||||
|
await asyncio.wait_for(self._flush_now.wait(), timeout=interval)
|
||||||
|
self._flush_now.clear()
|
||||||
|
await self.flush()
|
||||||
|
|
||||||
|
async def flush(self) -> None:
|
||||||
|
"""Отправить накопленные события. При сбое — fallback в лог (без исключений)."""
|
||||||
|
overflow, self._overflow = self._overflow, 0
|
||||||
|
if overflow:
|
||||||
|
logger.warning("Буфер событий переполнен, потеряно %s событий (не записаны в ClickHouse)", overflow)
|
||||||
|
|
||||||
|
if not self._rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.enabled:
|
||||||
|
self._rows.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = list(self._rows)
|
||||||
|
self._rows.clear()
|
||||||
|
|
||||||
|
if not await self._ensure_client():
|
||||||
|
self._log_fallback(rows, "ClickHouse недоступен")
|
||||||
|
return
|
||||||
|
|
||||||
|
client = self._client
|
||||||
|
if client is None:
|
||||||
|
self._log_fallback(rows, "клиент ClickHouse не создан")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.insert(self._table, rows, list(EVENT_COLUMNS))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Не удалось записать %s событий в ClickHouse", len(rows))
|
||||||
|
self._log_fallback(rows, "ошибка записи в ClickHouse")
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Остановить фоновый таск, сбросить остаток и закрыть клиент."""
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._task
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
if self._client is not None:
|
||||||
|
await self.flush()
|
||||||
|
client, self._client = self._client, None
|
||||||
|
try:
|
||||||
|
await client.close()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Не удалось закрыть клиент ClickHouse", exc_info=True)
|
||||||
|
elif self._rows:
|
||||||
|
# Клиента нет (ClickHouse недоступен) — не пробуем подключиться на выходе.
|
||||||
|
rows = list(self._rows)
|
||||||
|
self._rows.clear()
|
||||||
|
self._log_fallback(rows, "остановка бота")
|
||||||
|
|
||||||
|
def _log_fallback(self, rows: list[list[Any]], reason: str) -> None:
|
||||||
|
"""Вывести потерянные события в лог, чтобы данные не пропадали молча."""
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
lines = " | ".join(
|
||||||
|
f"{row[0]:%Y-%m-%d %H:%M:%S} {row[1]} user={row[2]} chat={row[4]} {row[5]}"
|
||||||
|
for row in rows[:FALLBACK_SAMPLE]
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Fallback (%s): %s событий не записаны в ClickHouse: %s%s",
|
||||||
|
reason,
|
||||||
|
len(rows),
|
||||||
|
lines,
|
||||||
|
" ..." if len(rows) > FALLBACK_SAMPLE else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dump_payload(payload: dict[str, Any] | None) -> str:
|
||||||
|
"""Сериализовать payload в JSON-строку с обрезкой по длине."""
|
||||||
|
if not payload:
|
||||||
|
return "{}"
|
||||||
|
try:
|
||||||
|
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
text = json.dumps({"_error": "payload не сериализуется"})
|
||||||
|
return text[:PAYLOAD_MAX_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
#: Активный клиент (если аналитика не инициализирована — None).
|
||||||
|
_client: AnalyticsClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def init_analytics(
|
||||||
|
settings: Settings | None = None,
|
||||||
|
*,
|
||||||
|
client: ClickHouseClient | None = None,
|
||||||
|
) -> AnalyticsClient:
|
||||||
|
"""Создать и запустить единственный клиент аналитики. Идемпотентно."""
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
return _client
|
||||||
|
_client = AnalyticsClient(settings, client=client)
|
||||||
|
await _client.start()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_analytics() -> None:
|
||||||
|
"""Остановить клиент аналитики и сбросить буфер при остановке приложения."""
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
await _client.close()
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics() -> AnalyticsClient | None:
|
||||||
|
"""Вернуть активный клиент аналитики (или None)."""
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def record(
|
||||||
|
event_type: EventType | str,
|
||||||
|
*,
|
||||||
|
user_id: int = 0,
|
||||||
|
username: str | None = None,
|
||||||
|
chat_id: int = 0,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Записать событие. Если аналитика не инициализирована — no-op."""
|
||||||
|
if _client is None:
|
||||||
|
return
|
||||||
|
_client.record(event_type, user_id=user_id, username=username, chat_id=chat_id, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def remember_user(user_id: int, username: str | None) -> None:
|
||||||
|
"""Запомнить username пользователя (для обогащения событий из сервисов)."""
|
||||||
|
if _client is not None:
|
||||||
|
_client.remember_user(user_id, username)
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Каталог событий аналитики.
|
||||||
|
|
||||||
|
Единственный источник правды по типам событий ClickHouse. Список синхронизирован
|
||||||
|
с документацией (``docs/events.md``) — рассинхрон ловит ``tests/test_analytics.py``.
|
||||||
|
|
||||||
|
Правило: добавляешь новое событие — добавь его в ``EventType`` и в ``docs/events.md``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(StrEnum):
|
||||||
|
"""Типы событий (значение = колонка ``event_type`` в ClickHouse)."""
|
||||||
|
|
||||||
|
# --- Сообщения и команды ---
|
||||||
|
MESSAGE = "message"
|
||||||
|
COMMAND = "command"
|
||||||
|
|
||||||
|
# --- Экономика ---
|
||||||
|
PURIFICATION = "purification"
|
||||||
|
EXP_ACCRUAL = "exp_accrual"
|
||||||
|
LEVEL_UP = "level_up"
|
||||||
|
MONEY_ACCRUAL = "money_accrual"
|
||||||
|
MONEY_SPEND = "money_spend"
|
||||||
|
EGGS = "eggs"
|
||||||
|
PURCHASE_TITLE = "purchase_title"
|
||||||
|
PURCHASE_DONAT = "purchase_donat"
|
||||||
|
|
||||||
|
# --- Участники ---
|
||||||
|
JOIN = "join"
|
||||||
|
LEAVE = "leave"
|
||||||
|
NICK_SET = "nick_set"
|
||||||
|
|
||||||
|
# --- Подписки и доступ ---
|
||||||
|
SUBSCRIBE = "subscribe"
|
||||||
|
UNSUBSCRIBE = "unsubscribe"
|
||||||
|
ACCESS_DENIED = "access_denied"
|
||||||
|
|
||||||
|
# --- Жизненный цикл бота и ошибки ---
|
||||||
|
BOT_START = "bot_start"
|
||||||
|
BOT_STOP = "bot_stop"
|
||||||
|
BOT_ALIVE = "bot_alive"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
#: Полный список типов событий (для проверки синхронности с документацией).
|
||||||
|
ALL_EVENT_TYPES: frozenset[str] = frozenset(member.value for member in EventType)
|
||||||
|
|
||||||
|
#: Колонки таблицы ``analytics.events`` в порядке вставки.
|
||||||
|
EVENT_COLUMNS: tuple[str, ...] = (
|
||||||
|
"event_time",
|
||||||
|
"event_type",
|
||||||
|
"user_id",
|
||||||
|
"username",
|
||||||
|
"chat_id",
|
||||||
|
"payload",
|
||||||
|
"bot_version",
|
||||||
|
"prod",
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["ALL_EVENT_TYPES", "EVENT_COLUMNS", "EventType"]
|
||||||
|
|
@ -53,6 +53,19 @@ class Settings(BaseSettings):
|
||||||
#: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически).
|
#: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически).
|
||||||
heartbeat_path: str = "/tmp/dispelevilbot.heartbeat"
|
heartbeat_path: str = "/tmp/dispelevilbot.heartbeat"
|
||||||
|
|
||||||
|
#: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить.
|
||||||
|
clickhouse_enabled: bool = True
|
||||||
|
clickhouse_host: str = "localhost"
|
||||||
|
clickhouse_port: int = 8123
|
||||||
|
clickhouse_db: str = "analytics"
|
||||||
|
clickhouse_user: str = "default"
|
||||||
|
clickhouse_password: str = ""
|
||||||
|
|
||||||
|
#: Батчинг записи событий: размер батча, интервал сброса и предел буфера.
|
||||||
|
analytics_batch_size: int = 200
|
||||||
|
analytics_flush_interval: float = 5.0
|
||||||
|
analytics_buffer_max: int = 5000
|
||||||
|
|
||||||
@field_validator("chat_id", "alert_chat_id", mode="before")
|
@field_validator("chat_id", "alert_chat_id", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _empty_int_to_zero(cls, value: object) -> object:
|
def _empty_int_to_zero(cls, value: object) -> object:
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.2.5"
|
__version__ = "0.3.0"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
# События аналитики (ClickHouse)
|
||||||
|
|
||||||
|
Бот пишет все значимые действия в ClickHouse — единую широкую таблицу
|
||||||
|
`analytics.events`. Дашборд (Superset) и ETL — отдельный финальный этап; здесь
|
||||||
|
описан **контракт** событий: какие бывают `event_type` и что лежит в `payload`.
|
||||||
|
|
||||||
|
> Источник правды в коде — `common/analytics/events.py` (`EventType`).
|
||||||
|
> Расхождение со списком ниже ловит `tests/test_analytics.py`.
|
||||||
|
> Добавляешь событие — добавь строку в таблицу.
|
||||||
|
|
||||||
|
## Поля события
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **event_time** | `DateTime('UTC')` | Момент события (UTC, без таймзоны) |
|
||||||
|
| **event_type** | `LowCardinality(String)` | Тип события (см. каталог ниже) |
|
||||||
|
| **user_id** | `Int64` | Telegram-ID пользователя (0 — системное событие) |
|
||||||
|
| **username** | `String` | `@username` на момент события (может быть пустым) |
|
||||||
|
| **chat_id** | `Int64` | ID чата (обычно основной чат клана) |
|
||||||
|
| **payload** | `String` | Дополнительные данные события в виде JSON |
|
||||||
|
| **bot_version** | `String` | Версия бота из `common/version.py` |
|
||||||
|
| **prod** | `UInt8` | 1 — прод-контур, 0 — тестовый |
|
||||||
|
|
||||||
|
## Каталог событий
|
||||||
|
|
||||||
|
| Тип события | Когда пишется | `payload` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `message` | Любое сообщение в основном чате | `message_id`, `chat_type`, `content_type`, `text_len`, `is_command` |
|
||||||
|
| `command` | Использование команды (`/инфо`, `/очищение`, …) | `command`, `args_count` |
|
||||||
|
| `purification` | Ежедневное очищение (`/очищение`) | `success`, `exp_add`, `money_add` |
|
||||||
|
| `exp_accrual` | Начисление опыта за очищение | `amount`, `streak` |
|
||||||
|
| `level_up` | Повышение уровня по итогам очищения | `level_from`, `level_to` |
|
||||||
|
| `money_accrual` | Начисление валюты (монетка за активность, награда за очищение) | `amount`, `reason`, `action_id` |
|
||||||
|
| `money_spend` | Трата валюты (покупка, обнуление баланса при удалении) | `amount`, `reason`, `product_id` |
|
||||||
|
| `eggs` | Получение яичек (`/яички`) | `amount` |
|
||||||
|
| `purchase_title` | Покупка звания | `title_id`, `price`, `success` |
|
||||||
|
| `purchase_donat` | Покупка доната | `donat_id`, `price`, `success` |
|
||||||
|
| `join` | Вход участника в основной чат | `chat_title` |
|
||||||
|
| `leave` | Выход участника из основного чата | `chat_title` |
|
||||||
|
| `nick_set` | Смена ника (`/ник`) | `nick` |
|
||||||
|
| `subscribe` | Подписка на рассылки (`/подписка`) | — |
|
||||||
|
| `unsubscribe` | Отписка от рассылок (`/отписка`) | — |
|
||||||
|
| `access_denied` | Отказ в доступе (не участник чата и не админ) | `reason` |
|
||||||
|
| `bot_start` | Старт бота | `app_env` |
|
||||||
|
| `bot_stop` | Остановка бота | `app_env`, `uptime_seconds` |
|
||||||
|
| `bot_alive` | Ежечасный пинг «бот живой» | `app_env`, `uptime_seconds` |
|
||||||
|
| `error` | Необработанная ошибка при обработке апдейта | `error_type`, `error`, `update_type` |
|
||||||
|
|
||||||
|
## Поведение при недоступности ClickHouse
|
||||||
|
|
||||||
|
Аналитика — **некритичная** подсистема: её сбой не должен влиять на бота.
|
||||||
|
|
||||||
|
- события копятся в буфере и отправляются батчами (по размеру и по таймеру);
|
||||||
|
- если ClickHouse недоступен, буфер не растёт бесконечно: при переполнении самые
|
||||||
|
старые записи отбрасываются со счётчиком в логе;
|
||||||
|
- если записать батч не удалось, события выводятся в stdout (`WARNING`, первые
|
||||||
|
несколько строк + общее количество) и отбрасываются — данные не пропадают молча;
|
||||||
|
- при остановке бота выполняется финальный сброс буфера;
|
||||||
|
- выключение целиком — `CLICKHOUSE_ENABLED=false` (тогда `record()` — no-op).
|
||||||
|
|
||||||
|
## Примеры запросов
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- активность по дням
|
||||||
|
SELECT toDate(event_time) AS day, count() FROM analytics.events GROUP BY day ORDER BY day;
|
||||||
|
|
||||||
|
-- топ команд за неделю
|
||||||
|
SELECT JSONExtractString(payload, 'command') AS cmd, count() AS uses
|
||||||
|
FROM analytics.events
|
||||||
|
WHERE event_type = 'command' AND event_time >= now() - INTERVAL 7 DAY
|
||||||
|
GROUP BY cmd ORDER BY uses DESC;
|
||||||
|
|
||||||
|
-- покупки за месяц
|
||||||
|
SELECT event_type, count(), sum(JSONExtractInt(payload, 'price'))
|
||||||
|
FROM analytics.events
|
||||||
|
WHERE event_type IN ('purchase_title', 'purchase_donat') AND event_time >= now() - INTERVAL 30 DAY
|
||||||
|
GROUP BY event_type;
|
||||||
|
|
||||||
|
-- ошибки
|
||||||
|
SELECT event_time, JSONExtractString(payload, 'error_type'), JSONExtractString(payload, 'error')
|
||||||
|
FROM analytics.events WHERE event_type = 'error' ORDER BY event_time DESC LIMIT 50;
|
||||||
|
```
|
||||||
|
|
@ -7,7 +7,7 @@ pydantic-settings>=2.3,<3.0
|
||||||
python-dotenv>=1.0,<2.0
|
python-dotenv>=1.0,<2.0
|
||||||
psycopg[binary,pool]>=3.2,<4.0
|
psycopg[binary,pool]>=3.2,<4.0
|
||||||
|
|
||||||
# Блок 3 (ClickHouse) — подключим позже:
|
# ClickHouse (аналитика событий, Блок 3). Extras [async] — нативный asyncio-клиент.
|
||||||
# clickhouse-connect>=0.7,<1.0
|
clickhouse-connect[async]>=1.0,<2.0
|
||||||
# Блок 4 (расписания из БД) — при необходимости:
|
# Блок 4 (расписания из БД) — при необходимости:
|
||||||
# APScheduler>=3.10,<4.0
|
# APScheduler>=3.10,<4.0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
"""Тесты аналитики: каталог событий, буферизация и деградация без ClickHouse.
|
||||||
|
|
||||||
|
Тесты не требуют ни ClickHouse, ни aiogram: клиент подменяется фейком, а
|
||||||
|
``clickhouse_connect`` импортируется лениво внутри клиента.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from common.analytics import ALL_EVENT_TYPES, EventType, close_analytics, get_analytics, init_analytics, record
|
||||||
|
from common.analytics.client import PAYLOAD_MAX_LENGTH, AnalyticsClient
|
||||||
|
from common.config import Settings
|
||||||
|
from common.version import __version__
|
||||||
|
|
||||||
|
_DOCS_PATH = Path(__file__).resolve().parents[1] / "docs" / "events.md"
|
||||||
|
|
||||||
|
#: Строка каталога событий: первый столбец в обратных кавычках.
|
||||||
|
_EVENT_ROW = re.compile(r"^\|\s*`([a-z_]+)`\s*\|", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClickHouse:
|
||||||
|
"""Заглушка клиента ClickHouse: пишет в список или падает."""
|
||||||
|
|
||||||
|
def __init__(self, *, fail: bool = False) -> None:
|
||||||
|
self.rows: list[list[Any]] = []
|
||||||
|
self.table: str | None = None
|
||||||
|
self.columns: list[str] | None = None
|
||||||
|
self.closed = False
|
||||||
|
self._fail = fail
|
||||||
|
|
||||||
|
async def insert(self, table: str, data: list[list[Any]], column_names: list[str]) -> None:
|
||||||
|
if self._fail:
|
||||||
|
raise RuntimeError("ClickHouse недоступен")
|
||||||
|
self.table = table
|
||||||
|
self.columns = list(column_names)
|
||||||
|
self.rows.extend(data)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides: Any) -> Settings:
|
||||||
|
return Settings(_env_file=None, bot_token="1:a", **overrides)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def _clean_singleton():
|
||||||
|
"""Тесты не должны оставлять активный singleton аналитики."""
|
||||||
|
await close_analytics()
|
||||||
|
yield
|
||||||
|
await close_analytics()
|
||||||
|
|
||||||
|
|
||||||
|
def _client(**overrides: Any) -> tuple[AnalyticsClient, FakeClickHouse]:
|
||||||
|
fake = FakeClickHouse()
|
||||||
|
return AnalyticsClient(_settings(**overrides), client=fake), fake
|
||||||
|
|
||||||
|
|
||||||
|
# --- каталог событий ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_types_are_unique_and_nonempty() -> None:
|
||||||
|
assert len(ALL_EVENT_TYPES) == len(EventType)
|
||||||
|
assert ALL_EVENT_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_types_match_documentation() -> None:
|
||||||
|
documented = set(_EVENT_ROW.findall(_DOCS_PATH.read_text(encoding="utf-8")))
|
||||||
|
assert documented == set(ALL_EVENT_TYPES), "Список событий в common/analytics/events.py и docs/events.md разошёлся"
|
||||||
|
|
||||||
|
|
||||||
|
# --- буферизация и запись ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_record_without_init_is_noop() -> None:
|
||||||
|
assert get_analytics() is None
|
||||||
|
record(EventType.MESSAGE, user_id=1, chat_id=2, payload={"a": 1}) # не должно бросать
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_buffers_and_flushes_rows() -> None:
|
||||||
|
client, fake = _client()
|
||||||
|
client.record(EventType.MESSAGE, user_id=11, username="vasya", chat_id=100, payload={"message_id": 5})
|
||||||
|
client.record(EventType.COMMAND, user_id=11, username="vasya", chat_id=100, payload={"command": "/инфо"})
|
||||||
|
|
||||||
|
assert client.pending == 2
|
||||||
|
|
||||||
|
await client.flush()
|
||||||
|
|
||||||
|
assert client.pending == 0
|
||||||
|
assert fake.table == "analytics.events"
|
||||||
|
assert fake.columns == [
|
||||||
|
"event_time",
|
||||||
|
"event_type",
|
||||||
|
"user_id",
|
||||||
|
"username",
|
||||||
|
"chat_id",
|
||||||
|
"payload",
|
||||||
|
"bot_version",
|
||||||
|
"prod",
|
||||||
|
]
|
||||||
|
assert len(fake.rows) == 2
|
||||||
|
|
||||||
|
row = fake.rows[0]
|
||||||
|
assert isinstance(row[0], datetime)
|
||||||
|
assert row[1] == "message"
|
||||||
|
assert row[2] == 11
|
||||||
|
assert row[3] == "vasya"
|
||||||
|
assert row[4] == 100
|
||||||
|
assert '"message_id": 5' in row[5]
|
||||||
|
assert row[6] == __version__
|
||||||
|
assert row[7] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remember_user_enriches_username() -> None:
|
||||||
|
client, fake = _client()
|
||||||
|
client.remember_user(42, "nickname")
|
||||||
|
client.record(EventType.PURIFICATION, user_id=42, chat_id=1)
|
||||||
|
|
||||||
|
await client.flush()
|
||||||
|
|
||||||
|
assert fake.rows[0][3] == "nickname"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_record_triggers_flush_at_batch_size() -> None:
|
||||||
|
client, _ = _client(analytics_batch_size=3)
|
||||||
|
|
||||||
|
client.record(EventType.MESSAGE, user_id=1)
|
||||||
|
client.record(EventType.MESSAGE, user_id=1)
|
||||||
|
assert not client._flush_now.is_set()
|
||||||
|
|
||||||
|
client.record(EventType.MESSAGE, user_id=1)
|
||||||
|
assert client._flush_now.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_overflow_drops_oldest_events() -> None:
|
||||||
|
client, _ = _client(analytics_buffer_max=2, analytics_batch_size=100)
|
||||||
|
|
||||||
|
for user_id in range(1, 5):
|
||||||
|
client.record(EventType.MESSAGE, user_id=user_id)
|
||||||
|
|
||||||
|
assert client.pending == 2
|
||||||
|
assert [row[2] for row in client._rows] == [3, 4]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_disabled_analytics_ignores_events() -> None:
|
||||||
|
client, fake = _client(clickhouse_enabled=False)
|
||||||
|
|
||||||
|
client.record(EventType.MESSAGE, user_id=1)
|
||||||
|
await client.flush()
|
||||||
|
|
||||||
|
assert client.pending == 0
|
||||||
|
assert fake.rows == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- деградация без ClickHouse ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flush_failure_logs_fallback_and_drops(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
fake = FakeClickHouse(fail=True)
|
||||||
|
client = AnalyticsClient(_settings(), client=fake)
|
||||||
|
client.record(EventType.MESSAGE, user_id=1, username="vasya", chat_id=100)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
await client.flush() # не должно бросать
|
||||||
|
|
||||||
|
assert client.pending == 0
|
||||||
|
assert any("Fallback" in message for message in caplog.messages)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unavailable_client_falls_back_to_log(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
settings = _settings(clickhouse_host="127.0.0.1", clickhouse_port=1, clickhouse_password="x")
|
||||||
|
client = AnalyticsClient(settings)
|
||||||
|
client.record(EventType.BOT_START, payload={"app_env": "dev"})
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
await client.flush()
|
||||||
|
|
||||||
|
assert client.pending == 0
|
||||||
|
assert any("Fallback" in message for message in caplog.messages)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_payload_is_truncated() -> None:
|
||||||
|
client, fake = _client()
|
||||||
|
client.record(EventType.MESSAGE, user_id=1, payload={"text": "x" * (PAYLOAD_MAX_LENGTH * 2)})
|
||||||
|
|
||||||
|
await client.flush()
|
||||||
|
|
||||||
|
assert len(fake.rows[0][5]) == PAYLOAD_MAX_LENGTH
|
||||||
|
|
||||||
|
|
||||||
|
# --- жизненный цикл ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_init_and_close_flush_pending_events() -> None:
|
||||||
|
fake = FakeClickHouse()
|
||||||
|
client = await init_analytics(_settings(), client=fake)
|
||||||
|
|
||||||
|
record(EventType.BOT_START, payload={"app_env": "dev"})
|
||||||
|
record(EventType.BOT_STOP, payload={"app_env": "dev"})
|
||||||
|
|
||||||
|
assert get_analytics() is client
|
||||||
|
assert client.pending == 2
|
||||||
|
|
||||||
|
await close_analytics()
|
||||||
|
|
||||||
|
assert get_analytics() is None
|
||||||
|
assert len(fake.rows) == 2
|
||||||
|
assert fake.closed is True
|
||||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
||||||
|
|
||||||
def test_version_is_set():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.2.5"
|
assert __version__ == "0.3.0"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue