Задача «передавать источник рекламы в заявку» выглядит на пятнадцать минут: сохранил метки в куки, подставил в скрытое поле формы. На практике именно так и делают, а потом маркетолог сравнивает данные из CRM с кабинетом Яндекс.Директа и обнаруживает расхождение вдвое.
Разберём рабочую реализацию, а главное — три решения, которые в ней нужно принять осознанно: какую модель атрибуции вы используете, что делать с уже пришедшим посетителем и почему на сайте с композитом всё это может не сработать вообще.
Зачем сохранять UTM-метки
UTM-параметры передаются в URL при переходе из рекламы:
https://site.ru/?utm_source=yandex&utm_medium=cpc&utm_campaign=sale2024Но при переходе на другую страницу параметры теряются. Сохранение в cookies позволяет:
- Отслеживать источник на протяжении всей сессии
- Передавать UTM в заказ или заявку
- Анализировать эффективность рекламных каналов
Базовая реализация
Добавьте в файл /local/php_interface/init.php:
<?php
use Bitrix\Main\Web\Cookie;
use Bitrix\Main\Context;
AddEventHandler("main", "OnBeforeProlog", "saveUtmToCookies");
function saveUtmToCookies()
{
$utmParams = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_content',
'utm_term',
];
$cookieLifetime = 60 * 60 * 24 * 30; // 30 дней
$response = Context::getCurrent()->getResponse();
foreach ($utmParams as $param) {
if (isset($_GET[$param]) && !empty($_GET[$param])) {
$cookie = new Cookie(
$param,
htmlspecialchars($_GET[$param]),
time() + $cookieLifetime
);
$cookie->setHttpOnly(false); // Доступно для JS
$response->addCookie($cookie);
}
}
}Этот код реализует last-touch атрибуцию — и делает это молча. Каждый новый визит с метками перезаписывает куки, поэтому в заявку попадёт последний источник, а не тот, который привёл клиента изначально.
Это не ошибка, это одна из двух моделей, и выбирать между ними должен маркетолог, а не код по умолчанию:
- Last-touch (перезаписывать): отвечает на вопрос «что подтолкнуло к заявке». Обычно ближе к реальности для коротких циклов сделки — интернет-магазин, доставка еды.
- First-touch (не перезаписывать, если куки уже есть): отвечает на «откуда клиент вообще узнал». Для B2B с циклом в месяцы это гораздо полезнее: человек пришёл из статьи, потом трижды вернулся по брендовому запросу, и last-touch припишет всю заслугу брендовой рекламе.
Практически всегда стоит хранить оба набора кук — first и last, — раз уж данные всё равно проходят через один обработчик. Стоимость нулевая, а спорить с маркетологом об атрибуции задним числом дорого.
Значение из $_GET попадает в куку почти без обработки, и это две разные проблемы.
Длина. Ссылку с меткой формирует внешний мир. utm_campaign на четыре килобайта создаст куку, которая будет отправляться браузером в каждом последующем запросе, — вплоть до отказа веб-сервера с ошибкой «431 Request Header Fields Too Large», после которой сайт для этого посетителя перестаёт открываться совсем. Обрезайте: 255 символов с запасом хватает любой реальной кампании.
Экранирование. htmlspecialchars() — это подготовка к выводу в HTML, а не проверка данных. Значение из куки потом попадает в скрытое поле формы, в JavaScript, в письмо менеджеру и в CRM — и в каждом из этих контекстов нужно своё экранирование. Правильнее фильтровать на входе по белому списку символов: буквы, цифры, дефис, подчёркивание, точка. Всё остальное в UTM-метке не имеет смысла.
Улучшенная версия
Расширенная реализация с дополнительными возможностями:
<?php
namespace Local\Marketing;
use Bitrix\Main\Web\Cookie;
use Bitrix\Main\Context;
use Bitrix\Main\EventManager;
class UtmTracker
{
/**
* Список отслеживаемых параметров
*/
private const UTM_PARAMS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_content',
'utm_term',
];
/**
* Дополнительные параметры (Яндекс.Директ, Google Ads)
*/
private const EXTRA_PARAMS = [
'yclid', // Яндекс.Директ
'gclid', // Google Ads
'fbclid', // Facebook
'referrer', // Реферер
];
/**
* Время жизни cookie (30 дней)
*/
private const COOKIE_LIFETIME = 2592000;
/**
* Префикс для cookies
*/
private const COOKIE_PREFIX = 'utm_';
/**
* Регистрация обработчика
*/
public static function register(): void
{
EventManager::getInstance()->addEventHandler(
'main',
'OnBeforeProlog',
[self::class, 'handleRequest']
);
}
/**
* Обработка запроса
*/
public static function handleRequest(): void
{
$allParams = array_merge(self::UTM_PARAMS, self::EXTRA_PARAMS);
$response = Context::getCurrent()->getResponse();
foreach ($allParams as $param) {
if (!isset($_GET[$param]) || empty($_GET[$param])) {
continue;
}
$value = self::sanitizeValue($_GET[$param]);
if (empty($value)) {
continue;
}
$cookie = new Cookie(
self::COOKIE_PREFIX . $param,
$value,
time() + self::COOKIE_LIFETIME
);
$cookie->setHttpOnly(false);
$cookie->setSecure(self::isHttps());
$cookie->setPath('/');
$response->addCookie($cookie);
}
// Сохраняем первый визит
self::saveFirstVisit();
}
/**
* Очистка значения
*/
private static function sanitizeValue(string $value): string
{
$value = trim($value);
$value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
// Ограничиваем длину
if (strlen($value) > 255) {
$value = substr($value, 0, 255);
}
return $value;
}
/**
* Проверка HTTPS
*/
private static function isHttps(): bool
{
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| ($_SERVER['SERVER_PORT'] ?? 80) == 443;
}
/**
* Сохранение даты первого визита
*/
private static function saveFirstVisit(): void
{
$cookieName = self::COOKIE_PREFIX . 'first_visit';
if (!isset($_COOKIE[$cookieName])) {
$response = Context::getCurrent()->getResponse();
$cookie = new Cookie(
$cookieName,
date('Y-m-d H:i:s'),
time() + self::COOKIE_LIFETIME
);
$response->addCookie($cookie);
}
}
/**
* Получение всех сохранённых UTM-параметров
*/
public static function getAll(): array
{
global $APPLICATION;
$result = [];
$allParams = array_merge(self::UTM_PARAMS, self::EXTRA_PARAMS);
foreach ($allParams as $param) {
$cookieName = self::COOKIE_PREFIX . $param;
$value = $APPLICATION->get_cookie($cookieName);
if (!empty($value)) {
$result[$param] = $value;
}
}
// Добавляем дату первого визита
$firstVisit = $APPLICATION->get_cookie(self::COOKIE_PREFIX . 'first_visit');
if ($firstVisit) {
$result['first_visit'] = $firstVisit;
}
return $result;
}
/**
* Получение конкретного параметра
*/
public static function get(string $param): ?string
{
global $APPLICATION;
$value = $APPLICATION->get_cookie(self::COOKIE_PREFIX . $param);
return !empty($value) ? $value : null;
}
/**
* Формирование строки для сохранения в заказ
*/
public static function getForOrder(): string
{
$utm = self::getAll();
if (empty($utm)) {
return '';
}
$parts = [];
foreach ($utm as $key => $value) {
$parts[] = "{$key}={$value}";
}
return implode('; ', $parts);
}
}Регистрация в init.php
<?php
// /local/php_interface/init.php
\Local\Marketing\UtmTracker::register();Использование
Получение UTM в любом месте сайта
<?php
use Local\Marketing\UtmTracker;
// Все параметры
$allUtm = UtmTracker::getAll();
print_r($allUtm);
// Конкретный параметр
$source = UtmTracker::get('utm_source');
echo "Источник: " . $source;Добавление UTM в заказ
<?php
// Обработчик события создания заказа
AddEventHandler('sale', 'OnSaleOrderBeforeSaved', function ($event) {
$order = $event->getParameter('ENTITY');
$utmString = \Local\Marketing\UtmTracker::getForOrder();
if (!empty($utmString)) {
$order->setField('USER_DESCRIPTION', $utmString);
// Или сохраняем в свойство заказа
}
});Передача в форму обратной связи
<input type="hidden" name="UTM_DATA" value="<?= htmlspecialchars(\Local\Marketing\UtmTracker::getForOrder()) ?>">Таблица отслеживаемых параметров
| Параметр | Описание | Пример |
|---|---|---|
utm_source | Источник трафика | yandex, google, vk |
utm_medium | Тип трафика | cpc, email, social |
utm_campaign | Название кампании | sale2024, promo |
utm_content | Содержимое объявления | banner1, text_ad |
utm_term | Ключевое слово | купить диван |
yclid | ID клика Яндекс.Директ | 12345678 |
gclid | ID клика Google Ads | EAIaIQ... |
Почему данные не сходятся с рекламным кабинетом
Несколько причин расхождения, которые обнаруживаются уже после внедрения.
Композит и кэширование. Если на сайте включён композитный режим, первую страницу отдаёт nginx напрямую из файла — PHP не запускается, OnBeforeProlog не срабатывает, метки не сохраняются. Ровно для того посетителя, который пришёл по рекламе и открыл одну страницу. Решается сохранением меток на стороне JavaScript либо исключением URL с параметрами из композита.
Блокировщики и режимы приватности. Часть посетителей приходит с заблокированными куками; для них источник не определится никогда. Это нормальный фон, а не поломка, — важно понимать порядок величины, чтобы не искать баг там, где его нет.
Разные окна атрибуции. Яндекс.Директ по умолчанию считает конверсии в своём окне, ваша кука живёт свои 30 дней. Числа не обязаны совпадать, и сравнивать их нужно с учётом настроек с обеих сторон.
yclid и gclid важнее UTM. Идентификатор клика позволяет сопоставить заявку с конкретным кликом в кабинете, чего UTM-метки не дают в принципе. Если задача — считать окупаемость рекламы, а не «примерно понимать источник», сохранять нужно в первую очередь их.
Итоги
Определитесь с моделью атрибуции до написания кода и храните оба набора меток — first и last.
Фильтруйте значения на входе: ограничение длины и белый список символов. Ссылку с меткой формирует кто угодно.
Проверьте, работает ли обработчик при включённом кэшировании страниц. На композите обработчик просто не выполнится для первого визита — того самого, который вам и нужен.
Сохраняйте yclid/gclid и адрес страницы входа, а не только UTM: первое даёт связь с кликом в кабинете, второе — понимание, куда именно вела реклама.
Про время жизни куки: его задаёт окно конверсии вашего бизнеса, а не привычка. 30 дней для интернет-магазина разумны, для B2B с циклом сделки в квартал — бесполезны: заявка придёт, когда кука уже истечёт, и источник потеряется.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.