База данных аналитики воронок#
PostgreSQL OLAP-хранилище событий funnel_step. Миграции — репозиторий olap-db.
Связанные документы: - collect_event.md — HTTP-ручка сервиса
Архитектура#
Flutter / Backend
│
▼
terra-user-analytics-service (Basic Auth)
│
▼ JSON-массив событий
sync.analytics_events_import
│
▼
analytics.events (партиции по месяцам)
Сервис аналитики не пишет в таблицу напрямую — только через ХП/функцию импорта.
Схемы PostgreSQL#
| Схема | Назначение |
|---|---|
analytics |
Данные — таблица events |
sync |
Импорт и управление партициями |
reports |
Выгрузка данных |
cron |
Обслуживание (truncate) |
Таблица analytics.events#
Партиционированная таблица (PARTITION BY RANGE (created_at)), шаг — месяц.
Колонки#
| Колонка | Тип | NULL | Описание |
|---|---|---|---|
id |
uuid | нет | PK, генерируется на стороне БД если не передан |
event |
text | нет | Тип события (funnel_step) |
funnel |
text | нет | Воронка |
action |
text | нет | Сценарий |
step |
text | нет | Этап |
status |
text | нет | Результат шага |
method |
text | да | Способ взаимодействия |
provider |
text | да | Провайдер |
platform |
text | нет | Источник события |
error_code |
text | да | Обязателен при status = failed |
user_uuid |
uuid | да | UUID пользователя |
session_uuid |
uuid | да | UUID попытки регистрации |
user_uuid_key |
uuid | нет | Generated: COALESCE(user_uuid, zero-uuid) — для дедупа |
session_uuid_key |
uuid | нет | Generated: COALESCE(session_uuid, zero-uuid) — для дедупа |
created_at |
timestamptz | нет | Время события (ключ партиционирования) |
dt_load |
timestamptz | нет | Время загрузки в OLAP (DEFAULT now()) |
Партиции (шардирование)#
Данные физически лежат в месячных партициях:
analytics.events — родительская таблица
analytics.events_y2026m08 — август 2026
analytics.events_y2026m09 — сентябрь 2026
...
PostgreSQL сам направляет INSERT в нужную партицию по created_at.
sync.ensure_partitions#
Создаёт партиции за указанный диапазон месяцев.
CALL sync.ensure_partitions(_months_behind := 1, _months_ahead := 3);
| Параметр | По умолчанию | Описание |
|---|---|---|
_months_behind |
1 | Сколько прошлых месяцев создать (для поздних событий) |
_months_ahead |
3 | Сколько будущих месяцев создать заранее |
Вызывается автоматически при каждом импорте. Также запускается при деплое миграций.
sync.drop_old_partitions#
Удаляет партиции старше retention.
CALL sync.drop_old_partitions(_retention_months := 24);
Рекомендуется повесить на cron (раз в месяц).
Импорт данных#
sync.analytics_events_import_impl (функция)#
Основной способ записи. Принимает JSON-массив объектов.
SELECT sync.analytics_events_import_impl('[
{
"event": "funnel_step",
"funnel": "auth",
"action": "login",
"step": "identification",
"status": "success",
"method": "phone",
"platform": "android",
"created_at": "2026-08-05T12:00:00Z"
},
{
"event": "funnel_step",
"funnel": "auth",
"action": "login",
"step": "verification_request",
"status": "success",
"method": "phone",
"provider": "call_robot",
"platform": "backend",
"created_at": "2026-08-05T12:00:05Z"
}
]'::jsonb);
Ответ:
{
"total": 2,
"inserted": 2,
"duplicates": 0,
"rejected": 0,
"ids": ["uuid-1", "uuid-2"]
}
| Поле | Описание |
|---|---|
total |
Событий в массиве |
inserted |
Успешно записано |
duplicates |
Отклонено как дубликат |
rejected |
Не прошли валидацию |
ids |
UUID вставленных записей |
sync.analytics_events_import (ХП)#
Обёртка для вызова через CALL:
DO $$
DECLARE
v_result jsonb;
BEGIN
CALL sync.analytics_events_import('[{...}]'::jsonb, v_result);
RAISE NOTICE '%', v_result;
END $$;
Валидация при импорте#
| Правило | Результат |
|---|---|
| Не массив / пустой массив | Exception / {total: 0} |
| Нет обязательного поля | rejected++ |
status = failed без error_code |
rejected++ |
Дубликат по ux_events_dedup |
duplicates++ |
Дедупликация#
Ключ:
event + funnel + action + step + status + platform + user_uuid + session_uuid + created_at
NULL в user_uuid / session_uuid нормализуются в zero-uuid через generated-колонки.
Выгрузка данных#
reports.analytics_events_export_impl (функция)#
Выгрузка событий за период [pb, pe) — pe не включается. Один аргумент _filters — JSON-объект.
SELECT reports.analytics_events_export_impl('{
"pb": "2026-08-01T00:00:00Z",
"pe": "2026-09-01T00:00:00Z",
"funnel": "auth",
"action": "login"
}'::jsonb);
Поля _filters:
| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
pb |
string (timestamptz) | ✅ | Начало периода |
pe |
string (timestamptz) | ✅ | Конец периода |
funnel |
string | ❌ | Воронка |
action |
string | ❌ | Сценарий |
step |
string | ❌ | Этап |
status |
string | ❌ | Результат шага |
method |
string | ❌ | Способ |
provider |
string | ❌ | Провайдер |
platform |
string | ❌ | Платформа |
user_uuid |
string | ❌ | UUID пользователя |
session_uuid |
string | ❌ | UUID сессии регистрации |
Ответ:
{
"pb": "2026-08-01T00:00:00Z",
"pe": "2026-09-01T00:00:00Z",
"filters": {"funnel": "auth", "action": "login"},
"total": 2,
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"event": "funnel_step",
"funnel": "auth",
"action": "login",
"step": "identification",
"status": "success",
"method": "phone",
"provider": null,
"platform": "android",
"error_code": null,
"user_uuid": null,
"session_uuid": null,
"created_at": "2026-08-05T12:00:00Z",
"dt_load": "2026-08-05T12:00:01Z"
}
]
}
reports.analytics_events_export (ХП)#
DO $$
DECLARE
v_result jsonb;
BEGIN
CALL reports.analytics_events_export('{
"pb": "2026-08-01T00:00:00Z",
"pe": "2026-09-01T00:00:00Z",
"funnel": "auth"
}'::jsonb, v_result);
RAISE NOTICE '%', v_result;
END $$;
Очистка#
cron.clear_events_truncate#
Полная очистка всех партиций (для dev/stage):
CALL cron.clear_events_truncate();
На prod — только через согласованный процесс.
Запросы для отчётов#
Все запросы идут к родительской таблице analytics.events — PostgreSQL сам читает нужные партиции.
Воронка login#
SELECT step, status, count(*)
FROM analytics.events
WHERE funnel = 'auth'
AND action = 'login'
AND created_at >= '2026-08-01'
AND created_at < '2026-09-01'
GROUP BY step, status
ORDER BY step, status;
Список партиций#
SELECT c.relname AS partition_name,
pg_get_expr(c.relpartbound, c.oid) AS bounds
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'analytics'
AND p.relname = 'events'
ORDER BY c.relname;
Объём по месяцам#
SELECT to_char(created_at, 'YYYY-MM') AS month,
count(*) AS events
FROM analytics.events
GROUP BY 1
ORDER BY 1;
Остальные отчёты — в spec.md.
Обслуживание#
| Задача | Команда | Периодичность |
|---|---|---|
| Создать партиции | CALL sync.ensure_partitions() |
Авто при импорте + при деплое |
| Удалить старые партиции | CALL sync.drop_old_partitions(24) |
Cron, раз в месяц |
| Очистка dev/stage | CALL cron.clear_events_truncate() |
Вручную |
| Vacuum/analyze | autovacuum (настроен на таблице) | Авто |
Типичные ошибки#
| Ошибка | Причина | Решение |
|---|---|---|
no partition of relation "events" found for row |
Нет партиции на месяц created_at |
CALL sync.ensure_partitions(_months_ahead := 6) |
duplicate key value violates unique constraint "ux_events_dedup" |
Повторная отправка | Нормально — событие уже есть |
new row violates check constraint "ck_events_error_code" |
failed без error_code |
Передать error_code |
Импорт вернул rejected > 0 |
Пустые обязательные поля | Проверить payload |