Skip to content

ТЗ — 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_visitsid, visitor_token, source, medium, campaign, term, content, referrer, first_seen_atзахваченные метки визита
cms_utm_attributionsid, visitor_token, attributable_type, attributable_id, attribution_model, visit_idпривязка визита к лиду/заказу

FK visit_idconstrained() + 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-заголовок Refererreferrerсанитизация URL, усечение домена без query-строки с ПДн
Событие создания лида/заказа (ядро/коммерция)стандартный payload событияконтракт события ядра, слушатель тонкий — только привязка визита по visitor_token
Форма отчёта (Filament, фильтр)period, campaignFormRequest, whitelist полей фильтрации/сортировки

Выходы:

ПотребительДанныеФормат
Filament adminотчёт «источники → конверсии»keyset-JSON через API
Карточка лида/заказа (ядро)атрибуция визитасервис-вызов AttributionService::forEntity()
cms/email-marketing (suggests)атрибуция email-кампанийсервис-вызов (канал 4)
Подписчики событийUtmVisitCaptured, UtmAttributedpayload по таблице ниже

Настройки (группа utm)

КлючТипДефолтaffectsPageCacheОписание
utm.enabledbooltrueнетВключение захвата UTM-меток
utm.cookie_ttl_daysint90нетСрок хранения first-party cookie с меткой и ретеншн cms_utm_visits
utm.attribution_modelstringfirst_clickнетМодель атрибуции: first_click или last_click
utm.max_param_lengthint100нетМаксимальная длина одного UTM-параметра — превышение обрезается при захвате
utm.raw_visits_retention_daysint180нетСрок хранения сырых визитов до агрегации/очистки (не короче cookie_ttl_days)

Лимиты и квоты: max_param_length — защита от произвольно длинных query-параметров (защита от злоупотребления/XSS-подобных payload в UTM); raw_visits_retention_days — обязательная политика очистки журнальной таблицы (см. «Анти-паттерны стандарта»: журнал без ретеншна — бомба на диске).

API

МетодПутьДоступНазначение
GET/api/v1/admin/utm/reportadmin (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 в ссылке не создаёт ошибок — визит просто не атрибутируется, конверсия всё равно фиксируется.

Крайние случаи и типовые баги

  1. Атрибуция first vs last touch — настройка attribution_model определяет, какой визит побеждает при повторных заходах с разными метками: first_click не перезаписывает существующую cookie-метку, last_click перезаписывает при каждом новом визите с UTM-параметрами.
  2. Длина/валидность UTM-параметров — параметр длиннее max_param_length обрезается при захвате (не отклоняется целиком — частичная метка лучше отсутствующей), значения санитизируются (экранирование при выводе, запрет управляющих символов).
  3. Склейка визитов в лид — посетитель заходил несколько раз до конверсии → при first_click в cms_utm_attributions фиксируется самый первый визит по visitor_token, при last_click — последний перед моментом конверсии; выбор не меняется задним числом после фиксации атрибуции.
  4. Ретеншн сырых визитов — без плановой очистки cms_utm_visits растёт неограниченно (анти-паттерн «журнал без политики очистки»); raw_visits_retention_days обязателен, purge-expired запускается по расписанию, не только вручную.
  5. Двойной сабмит формы конверсии (гонка) → привязка атрибуции идемпотентна: повторное событие LeadCreated для того же attributable_id не создаёт вторую строку cms_utm_attributions (уникальность по (attributable_type, attributable_id)).
  6. Модуль выключен посреди визита — метка не захвачена в начале сессии, включён модуль на середине → визит с этого момента не имеет UTM-контекста, атрибуция при конверсии просто отсутствует (не ошибка, а честное отсутствие данных).
  7. Отсутствие cms/email-marketing (suggests) → email-кампании не участвуют в отчёте атрибуции отдельной строкой, но обычные UTM-source вида email продолжают захватываться как обычный источник трафика — деградация неполная, базовый функционал жив.
  8. Пустые/огромные визиты — посетитель без единого UTM-параметра (прямой заход) не создаёт строку cms_utm_visits вовсе (нет смысла хранить пустую метку); бот-трафик с фиктивными UTM на реферальных спам-доменах — не фильтруется модулем специально (вне ответственности), но max_param_length ограничивает ущерб от аномальных значений.
  9. Измерение city/locale: атрибуция не зависит от city_id/locale — источник трафика универсален для всех измерений сайта; отчёт может быть отфильтрован по периоду/кампании, но не хранит city_id как отдельное поле (осознанное ограничение).
  10. Противоречивые настройки: raw_visits_retention_days < cookie_ttl_days → ⚠️ Противоречие: сырые визиты будут удалены раньше, чем истечёт cookie у посетителя, что даёт «осиротевшую» cookie без соответствующей записи в БД при повторном визите. Разрешение: FormRequest настроек отклоняет такую комбинацию (422 «retention не может быть короче TTL cookie»), Filament подсказывает минимум.
  11. Конкурентное редактирование настроек атрибуции (attribution_model) двумя админами одновременно — настройки ядра уже используют оптимистичную блокировку группы в settings-store; специфики модуля сверх этого нет.
  12. Огромный отчёт (десятки тысяч кампаний за период) → построение отчёта агрегирует в БД (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 запрещён

Внутренняя база знаний студии