Внешний счётчик ставится за пять минут, и обычно этого достаточно. Но у модели «сторонний скрипт с чужого домена» есть технический потолок, и на магазине я в него упёрся: запрос к третьему домену режут блокировщики и антитрекинг браузеров, на объёме включается сэмплирование, cookie тянет за собой баннер согласия (а все, кто его отклонил, выпадают из статистики целиком), и главное – поведение ваших покупателей лежит в чужой системе, рядом с которой нет ваших заказов.
Ниже – как устроен свой cookieless-трекинг в двух проектах на Next.js: интернет-магазин (Next.js 15.5, TypeScript, Prisma 6, PostgreSQL) и маркетплейс услуг (тот же Next.js, но pg без ORM). Код в статье – из работающих проектов, с теми решениями, которые оказались неочевидными, и с теми, которые пришлось переделать.
Сразу оговорка: это не «Метрика – зло». Для контентного сайта внешний счётчик закрывает вопрос целиком. Разговор начинается там, где аналитика – часть продукта.
Идентификатор, которого нет
Главный вопрос своей аналитики – не «куда писать события», а «как отличить одного посетителя от другого, ничего не записав в браузер и не сохранив персональных данных».
Ответ, который используют privacy-first счётчики и который в итоге стоит у меня: не хранить идентификатор вообще, а каждый раз вычислять его заново – как HMAC от того, что и так пришло в HTTP-запросе, с календарным днём внутри подписываемой строки.
import { createHmac } from "node:crypto";const HASH_KEY = process.env.AUTH_SECRET ?? "fallback-key";const MSK_OFFSET_MS = 3 * 60 * 60 * 1000;/** `yyyy-mm-dd` календарного дня по MSK для момента времени. */function mskDateStamp(now: Date): string { return new Date(now.getTime() + MSK_OFFSET_MS).toISOString().slice(0, 10);}/** Анонимный, ротирующийся по дням visitor-хэш (32 hex-символа). */export function dailyVisitorHash(ip: string, userAgent: string, now: Date): string { return createHmac("sha256", HASH_KEY) .update(`${mskDateStamp(now)}|${ip}|${userAgent}`) .digest("hex") .slice(0, 32);}
Что здесь важно, кроме самого хеша:
День берётся по своему часовому поясу, а не по UTC. Иначе «уникальные за сегодня» перещёлкиваются в 3 часа ночи по Москве, и вечерний трафик разрезается на две части. Пять строк кода, которые иначе тихо портят все дневные срезы.
День внутри подписываемой строки, а не в ключе. Практическое следствие: сшить посетителя между днями нельзя by design, «уникальные» считаются в пределах суток. Честная плата за это – ниже, в ограничениях.
Обрезка до 32 символов. 128 бит на анонимный дневной счётчик достаточно, а колонка и индекс по ней вдвое меньше.
IP и User-Agent в базу не попадают. Они живут в памяти процесса ровно на время вычисления хеша.
Что писать в базу
Схема события – ровно то, из чего строятся отчёты, и ничего «на всякий случай»:
model PageView { id String @id @default(cuid()) path String visitorHash String referrerHost String? source String @default("direct") campaign String? device String @default("desktop") country String? createdAt DateTime @default(now()) @@index([createdAt]) @@index([path, createdAt]) @@index([visitorHash, createdAt]) @@index([source, createdAt]) @@index([campaign, createdAt])}
От referrer-а остаётся только хост: в query-строке приезжает мусор и иногда чужие персональные данные, а для отчёта «откуда пришли» хост исчерпывающ. Индексы все составные с createdAt, потому что любой запрос в аналитике – это «что-то за период», и одиночный индекс по path в таком плане бесполезен.
Дальше – метки кампаний. Поле campaign заполняется из utm_campaign, то есть из адресной строки, то есть кем угодно. Без фильтра туда можно насыпать произвольных строк и раздуть кардинальность колонки, по которой стоит индекс:
export function normaliseCampaign(raw: string | null | undefined): string | null { if (!raw) return null; const cleaned = raw.trim().toLowerCase().slice(0, 64); return /^[a-z0-9._-]+$/.test(cleaned) ? cleaned : null;}
Почему beacon, а не middleware
Интуитивно правильный ход в Next.js – считать визиты в middleware: серверно, вырезать нечем. На App Router это работает плохо. Через middleware проходят не только переходы человека: там же RSC-запросы, префетч ссылок при наведении, повторные запросы на ревалидацию. Считая всё это, вы получаете красивый растущий график, который не имеет отношения к тому, что кто-то смотрел страницу.
Поэтому событие шлёт клиент – одним sendBeacon на смену маршрута, а сервер по этому событию считает идентификатор. Разделение выходит такое: что произошло, знает клиент, кто это сделал – решает только сервер.
"use client";export function PageviewTracker() { const pathname = usePathname(); const lastSent = useRef<string | null>(null); const isFirstView = useRef(true); useEffect(() => { if (!pathname || ADMIN_PATH.test(pathname) || doNotTrackEnabled()) return; if (lastSent.current === pathname) return; // На первой странице визита referrer настоящий, дальше SPA-навигация // его не меняет — подставляем предыдущий свой путь сами. const referrer = isFirstView.current ? document.referrer : `${window.location.origin}${lastSent.current ?? ""}`; // Метку берём только на первой странице визита: она отвечает на вопрос // «откуда пришли», а не «где ходили». Иначе один переход из письма, // после которого человек полистал каталог, засчитался бы как десяток. const campaign = isFirstView.current ? (new URLSearchParams(window.location.search).get("utm_campaign") ?? undefined) : undefined; lastSent.current = pathname; isFirstView.current = false; const body = JSON.stringify({ path: pathname, referrer, campaign }); try { if (navigator.sendBeacon) { navigator.sendBeacon("/api/track", new Blob([body], { type: "application/json" })); } else { void fetch("/api/track", { method: "POST", body, keepalive: true, headers: { "content-type": "application/json" } }); } } catch { // Capture is best-effort — never disrupt navigation. } }, [pathname]); return null;}
Два момента, которые всплыли на практике. Referrer в SPA живёт только до первой клиентской навигации – дальше document.referrer остаётся тем же самым, и без ручной подстановки предыдущего пути внутренние переходы выглядят как повторные входы из поиска. И sendBeacon вместо fetch – не из-за производительности, а потому что он доживает до конца выгрузки страницы: событие с последней страницы перед уходом иначе теряется.
Тут же честная граница метода. Это клиентский JS, и «нечего блокировать» – преувеличение: с выключенным JS визит не посчитается. Что реально меняется – запрос идёт на свой домен, /api/track, и в фильтрах блокировщиков его нет, потому что фильтры составлены по третьим доменам. Мимо проходит не «слепая зона неизвестного размера», а понятная и небольшая доля.
Приёмник: всё, что может пойти не так, идёт в 204
export async function POST(request: Request) { // Respect Do-Not-Track at the edge — skip entirely, store nothing. if (request.headers.get("dnt") === "1") return NO_CONTENT; const limited = await withRateLimit(request, "track", { limit: 120, windowMs: 60_000 }); if (limited) return limited; const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NO_CONTENT; const path = normalisePath(parsed.data.path); if (!isTrackablePath(path)) return NO_CONTENT; // ... await recordPageView({ /* ip, userAgent, ownHost, country из заголовков */ }); return NO_CONTENT;}
Правила приёмника простые и стоят того, чтобы их назвать:
-
DNT: 1– выходим сразу, до всего остального. Проверка есть и на клиенте, но полагаться на клиент в таком вопросе неправильно. -
Публичный beacon без авторизации – это открытая ручка на запись в вашу БД, поэтому rate-limit по IP обязателен.
-
Ответ всегда 204, ошибки БД глушатся. Аналитика не имеет права ронять страницу или мешать навигации.
-
Админка и
/api/*в подсчёт не идут – иначе собственная работа в админке становится вашим самым популярным разделом.
Классификация – три regex-а по User-Agent и хосту: бот / устройство / источник. Ботов режем до вставки (иначе краулеры выглядят как трафик, и в этом году их особенно много), устройство и источник пишем в строку. Один неочевидный приоритет в классификации источника:
// Метка важнее referrer: почтовые клиенты его либо режут (тогда переход// выглядел бы «прямым»), либо подставляют свой веб-интерфейс (тогда// письмо засчиталось бы в «поиск» — mail.ru совпадает с шаблоном// поисковиков). Метку ставим мы сами, и она однозначна.if (campaign) return "email";if (!referrerHost) return "direct";
Определение источника по referrer-у ломается именно на почте, и это не редкий случай, а весь email-трафик целиком.
Тот же механизм в маркетплейсе: воронка для партнёра
Во втором проекте – маркетплейс услуг – тот же дневной HMAC (портирован файлом, только на pg вместо Prisma) обслуживает другую задачу: партнёр видит в своём кабинете воронку из четырёх шагов – показы карточек в листингах → просмотры → уникальные визиты → заявки, с конверсией между шагами.
Показ фиксируется через IntersectionObserver при видимости не менее половины карточки, с дедупом пары (компания, карточка) на сессию вкладки, чтобы прокрутка туда-сюда не накручивала счётчик. Транспорт тот же sendBeacon.
И здесь – самая полезная ошибка из всей этой истории. В первой версии visitorId присылал клиент. Так проще: у клиента уже есть sessionStorage, генерируй туда UUID и слай. Проблема в том, что цифры из этой воронки партнёр видит – а значит, у стороны, которая может подделать поле, появляется мотив его подделать: накрутить себе показы и просмотры ничего не стоит. Клиентский visitorId из API убрали, теперь он не принимается совсем, visitor_id считается на сервере тем же дневным HMAC, а у ботов остаётся NULL, чтобы не попадать в уникальных.
Правило после этого простое: если по метрике кто-то отчитывается или за неё платит, её идентификатор не может приходить из браузера.
Где на самом деле появляется преимущество
Ожидаемый ответ – «сджойнить pageview с заказами». На практике самое ценное строится вообще без журнала визитов. Товарная аналитика в магазине собрана из данных, которые лежат в БД и так: серверная копия корзины, заказы, запросы «сообщить о поступлении».
-
Воронка корзина → заказ – сколько корзин с товаром создано и сколько из них превратилось в заказ, за день/неделю/месяц. Это состояние в базе, а не цепочка событий, поэтому цифра не зависит от того, доехал ли beacon.
-
Брошенные корзины в рублях – не «сколько корзин брошено», а сколько денег в них стоит. Порог простой: корзина активна и час без движения (тот же порог, что у первого письма о брошенной корзине – одно определение на аналитику и на рассылку, иначе цифры в отчёте и в письмах разъезжаются).
-
Бестселлеры и продажи по категориям – в единицах и в выручке, по оплаченным заказам.
-
Неудовлетворённый спрос – запросы «сообщить о поступлении» по товарам, которых нет в наличии. Прямой список того, что стоит привезти.
-
Повторные покупки – доля покупателей с двумя и более оплаченными заказами. Гостевые заказы из этого расчёта исключены: они не атрибутируются, и включать их – значит занижать метрику непонятно на сколько.
Ни один из этих вопросов внешний счётчик закрыть не может: он знает про страницы и клики, но не знает ваш каталог, ваши статусы заказов и ваши деньги. Журнал визитов при этом нужен – он отвечает на «откуда пришли и что смотрели», – но продуктовые ответы живут в предметных таблицах. Если строите своё, начинать стоит с серверной корзины, а не с журнала событий.
Про 152-ФЗ, без юридического оптимизма
Что снимается технически: в браузер ничего не записывается, значит нет cookie – значит нет и предмета для баннера согласия под аналитику. В базе нет ни IP, ни User-Agent, ни идентификатора, живущего дольше суток.
Что остаётся и о чём стоит говорить прямо: IP до момента хеширования всё равно проходит через ваш сервер, а вопрос «является ли необратимый хеш от IP персональными данными» – предмет обсуждения, а не решённый факт. Мы убираем сам источник проблемы – хранение, – а не выдаём себе индульгенцию. Формулировки политики – всё равно к юристу.
Отдельно: sessionStorage для дедупа показов в маркетплейсе – не cookie и не идентификатор посетителя, но это клиентское хранилище, и в строгих трактовках это тоже тема для разговора. Если хотите совсем чисто – дедуп переносится на сервер по (visitor_hash, card, день) ценой лишних запросов.
Честные ограничения
-
Точность идентификации грубее cookie. За одним NAT сидят десятки людей с похожими UA, у одного человека несколько устройств. Для трендов, источников и воронок этого достаточно, для точного cross-device – нет.
-
Ключ стабильный, ротируется только дневная метка. Значит человек с доступом к секрету и к списку IP+UA технически может пересчитать хеши за прошлые дни. Полностью это закрывается случайной солью, которая генерируется на сутки и потом выбрасывается; я сознательно выбрал стабильный секрет – при выбрасываемой соли любой перезапуск процесса или второй инстанс дают разные хеши в пределах одного дня, и «уникальные» ломаются. Кому важна именно эта угроза – соль надо хранить в общем месте (Redis, таблица) и удалять на следующий день.
-
Боты отсекаются по User-Agent. Это эвристика: честные краулеры представляются, нечестные – нет.
-
Никакой экосистемы. Вебвизора, карт кликов и готовых отчётов нет – есть только то, что вы построили. Ретаргетинг и look-alike у рекламных площадок завязаны на их же счётчики: нужны аудитории – счётчик всё равно ставится, параллельно своему.
-
Это код, который надо поддерживать. Своя аналитика – часть продукта со своими багами, миграциями и ростом таблицы событий, а не «вставил скрипт и забыл».
Кому не нужно
Если сайт – блог, визитка или лендинг без транзакций, а вопрос ограничивается «сколько зашло и откуда» – ставьте внешний счётчик и не усложняйте. Своё оправдано там, где данные лежат рядом с деньгами: магазин, маркетплейс, платформа – где нужны воронки в рублях, где цифры показывают третьей стороне, и где не хочется отдавать поведение клиентов наружу.
Итог
Весь механизм – это дневной HMAC вместо идентификатора, одна таблица событий с составными индексами, beacon на смену маршрута и приёмник, который на любую проблему отвечает 204. Пара сотен строк, ноль зависимостей, ничего в браузере. Основную ценность при этом дают не события, а то, что аналитика стоит на тех же таблицах, что заказы и корзины: вопросы вида «сколько денег зависло в брошенных корзинах» решаются запросом, а не интеграцией.
ссылка на оригинал статьи https://habr.com/ru/articles/1073296/