Free

Пусть Claude исправит ложь в вашем самодельном analytics

Цифры самодельного admin искажены в 5-10×: раздувание `server_side_visits`, несовпадающие знаменатели, `start_with?` глотает vanity URL


У меня есть admin-дашборд с трекингом на Ahoy. Изначально я выбрал Ahoy, потому что хотел всё внутри Rails и не хотел плодить внешнюю зависимость. Позже я подвесил рядом инструмент стороннего разработчика (Simple Analytics, Plausible, что угодно — нужен был просто компаратор).

В какой-то день я смотрел на оба табло бок о бок и увидел, что число уникальных посетителей расходится в 5–10 раз. Первая инстинктивная реакция: сторонний инструмент теряет данные. Я попросил Claude мельком взглянуть. Оказалось, что мой собственный дашборд врёт не по одной причине — это были три разных бага, наслоившихся друг на друга. Этот пост — про эти три ловушки.

Ловушка 1: server_side_visits = true

По умолчанию Ahoy трекает на стороне браузера через JS. Я включил server_side_visits = true, потому что хотел ловить и запросы, не запускающие JS — ботов, API, cURL.

Чего я не учёл: с этим флагом каждый controller action создаёт строку Visit.

  • Пользователь заходит, читает две статьи (2 pageview), нажимает like-API (JSON), получает редирект на sign in и возвращается, плюс sub-request от Turbo Frame, который лениво подгружает следующую страницу. Реальное количество строк Visit: спокойно 6–10.
  • «Уникальные посетители» раньше = 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

Ловушка 2: 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-компонент, и он спросил: «почему эти два процента не комплементарны?»

Ловушка 3: 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 — нет. Без этих спек следующий, кто будет рефакторить список исключений, споткнётся на той же категории багов.

Бонусная уборка: dedup на границе секунды в rake-backfill

Не главное блюдо, но тот же 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 трекинг одновременно, все три ловушки, в которые я провалился, — здесь:

  1. После server_side_visits = true число в заголовке должно считать «Visits, давшие pageview», а не общее количество Visits.
  2. Соотношения вроде bounce / engaged должны иметь явно выровненные знаменатели — пишите спец: expect(bounce + engaged).to be_within(1.0).of(100).
  3. start_with? внутри exclude_method неявно предполагает, что у вас нет vanity-URL. Если они есть — переходите к точному равенству плюс segment-anchored regex.

Самохостинговая аналитика необычно уязвима к подтверждающему сдвигу — трекер написали вы, трекеру вы доверяете. Дать Claude вытащить сторонние числа и сравнить их — и три разные ошибки всплыли вместе.

Если ваш admin-дашборд несколько дней никто не пересматривал, повесьте рядом вторые часы. Чем больше разрыв, тем больше вы найдёте.