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. Вони дивляться свій профіль, чужі, перезавантажують, лишають коментарі — і кожен перегляд сторінки тихо викидається. З точки зору дашборда ці користувачі ніколи не користувалися сайтом.

Сторонній інструмент не поділяє цей exclude-список, тому бачить їх. Це частина різниці у 5–10×.

Виправлення: точна рівність для /up; segment-anchored regex для namespace-префіксів — /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 — ні. Без цих спек наступного, хто рефакторитиме exclude-список, спіткне той самий клас бага.

Бонусне прибирання: 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 провалюється, два візити бекфіляться.

Виправлення: перейти на 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-дашборд кілька днів ніхто не переглядав, почепіть поряд другий годинник. Що більший розрив, то більше знайдете.