Тема
ТЗ — UTM/атрибуция (cms/utm)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Захват UTM-меток и источника трафика при первом визите, сохранение в first-party cookie и привязка к лиду/заказу в момент конверсии. Строит отчёт «источники → конверсии» с поддержкой моделей атрибуции первого и последнего клика.
- Захват UTM-меток (
source/medium/campaign/term/content) и реферера при первом визите - Хранение в first-party cookie с настраиваемым TTL, без привязки к сторонним доменам
- Привязка сохранённых меток к лиду/заказу в момент конверсии
- Отчёт «источники → конверсии» в админке с разбивкой по кампаниям
- Мультиканальная атрибуция: модель первого клика и модель последнего клика (переключение конфигом)
- Хранение истории визитов пользователя для мультиканального разбора (без ПДн в самой метке)
- Санитизация и обрезка UTM-параметров по лимитам длины при захвате
- Плановая агрегация и ретеншн сырых визитов старше
cookie_ttl_days
Зависимости и выключение
requires: ядро (лиды/заказы) · suggests: cms/email-marketing (атрибуция email-кампаний)
Поведение при выключении: UTM-метки не захватываются и не сохраняются, лиды/заказы создаются без привязки к источнику — оформление заявок и заказов не затрагивается, деградация аналитики, не функциональности. Внешних платных API нет — модуль не тарифицируется, единственная стоимость — рост объёма cms_utm_visits без плановой очистки (см. «Крайние случаи»).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_utm_visits | id, visitor_token, source, medium, campaign, term, content, referrer, first_seen_at | захваченные метки визита |
cms_utm_attributions | id, visitor_token, attributable_type, attributable_id, attribution_model, visit_id | привязка визита к лиду/заказу |
FK visit_id — constrained() + index(); индекс visitor_token под горячий путь разрешения атрибуции; attribution_model — PHP Enum; полиморфный индекс (attributable_type, attributable_id).
ПДн-паспорт: visitor_token — обезличенный идентификатор (не email/телефон/ФИО), referrer/UTM-поля — не персональные данные по смыслу, но cms_utm_visits — журнал поведения конкретного браузера и подпадает под ретеншн-политику: срок хранения = cookie_ttl_days (деф. 90 суток), плановая очистка cms:utm:purge-expired. Участие в «выгрузить всё по субъекту»/«забыть по запросу» (152-ФЗ): при наличии email/телефона в связанном лиде выгрузка идёт через cms_utm_attributions.attributable_id → лид; сам visitor_token не позволяет установить личность напрямую.
Входные и выходные данные
Входы:
| Источник | Поля | Чем валидируется |
|---|---|---|
| Query-параметры первого визита (посетитель) | utm_source, utm_medium, utm_campaign, utm_term, utm_content | санитизация + обрезка по лимиту длины (см. «Настройки»), только из текущего запроса, не из сторонних cookie |
HTTP-заголовок Referer | referrer | санитизация URL, усечение домена без query-строки с ПДн |
| Событие создания лида/заказа (ядро/коммерция) | стандартный payload события | контракт события ядра, слушатель тонкий — только привязка визита по visitor_token |
| Форма отчёта (Filament, фильтр) | period, campaign | FormRequest, whitelist полей фильтрации/сортировки |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Filament admin | отчёт «источники → конверсии» | keyset-JSON через API |
| Карточка лида/заказа (ядро) | атрибуция визита | сервис-вызов AttributionService::forEntity() |
cms/email-marketing (suggests) | атрибуция email-кампаний | сервис-вызов (канал 4) |
| Подписчики событий | UtmVisitCaptured, UtmAttributed | payload по таблице ниже |
Настройки (группа utm)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
utm.enabled | bool | true | нет | Включение захвата UTM-меток |
utm.cookie_ttl_days | int | 90 | нет | Срок хранения first-party cookie с меткой и ретеншн cms_utm_visits |
utm.attribution_model | string | first_click | нет | Модель атрибуции: first_click или last_click |
utm.max_param_length | int | 100 | нет | Максимальная длина одного UTM-параметра — превышение обрезается при захвате |
utm.raw_visits_retention_days | int | 180 | нет | Срок хранения сырых визитов до агрегации/очистки (не короче cookie_ttl_days) |
Лимиты и квоты: max_param_length — защита от произвольно длинных query-параметров (защита от злоупотребления/XSS-подобных payload в UTM); raw_visits_retention_days — обязательная политика очистки журнальной таблицы (см. «Анти-паттерны стандарта»: журнал без ретеншна — бомба на диске).
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/utm/report | admin (utm.view) | Отчёт «источники → конверсии» |
| GET | /api/v1/admin/utm/attributions/{leadOrOrder} | admin (utm.view) | Атрибуция конкретного лида/заказа |
Компоненты
Filament: отчёт источников с фильтром по периоду/кампании, карточка атрибуции на лиде/заказе. Команды: cms:utm:purge-expired --json.
Демо-контент: UtmDemoSeeder создаёт 3 демо-визита с разными source/medium/campaign и 2 демо-атрибуции (лид/заказ) — отчёт в галерее /_gallery показывает разбивку по источникам без реального трафика.
Фронтенд-бюджет: захват UTM выполняется на сервере при первом запросе (нет клиентского JS вовсе) — 0 влияния на фронтенд-бюджет и CLS.
Эксплуатация (ранбук): метрики utm_visits_captured_total, utm_attributions_recorded_total{model}, utm_visits_purged_total; алерт — рост объёма cms_utm_visits без соответствующего роста purged_total (плановая очистка не выполняется).
| Симптом | Что проверить / команда |
|---|---|
| Метки не сохраняются | utm.enabled, проверить, что первый визит действительно содержит utm_* в query |
| Атрибуция не проставляется на лиде | проверить visitor_token в cookie посетителя, TTL не истёк |
| Отчёт растёт медленно/долго строится | индекс visitor_token, объём cms_utm_visits, актуальность raw_visits_retention_days |
| Таблица визитов разрослась | cms:utm:purge-expired --json, сверить raw_visits_retention_days |
Бэкап/рестор: cms_utm_visits/attributions — в бэкапе целиком (журнальные, но полезны для отчётности); после рестора команда cms:utm:purge-expired может сразу отработать плановую очистку за пропущенный период — это ожидаемо, не потеря данных.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
UtmVisitCaptured | захвачена метка первого визита | visitor_token, source, medium, campaign |
UtmAttributed | визит привязан к лиду/заказу при конверсии | visitor_token, attributable_type, attributable_id |
Слушает: события создания лида и заказа ядра/коммерции для привязки атрибуции. cms/email-marketing потребляет атрибуцию для разметки эффективности email-кампаний (suggests).
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро (LeadCreated, OrderCompleted) | событие (1) | ядро → utm | привязка сохранённого визита к лиду/заказу по visitor_token |
cms/email-marketing (suggests) | сервис-вызов (4) | email-marketing → utm | атрибуция открытий/переходов email-кампаний как источника |
| Filament admin | сервис-вызов (внутримодульно) | admin → utm | построение отчёта «источники → конверсии» |
очередь utm | очередь (5) | utm → воркер | purge-expired — плановая очистка визитов старше TTL |
Фоновая работа
Очередь utm: purge-expired — плановая очистка визитов старше raw_visits_retention_days (не короче cookie_ttl_days) по расписанию через ScheduleRegistrar. Захват метки и привязка атрибуции — синхронные, без внешних вызовов.
Производительность и кеш
Ожидаемые объёмы: один cms_utm_visits-визит на посетителя за TTL-окно (десятки-сотни тысяч строк на среднем трафике), одна cms_utm_attributions-запись на конверсию. Горячий путь — захват метки на первом визите: один insert без предварительного select (uniqueness не требуется — новый визит = новая строка, upsert не нужен). Разрешение атрибуции на карточке лида/заказа — один запрос по индексу visitor_token/attributable_id.
Критичные индексы: visitor_token на обеих таблицах, полиморфный (attributable_type, attributable_id). Построение отчёта — агрегация по campaign с group by, keyset-пагинация по периодам, без построчной обработки в PHP (агрегация в БД). cms_utm_visits — кандидат на BRIN по first_seen_at при большом объёме. Собственных тегов page-cache нет — захват выполняется вне кешируемого фрагмента ответа.
Безопасность
Захват UTM-меток — только из query-параметров текущего запроса, без чтения сторонних cookie; first-party cookie без привязки к сторонним доменам. visitor_token не содержит и не позволяет восстановить персональные данные. Значения UTM-параметров экранируются при выводе в отчётах (, не {!! !!}) — источник данных неконтролируемый (query-параметры посетителя).
Права: utm.view, utm.manage.
Матрица ролей:
| Действие | Администратор | Менеджер | Редактор | Studio |
|---|---|---|---|---|
Просмотр отчёта/атрибуции (view) | ✅ | ✅ | ✅ | ✅ |
Правка attribution_model, TTL, лимитов (manage) | ✅ | ✅ | — | ✅ |
Ручной запуск purge-expired | ✅ | — | — | ✅ |
UX-требования
Админ: пустое состояние отчёта — «Нет визитов с UTM-метками за период» с подсказкой проверить наличие utm_* в ссылках кампаний; массовый экспорт отчёта по кампаниям; человеческая ошибка при попытке установить raw_visits_retention_days короче cookie_ttl_days («Срок хранения визитов не может быть короче TTL cookie»); подтверждение перед ручным запуском purge-expired вне расписания (необратимое удаление сырых визитов).
Посетитель: захват метки не создаёт заметной задержки (выполняется в рамках обычного запроса страницы, не отдельным round-trip); отсутствие UTM в ссылке не создаёт ошибок — визит просто не атрибутируется, конверсия всё равно фиксируется.
Крайние случаи и типовые баги
- Атрибуция first vs last touch — настройка
attribution_modelопределяет, какой визит побеждает при повторных заходах с разными метками:first_clickне перезаписывает существующую cookie-метку,last_clickперезаписывает при каждом новом визите с UTM-параметрами. - Длина/валидность UTM-параметров — параметр длиннее
max_param_lengthобрезается при захвате (не отклоняется целиком — частичная метка лучше отсутствующей), значения санитизируются (экранирование при выводе, запрет управляющих символов). - Склейка визитов в лид — посетитель заходил несколько раз до конверсии → при
first_clickвcms_utm_attributionsфиксируется самый первый визит поvisitor_token, приlast_click— последний перед моментом конверсии; выбор не меняется задним числом после фиксации атрибуции. - Ретеншн сырых визитов — без плановой очистки
cms_utm_visitsрастёт неограниченно (анти-паттерн «журнал без политики очистки»);raw_visits_retention_daysобязателен,purge-expiredзапускается по расписанию, не только вручную. - Двойной сабмит формы конверсии (гонка) → привязка атрибуции идемпотентна: повторное событие
LeadCreatedдля того жеattributable_idне создаёт вторую строкуcms_utm_attributions(уникальность по(attributable_type, attributable_id)). - Модуль выключен посреди визита — метка не захвачена в начале сессии, включён модуль на середине → визит с этого момента не имеет UTM-контекста, атрибуция при конверсии просто отсутствует (не ошибка, а честное отсутствие данных).
- Отсутствие
cms/email-marketing(suggests) → email-кампании не участвуют в отчёте атрибуции отдельной строкой, но обычные UTM-source видаemailпродолжают захватываться как обычный источник трафика — деградация неполная, базовый функционал жив. - Пустые/огромные визиты — посетитель без единого UTM-параметра (прямой заход) не создаёт строку
cms_utm_visitsвовсе (нет смысла хранить пустую метку); бот-трафик с фиктивными UTM на реферальных спам-доменах — не фильтруется модулем специально (вне ответственности), ноmax_param_lengthограничивает ущерб от аномальных значений. - Измерение city/locale: атрибуция не зависит от
city_id/locale— источник трафика универсален для всех измерений сайта; отчёт может быть отфильтрован по периоду/кампании, но не хранитcity_idкак отдельное поле (осознанное ограничение). - Противоречивые настройки:
raw_visits_retention_days < cookie_ttl_days→ ⚠️ Противоречие: сырые визиты будут удалены раньше, чем истечёт cookie у посетителя, что даёт «осиротевшую» cookie без соответствующей записи в БД при повторном визите. Разрешение: FormRequest настроек отклоняет такую комбинацию (422 «retention не может быть короче TTL cookie»), Filament подсказывает минимум. - Конкурентное редактирование настроек атрибуции (
attribution_model) двумя админами одновременно — настройки ядра уже используют оптимистичную блокировку группы в settings-store; специфики модуля сверх этого нет. - Огромный отчёт (десятки тысяч кампаний за период) → построение отчёта агрегирует в БД (
GROUP BY campaign), не построчно в PHP; keyset-пагинация по кампаниям исключаетOFFSET-деградацию на больших выборках.
Донорский код
Донор: — (новая разработка)
Legacy-импорт: cms:utm:import-legacy --source=<профиль> — перенос истории визитов и атрибуций (если у клиента был сторонний трекер с экспортом) в cms_utm_visits/attributions по ключу external_id; идемпотентен, --dry-run с отчётом расхождений. На практике востребован реже других legacy-импортёров студии — исторические визиты чаще не переносятся при смене платформы (данные быстро теряют актуальность).
Тесты и приёмка
- [ ] Контрактный тест: визит с UTM-метками привязывается к заказу при конверсии
- [ ] Cookie first-party, TTL соблюдается, метка не перезаписывается повторным визитом при
first_click - [ ]
last_clickперезаписывает метку при каждом новом визите с UTM-параметрами - [ ] Параметр длиннее
max_param_lengthобрезается, а не отклоняет весь визит - [ ] Отчёт «источники → конверсии» корректно агрегирует по кампаниям
- [ ] При выключении модуля лиды/заказы создаются без атрибуции, без ошибок
- [ ]
raw_visits_retention_days < cookie_ttl_daysотклоняется настройками (422) - [ ]
purge-expiredудаляет визиты старше срока и не трогает свежие - [ ] Повторное событие конверсии не создаёт вторую атрибуцию (уникальность связки)
- [ ] Права
utm.view/utm.manageразграничивают чтение отчёта и управление - [ ] Нет N+1 при построении отчёта по большому числу визитов
- [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API; тестовая БД только
utm_test,migrate:freshзапрещён