Free

Pozwól Claude'owi poprawić kłamstwa w twoim domowym analytics

Liczby domowego admina rozjeżdżają się 5-10×: napompowane `server_side_visits`, niezgodne mianowniki, `start_with?` połyka vanity URL


Mam panel admina z trackingiem własnej roboty, oparty o Ahoy. Wybrałem Ahoy na początku, bo chciałem mieć wszystko w Railsach i nie chciałem dorzucać zewnętrznej zależności. Później doczepiłem obok narzędzie firmy trzeciej (Simple Analytics, Plausible, którekolwiek — chodziło o porównanie).

Pewnego dnia spojrzałem na obie tabele obok siebie i zobaczyłem, że unikalni odwiedzający różnią się 5 do 10 razy. Pierwsza odruchowa reakcja: narzędzie zewnętrzne traci dane. Poprosiłem Claude'a, żeby rzucił okiem. Okazało się, że mój własny panel kłamie nie z jednego powodu — to były trzy różne bugi nałożone na siebie. Ten wpis jest o tych trzech pułapkach.

Pułapka 1: server_side_visits = true

Domyślnie Ahoy trackuje po stronie przeglądarki przez JS. Włączyłem server_side_visits = true, bo chciałem łapać też requesty, które nie odpalają JS — boty, wywołania API, cURL.

Czego nie wkalkulowałem: z tym flagiem włączonym każda controller action tworzy wiersz Visit.

  • Użytkownik wchodzi, czyta dwa artykuły (2 pageview), klika like (API JSON), dostaje redirect na sign in i wraca, dochodzi sub-request Turbo Frame leniwie ładujący kolejną stronę. Realna liczba wierszy Visit: spokojnie 6–10.
  • „Unikalni odwiedzający" liczyło się jako Visit.distinct.count(:visitor_token). Ten numer był rozdmuchany 5–10× ponad prawdę.

Narzędzie firmy trzeciej liczy tylko wizyty, które wygenerowały realne zdarzenie $view, więc nigdy się nie rozdmuchało.

Fix nie jest skomplikowany, ale trzeba zmienić ramę: numer headline nie może opierać się na całkowitej liczbie Visits, musi opierać się na „Visits, które wygenerowały co najmniej jeden pageview". Pozostałe API / redirects / sub-requesty Turbo Frame trafiają do osobnego kubełka, noise_visits_count — to nie odwiedzający, ale realny ruch, więc warto pokazywać osobno.

visits_with_any_pageview = Ahoy::Event
  .where(name: "$view", time: range)
  .joins(:visit)
  .distinct
  .pluck("ahoy_visits.id")

# headline liczy tylko to
unique_visitors = Ahoy::Visit.where(id: visits_with_any_pageview).distinct.count(:visitor_token)
noise_visits_count = total_visits - visits_with_any_pageview.size

Pułapka 2: bounce_rate i engaged_rate nigdy nie sumują się do 100%

Ten bug wygląda subtelniej, ale właśnie od niego mrowi po krzyżu.

bounce_rate: wizyty, które zobaczyły tylko jedną stronę / wizyty, które zobaczyły co najmniej jedną stronę.
engaged_rate: wizyty z ≥2 pageview albo czasem >30 s / wizyty łącznie.

Dwa różne mianowniki.

Intuicyjnie te dwie wartości powinny być uzupełnieniem — wizyta jest albo bounce, albo engaged, więc razem ~100%. Panel pokazywał bounce 47% + engaged 31% = 78%. Gdzie poszło pozostałe 22%?

Do kubełka „Visits bez żadnego pageview" z Pułapki 1. Wpadły do mianownika engaged_rate (wizyty łącznie), ale nie weszły do mianownika bounce_rate (pageview-visits).

Fix: wyrównać oba mianowniki na visits_with_any_pageview. Wtedy obie proporcje naprawdę sumują się ~100%, a my zamykamy to specem.

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)

Patrzyłem na panel od kilku dni i ani razu nie zauważyłem, że te dwie liczby nie sumują się do 100% — bo zawsze patrzyłem na nie osobno. Tym, kto postawił je obok siebie, był Claude, podczas składania komponentu admin-overview, i zapytał mnie: „dlaczego te dwa wskaźniki nie są dopełniające?"

Pułapka 3: start_with?("/up") po cichu wywala całych użytkowników

Ta jest najbardziej absurdalna.

