Цифры самодельного admin искажены в 5-10×: раздувание `server_side_visits`, несовпадающие знаменатели, `start_with?` глотает vanity URL
У меня есть admin-дашборд с трекингом на Ahoy. Изначально я выбрал Ahoy, потому что хотел всё внутри Rails и не хотел плодить внешнюю зависимость. Позже я подвесил рядом инструмент стороннего разработчика (Simple Analytics, Plausible, что угодно — нужен был просто компаратор).
В какой-то день я смотрел на оба табло бок о бок и увидел, что число уникальных посетителей расходится в 5–10 раз. Первая инстинктивная реакция: сторонний инструмент теряет данные. Я попросил Claude мельком взглянуть. Оказалось, что мой собственный дашборд врёт не по одной причине — это были три разных бага, наслоившихся друг на друга. Этот пост — про эти три ловушки.
server_side_visits = trueПо умолчанию Ahoy трекает на стороне браузера через JS. Я включил server_side_visits = true, потому что хотел ловить и запросы, не запускающие JS — ботов, API, cURL.
Чего я не учёл: с этим флагом каждый controller action создаёт строку Visit.
Visit.distinct.count(:visitor_token). Это число было раздуто в 5–10 раз против истины.Сторонний инструмент считает только визиты, которые сгенерировали реальное событие $view, поэтому никогда не раздувался.
Фикс не сложен, но рамку надо менять: число в заголовке нельзя считать по общему количеству Visits, нужно считать по «Visits, давшие хотя бы один pageview». Оставшиеся API / редиректы / sub-request от Turbo Frame идут в отдельный кошёлек, noise_visits_count — это не посетители, но это реальный трафик, и показывать его полезно.
visits_with_any_pageview = Ahoy::Event
.where(name: "$view", time: range)
.joins(:visit)
.distinct
.pluck("ahoy_visits.id")
# заголовок считает только это
unique_visitors = Ahoy::Visit.where(id: visits_with_any_pageview).distinct.count(:visitor_token)
noise_visits_count = total_visits - visits_with_any_pageview.size
bounce_rate и engaged_rate никогда не дают в сумме 100%Этот баг кажется тоньше, но именно от него мурашки.
bounce_rate: визиты, посмотревшие только одну страницу / визиты, посмотревшие хотя бы одну страницу.
engaged_rate: визиты с ≥2 pageview или временем >30 сек / общие визиты.
Два разных знаменателя.
Интуитивно эти две доли должны быть взаимодополняющими — визит либо bounce, либо engaged, в сумме ~100%. Дашборд показывал bounce 47% + engaged 31% = 78%. Куда делись оставшиеся 22%?
В кошёлек «Visits без pageview» из Ловушки 1. Они попали в знаменатель engaged_rate (общие визиты), но были исключены из знаменателя bounce_rate (pageview-visits).
Фикс: выровнять оба знаменателя на visits_with_any_pageview. Тогда два процента действительно дают в сумме ~100%, и это закрепляется спецой.
denominator = visits_with_any_pageview.size
bounce_rate = bounced_visits.to_f / denominator * 100
engaged_rate = engaged_visits.to_f / denominator * 100
# spec: expect(bounce_rate + engaged_rate).to be_within(1.0).of(100)
Я смотрел на дашборд несколько дней и ни разу не заметил, что эти два числа не дают в сумме 100% — потому что всегда смотрел на них раздельно. Тот, кто поставил их рядом, — это Claude, когда собирал admin-overview-компонент, и он спросил: «почему эти два процента не комплементарны?»
start_with?("/up") молча выбрасывает целых пользователейЭто самая нелепая.
В config/initializers/ahoy.rb есть Ahoy.exclude_method, который фильтрует пути, которые я не хочу считать — health check, assets, admin, внутренние jobs. Изначально:
Ahoy.exclude_method = lambda do |controller, request|
path = request.path
path.start_with?("/rails/", "/cable", "/jobs", "/up", "/assets", "/admin", "/ahoy") ||
path.end_with?(".json", ".xml", ".gz") ||
path.start_with?("/sitemap")
end
"/up" — endpoint health check в Rails 8. start_with?("/up") выглядит нормально — пока не вспомнишь, что у сайта есть vanity-URL-профили: домашняя страница каждого пользователя — /<username>.
Из этого следует:
/update (пользователь) исключается — начинается с /up/administrator исключается/jobsworth исключается/cabletv исключается/ahoyo тоже исключился бы, если бы такой пользователь существовалPageview этих пользователей в Ahoy не попадают вообще. Они смотрят свой профиль, чужие, перезагружают, оставляют комментарии, и каждый просмотр страницы молча выбрасывается. С точки зрения дашборда эти пользователи никогда не пользовались сайтом.
У стороннего инструмента такого списка исключений нет, поэтому он их видит. Это — часть разрыва в 5–10×.
Фикс: точное равенство для /up; для namespace-префиксов — segment-anchored regex, чтобы /admin/ исключался, а /administrator — нет.
NAMESPACE_PREFIX_PATTERN = %r{\A/(cable|jobs|assets|admin|ahoy)(/|\z)}.freeze
Ahoy.exclude_method = lambda do |controller, request|
path = request.path
path == "/up" ||
path.start_with?("/rails/", "/sitemap") ||
NAMESPACE_PREFIX_PATTERN.match?(path) ||
path.end_with?(".json", ".xml", ".gz")
end
В спеце добавляются граничные случаи путей: /up исключается, а /update — нет; /admin/foo исключается, а /administrator — нет. Без этих спек следующий, кто будет рефакторить список исключений, споткнётся на той же категории багов.
Не главное блюдо, но тот же PR заодно почистил.
Есть rake-задача, которая бэкфилит исторические события sign-in как визиты. Логика dedup укладывала таймстампы в посекундный кошёлек через Time#to_i:
existing_buckets = existing_visits.map { |v| v.started_at.to_i }
return if existing_buckets.include?(time.to_i)
Проблема: пользователь делает двойной клик по sign-in. Два таймстампа выпадают на 12:34:56.9 и 12:34:57.1. После to_i это 56 и 57 — не совпадают, dedup ломается, два визита пишутся как backfill.
Фикс: переключиться на 1-секундное временное окно:
return if existing_visits.any? { |v| (v.started_at - time).abs < 1.0 }
Заодно починился флаковый спец, зависевший от случайного субсекундного смещения 1.hour.ago.
Если вы смотрите на одни часы, неправильное число может оставаться неправильным вечно.
Я смотрел на свой admin-дашборд несколько дней и не замечал, что «уникальные посетители» в 5–10 раз больше реального числа — потому что у меня не было ориентира. В тот момент, когда я подвесил рядом сторонний инструмент, из ниоткуда возник вопрос: «почему такая большая разница?». Конкретный бренд стороннего инструмента не имеет значения — Simple Analytics, Plausible, всё, что выдаёт уникальных посетителей и pageview, подойдёт. Его задача — калибровать, а не заменять ваш собственный трекер.
Конкретно для Ahoy, где можно делать и браузерный, и server-side трекинг одновременно, все три ловушки, в которые я провалился, — здесь:
server_side_visits = true число в заголовке должно считать «Visits, давшие pageview», а не общее количество Visits.expect(bounce + engaged).to be_within(1.0).of(100).start_with? внутри exclude_method неявно предполагает, что у вас нет vanity-URL. Если они есть — переходите к точному равенству плюс segment-anchored regex.Самохостинговая аналитика необычно уязвима к подтверждающему сдвигу — трекер написали вы, трекеру вы доверяете. Дать Claude вытащить сторонние числа и сравнить их — и три разные ошибки всплыли вместе.
Если ваш admin-дашборд несколько дней никто не пересматривал, повесьте рядом вторые часы. Чем больше разрыв, тем больше вы найдёте.