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.
server_side_visits = trueDomyś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.
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
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?"
start_with?("/up") po cichu wywala całych użytkownikówTa 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.
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.
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:
server_side_visits = true numer headline musi liczyć „Visits, które wygenerowały pageview", a nie wszystkie Visits.expect(bounce + engaged).to be_within(1.0).of(100).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.