Главная/Статьи/Битрикс: сохранение UTM-меток в cookies

Битрикс: сохранение UTM-меток в cookies

Автоматическое сохранение UTM-параметров в cookies для отслеживания источников трафика. Полная реализация с учётом перезаписи и времени жизни.

ДМ
Дмитрий Мещеряков
📅 26 октября 2024 г.📖 7 мин чтения

Задача «передавать источник рекламы в заявку» выглядит на пятнадцать минут: сохранил метки в куки, подставил в скрытое поле формы. На практике именно так и делают, а потом маркетолог сравнивает данные из CRM с кабинетом Яндекс.Директа и обнаруживает расхождение вдвое.

Разберём рабочую реализацию, а главное — три решения, которые в ней нужно принять осознанно: какую модель атрибуции вы используете, что делать с уже пришедшим посетителем и почему на сайте с композитом всё это может не сработать вообще.

Зачем сохранять UTM-метки

UTM-параметры передаются в URL при переходе из рекламы:

text
https://site.ru/?utm_source=yandex&utm_medium=cpc&utm_campaign=sale2024

Но при переходе на другую страницу параметры теряются. Сохранение в cookies позволяет:

  • Отслеживать источник на протяжении всей сессии
  • Передавать UTM в заказ или заявку
  • Анализировать эффективность рекламных каналов

Базовая реализация

Добавьте в файл /local/php_interface/init.php:

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
<?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
<?php
// /local/php_interface/init.php

\Local\Marketing\UtmTracker::register();

Использование

Получение UTM в любом месте сайта

php
<?php
use Local\Marketing\UtmTracker;

// Все параметры
$allUtm = UtmTracker::getAll();
print_r($allUtm);

// Конкретный параметр
$source = UtmTracker::get('utm_source');
echo "Источник: " . $source;

Добавление UTM в заказ

php
<?php
// Обработчик события создания заказа
AddEventHandler('sale', 'OnSaleOrderBeforeSaved', function ($event) {
    $order = $event->getParameter('ENTITY');
    
    $utmString = \Local\Marketing\UtmTracker::getForOrder();
    
    if (!empty($utmString)) {
        $order->setField('USER_DESCRIPTION', $utmString);
        // Или сохраняем в свойство заказа
    }
});

Передача в форму обратной связи

php
<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Ключевое словокупить диван
yclidID клика Яндекс.Директ12345678
gclidID клика Google AdsEAIaIQ...

Почему данные не сходятся с рекламным кабинетом

Несколько причин расхождения, которые обнаруживаются уже после внедрения.

Композит и кэширование. Если на сайте включён композитный режим, первую страницу отдаёт nginx напрямую из файла — PHP не запускается, OnBeforeProlog не срабатывает, метки не сохраняются. Ровно для того посетителя, который пришёл по рекламе и открыл одну страницу. Решается сохранением меток на стороне JavaScript либо исключением URL с параметрами из композита.

Блокировщики и режимы приватности. Часть посетителей приходит с заблокированными куками; для них источник не определится никогда. Это нормальный фон, а не поломка, — важно понимать порядок величины, чтобы не искать баг там, где его нет.

Разные окна атрибуции. Яндекс.Директ по умолчанию считает конверсии в своём окне, ваша кука живёт свои 30 дней. Числа не обязаны совпадать, и сравнивать их нужно с учётом настроек с обеих сторон.

yclid и gclid важнее UTM. Идентификатор клика позволяет сопоставить заявку с конкретным кликом в кабинете, чего UTM-метки не дают в принципе. Если задача — считать окупаемость рекламы, а не «примерно понимать источник», сохранять нужно в первую очередь их.

Итоги

Определитесь с моделью атрибуции до написания кода и храните оба набора меток — first и last.

Фильтруйте значения на входе: ограничение длины и белый список символов. Ссылку с меткой формирует кто угодно.

Проверьте, работает ли обработчик при включённом кэшировании страниц. На композите обработчик просто не выполнится для первого визита — того самого, который вам и нужен.

Сохраняйте yclid/gclid и адрес страницы входа, а не только UTM: первое даёт связь с кликом в кабинете, второе — понимание, куда именно вела реклама.

💡 Совет

Про время жизни куки: его задаёт окно конверсии вашего бизнеса, а не привычка. 30 дней для интернет-магазина разумны, для B2B с циклом сделки в квартал — бесполезны: заявка придёт, когда кука уже истечёт, и источник потеряется.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.