Реклама откручивается, заявки идут, а откуда именно пришёл человек — непонятно. В письме только имя и телефон. Ставится задача «прокинуть UTM-метки в заявку», разработчик добавляет скрытые поля — и метки всё равно не приходят.
Разберу, почему так происходит и что действительно нужно сделать. Речь про самописные формы на чистом HTML и JS, без плагинов вроде WPForms или Contact Form 7 — у них свои механизмы, там всё проще.
Это главная причина, из-за которой задача проваливается, и проверять её нужно до того, как писать хоть строчку JavaScript.
Скрытое поле в форме — это просто ещё одна пара «ключ-значение» в POST-запросе. Попадёт ли она в письмо, зависит от того, как письмо собирает серверный обработчик.
Если он перебирает всё, что пришло, — новые поля появятся в письме сами:
$body = '';
foreach ($_POST as $key => $value) {
$body .= htmlspecialchars($key) . ': ' . htmlspecialchars($value) . "\n";
}
А если поля перечислены руками — а так написано большинство обработчиков, — то никакие скрытые инпуты в письмо не попадут, сколько их ни добавляй:
$body = "Имя: {$_POST['name']}\n"
. "Телефон: {$_POST['phone']}\n"
. "Сообщение: {$_POST['message']}\n";
Отсюда два сценария:
Второй вариант выглядит грубо, но он работает всегда и не требует доступа к бэкенду. Метки просто оказываются внизу текста заявки:
Перезвоните после 18:00
--- источник: yandex / cpc / avgust_remont / yclid=7712... / cid=17419...
Допустим, с письмом разобрались. Дальше вы вешаете обработчик на submit, создаёте скрытые поля — а в POST их всё равно нет.
Причина в том, что форму отправляет не браузер, а JavaScript. И если код собирает данные вручную, скрытые поля он просто не видит:
// метки сюда не попадут никогда
form.addEventListener('submit', async (e) => {
e.preventDefault();
await fetch('/send.php', {
method: 'POST',
body: JSON.stringify({
name: form.name.value,
phone: form.phone.value
})
});
});
В браузере поля есть, в DevTools они видны, а в запрос уходят три перечисленных ключа — и всё.
Как отличить один случай от другого. Откройте вкладку Network, отправьте тестовую заявку и посмотрите payload запроса. Если там ровно те поля, что вы ожидали, и ничего лишнего — данные собираются вручную. Если весь набор полей формы — используется FormData(form), и скрытые поля сработают.
Когда сборка ручная, скрытые поля бесполезны, и правильное место — обёртка над fetch, которая дописывает метки в тело запроса:
const origFetch = window.fetch;
window.fetch = function (url, opts = {}) {
if (opts.method && opts.method.toUpperCase() === 'POST' && isFormEndpoint(url)) {
opts.body = appendMarks(opts.body);
}
return origFetch.call(this, url, opts);
};
appendMarks разбирает тело — JSON, FormData или строку application/x-www-form-urlencoded — и добавляет туда метки. Для старых сайтов на jQuery то же самое делается через $.ajaxPrefilter.
Классическое решение — записать UTM в cookie на 365 дней. С этим есть проблема, о которой обычно узнают постфактум.
В Safari cookie, поставленная из JavaScript, живёт 7 суток. Это Intelligent Tracking Prevention, и обойти его нельзя: срок в 365 дней, который вы указали, браузер молча сократит. На мобильном трафике из iOS это заметная часть аудитории.
Поэтому дублируем в localStorage — на него ограничение не распространяется, — и читаем оттуда, если cookie уже нет:
const KEYS = ['utm_source','utm_medium','utm_campaign','utm_content','utm_term','yclid'];
const TTL_DAYS = 365;
function save(name, value) {
const d = new Date();
d.setTime(d.getTime() + TTL_DAYS * 864e5);
document.cookie = `${name}=${encodeURIComponent(value)};expires=${d.toUTCString()};path=/;SameSite=Lax`;
try { localStorage.setItem(name, value); } catch (e) {}
}
function read(name) {
const m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
if (m) return decodeURIComponent(m[1]);
try { return localStorage.getItem(name) || ''; } catch (e) { return ''; }
}
Частая ошибка: скрипт при каждой загрузке страницы перезаписывает все ключи тем, что нашёл в адресе. Человек пришёл по объявлению, через неделю зашёл напрямую и оставил заявку — источник потерян, заявка засчитана как «прямой заход».
Записываем только тогда, когда в адресе действительно есть метки:
const params = new URLSearchParams(location.search);
const hasNew = KEYS.some(k => params.get(k));
if (hasNew) KEYS.forEach(k => { if (params.get(k)) save(k, params.get(k)); });
Дальше вопрос модели атрибуции: оставлять первый источник или перезаписывать последним. Универсального ответа нет, но по умолчанию я беру последний непрямой заход — так считает большинство рекламных систем, и цифры в отчётах сходятся.
_ym_uid ставит счётчик Метрики, а не ваш скрипт. Если пользователь отправил форму в первые секунды после загрузки, счётчик может ещё не отработать, и в заявку уйдёт пустое значение.
Поэтому читаем с коротким повтором и не блокируем отправку, если значения так и не дождались:
function waitYm(timeout = 3000, step = 300) {
return new Promise(resolve => {
const t0 = Date.now();
(function tick() {
const v = read('_ym_uid');
if (v || Date.now() - t0 > timeout) return resolve(v || '');
setTimeout(tick, step);
})();
});
}
Тот же принцип с yclid: он приходит в адресе только при переходе по объявлению, и если его нет — это нормально, а не повод показывать ошибку.
Модалки, попапы и формы, подгружаемые аяксом, к моменту работы скрипта в DOM не существуют. Перебор document.querySelectorAll('form') на DOMContentLoaded их не увидит.
Решение — не перебирать формы, а слушать событие на уровне документа в фазе перехвата:
document.addEventListener('submit', onSubmit, true);
Так обработчик поймает любую форму, включая добавленные позже, и сработает раньше обработчиков самой формы — а значит, успеет добавить поля до того, как код страницы соберёт данные.
Проверять надо не глазами по коду, а реальными заявками:
?utm_source=test&utm_medium=cpc&utm_campaign=check1&yclid=123.localStorage.Пять писем, пять минут, и никаких сюрпризов через месяц, когда по отчётам окажется, что 80 % заявок «прямые».
Чаще всего письмо в PHP собирается по жёсткому списку полей: имя, телефон, сообщение. Новое скрытое поле в этот список не входит и в письмо не попадает, хотя браузер его отправил. Проверяется за минуту: временно собрать тело письма циклом по всему $_POST и отправить заявку самому себе.
Форма уходит не обычным submit, а через fetch с телом, собранным руками из перечисленных полей. Скрытые поля в такое тело не попадают никогда, сколько их ни добавляй в разметку. Смотреть надо не на форму, а на код отправки.
Safari сокращает срок куки, поставленной из JavaScript, до семи суток. Для сделок с долгим циклом метку надо дублировать — в localStorage или в куку, выставленную сервером.
Открыть страницу с формой, дописав в адрес ?utm_source=proverka&utm_medium=test, и отправить заявку самому себе. Если метки нет в письме — дело не в рекламе и не в подрядчике по трафику.
Разбираться самому некогда? Сделаю под ключ: один js-файл в футер, работает со всеми формами, с проверкой тестовыми заявками и показом результата в письмах.
Написать в Telegram @vvs35_bot