Skip to content

База данных аналитики воронок#

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