# События аналитики (ClickHouse) Бот пишет все значимые действия в ClickHouse — единую широкую таблицу `analytics.events`. Дашборд (Superset) и ETL — отдельный финальный этап; здесь описан **контракт** событий: какие бывают `event_type` и что лежит в `payload`. > Источник правды в коде — `common/analytics/events.py` (`EventType`). > Расхождение со списком ниже ловит `tests/test_analytics.py`. > Добавляешь событие — добавь строку в таблицу. ## Схема в ClickHouse DDL — `clickhouse/init/001_events.sql` (применяется при первой инициализации контейнера). - `analytics.events` — сырые события, `MergeTree`, `PARTITION BY toYYYYMMDD(event_time)`, `ORDER BY (event_time, event_type, user_id)`. Партиционирование по дню выбрано специально: сырые партиции ежедневно удаляются после бэкапа (дешёвый `DROP PARTITION`, без mutation). - `analytics.events_daily` — агрегат (`SummingMergeTree`): `day`, `event_type`, `user_id`, `chat_id`, `events`. Строится материализованным представлением `events_daily_mv`. - `analytics.money_daily` — агрегат по деньгам: `day`, `event_type`, `user_id`, `amount_sum`, `events` (из `payload.amount` событий `money_accrual`/`money_spend`). Агрегаты остаются на VPS навсегда, поэтому активность, топ команд и денежные потоки доступны и после удаления сырых событий. Запросы к агрегатам суммируют `events`/`amount_sum` (фон слияний `SummingMergeTree` не гарантирует полную свёртку в любой момент). ## Хранение и бэкап | Что | Где | Сколько | | --- | --- | --- | | Сырые события | VPS, `analytics.events` | последние `BACKUP_GRACE_DAYS` дней (по умолчанию 7) | | Агрегаты | VPS | постоянно | | Полная история | домашний сервер, свой ClickHouse | архивы `events_YYYY-MM-DD.parquet.gz` (ежедневно) | Каждую ночь (по Москве) домашний сервер **сам забирает** вчерашнюю партицию с VPS (`pull` по ssh), импортирует её в свой ClickHouse и **сверяет количество строк**; только после успешной сверки он просит VPS удалить партиции старше грейса. Ошибка бэкапа данные не удаляет. Подробности — `DEPLOY.md` и `deploy/ssh-access.md`. ## Уведомления в чат логов Помимо записи в ClickHouse часть событий дублируется сообщением в `ALERT_CHAT_ID` (если он задан; `0` — выключено). Отправка не критична: ошибки только логируются. | Уведомление | Когда | Событие в ClickHouse | | --- | --- | --- | | Вход участника | кто-то вошёл в основной чат | `join` | | Выход участника | кто-то вышел из основного чата | `leave` | | Очищение | успешное `/очищение` (`+опыт`, `+монеты`) | `purification` | | Пинг «бот живой» | раз в час | `bot_alive` | | Попытка доступа извне | не участник чата стучится в бота | `access_denied` | ## Поля события | Поле | Тип | Описание | | --- | --- | --- | | **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; ```