W config/initializers/ahoy.rb jest Ahoy.exclude_method, który filtruje ścieżki, których nie chcę zliczać — health checks, assets, admin, wewnętrzne jobs. Wersja oryginalna:

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" to endpoint health check w Rails 8. start_with?("/up") wygląda OK — dopóki nie przypomnisz sobie, że strona ma profile użytkowników z vanity URL: home każdego użytkownika to /<username>.

Czyli:

  • /update (użytkownik) jest wykluczany — zaczyna się od /up
  • /administrator jest wykluczany
  • /jobsworth jest wykluczany
  • /cabletv jest wykluczany
  • /ahoyo też by był, gdyby ten użytkownik istniał

Pageview tych użytkowników w ogóle nie wchodzi do Ahoy. Patrzą na swój profil, na cudze, odświeżają, zostawiają komentarze, a każde wejście na stronę po cichu się wyrzuca. Z perspektywy panelu ci użytkownicy nigdy nie korzystali ze strony.

Narzędzie firmy trzeciej nie współdzieli tej listy wykluczeń, więc ich widziało. To tłumaczy część różnicy 5–10×.

Fix: dokładna równość dla /up; regex zakotwiczony na segmentach dla prefiksów namespace — /admin/ wykluczany, ale /administrator nie.

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

W speku dorzucamy przypadki graniczne ścieżek: /up wykluczony, ale /update nie; /admin/foo wykluczony, ale /administrator nie. Bez tych speców kolejna osoba, która zrefaktoruje listę wykluczeń, wpadnie w tę samą klasę bugów.

Bonusowe sprzątanie: dedup na granicy sekundy w rake'u od backfillu

To nie danie główne, ale ten sam PR przy okazji to posprzątał.

Jest rake task, który backfillem zamienia historyczne zdarzenia sign-in na wizyty. Logika dedup wkładała timestampy do kubełka po sekundach przez Time#to_i:

existing_buckets = existing_visits.map { |v| v.started_at.to_i }
return if existing_buckets.include?(time.to_i)

Problem: użytkownik klika sign-in dwa razy szybko (double-click). Dwa timestampy wpadają w 12:34:56.9 i 12:34:57.1. Po to_i to 56 i 57, nie pasują, dedup zawodzi, dwie wizyty są backfillowane.

Fix: przejść na 1-sekundowe okno czasowe:

return if existing_visits.any? { |v| (v.started_at - time).abs < 1.0 }

Przy okazji naprawiło to flaky speca, który zależał od losowego sub-sekundowego offsetu 1.hour.ago.

Prawdziwa lekcja: potrzebujesz drugiego zegara

Jeśli patrzysz tylko na jeden zegar, błędna liczba może zostać błędna na zawsze.

Patrzyłem na swój panel admina od kilku dni i nie zauważyłem, że „unikalni odwiedzający" są 5–10× powyżej prawdziwej liczby — bo nie miałem punktu odniesienia. W chwili, gdy doczepiłem narzędzie firmy trzeciej obok, znikąd wyskoczyło pytanie: „dlaczego ta różnica jest tak duża?". Konkretna marka narzędzia firmy trzeciej nie ma znaczenia — Simple Analytics, Plausible, cokolwiek, co poda ci unikalnych odwiedzających i pageview, się nada. Jego rola to kalibracja, nie zastąpienie własnego trackingu.

Konkretnie dla Ahoy, gdzie da się trackować zarówno po stronie przeglądarki, jak i serwera, trzy pułapki, w które wszedłem, są tu wszystkie:

  1. Po server_side_visits = true numer headline musi liczyć „Visits, które wygenerowały pageview", a nie wszystkie Visits.
  2. Wskaźniki typu bounce / engaged muszą mieć jawnie wyrównane mianowniki — pisz speca: expect(bounce + engaged).to be_within(1.0).of(100).
  3. start_with? w exclude_method zakłada milcząco, że nie masz vanity URL-i. Jeśli masz — przejdź na dokładną równość plus regex zakotwiczony na segmentach.

Self-hosted analytics jest niezwykle podatne na confirmation bias — to ty napisałeś tracker, to ty mu ufasz. Pozwolenie Claude'owi pociągnąć liczby firmy trzeciej i porównać sprawiło, że wszystkie trzy błędy wypłynęły razem.

Jeśli twojego panelu admina nikt od kilku dni nie audytował, doczep mu obok drugi zegar. Im większa luka, tym więcej znajdziesz.