Numeri di admin fatto in casa sbagliati di 5-10×: inflazione di `server_side_visits`, denominatori non allineati, `start_with?` ingoia vanity URL
Ho una dashboard di admin con il tracking fatto in casa via Ahoy. All'inizio avevo scelto Ahoy perché volevo tutto dentro Rails e non volevo aggiungere una dipendenza esterna. In seguito ho appeso accanto uno strumento di terze parti (Simple Analytics, Plausible, uno qualsiasi — il punto era avere un metro di paragone).
Un giorno ho fissato i due cruscotti affiancati e ho visto che i visitatori unici differivano di 5-10 volte. Prima reazione istintiva: lo strumento di terze parti sta perdendo dati. Ho chiesto a Claude di darci un'occhiata. È venuto fuori che la mia dashboard non mentiva per un solo motivo — erano tre bug diversi accatastati. Questo post parla di quelle tre trappole.
server_side_visits = trueAhoy traccia di default lato browser via JS. Avevo attivato server_side_visits = true perché volevo prendere anche le request che non eseguono JS — bot, chiamate API, cURL.
Quello che non avevo messo in conto: con quel flag attivo, ogni controller action crea una riga Visit.
Visit.distinct.count(:visitor_token). Quel numero era gonfiato di 5-10× rispetto al vero.Lo strumento di terze parti conta solo le visite che hanno generato un evento $view reale, quindi non si è mai gonfiato.
Il fix non è complicato, ma bisogna cambiare l'inquadratura: il numero di headline non può usare il totale Visits, deve usare "Visits che hanno prodotto almeno un pageview". Le restanti API / redirect / sub-request Turbo Frame finiscono in un secchio separato, noise_visits_count — non sono visitatori, ma è traffico vero, e mostrarlo è utile.
visits_with_any_pageview = Ahoy::Event
.where(name: "$view", time: range)
.joins(:visit)
.distinct
.pluck("ahoy_visits.id")
# headline conta solo questo
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 e engaged_rate non fanno mai 100%Questo bug sembra più sottile, ma è quello che fa raggelare la schiena.
bounce_rate: visite che hanno visto solo una pagina / visite che hanno visto almeno una pagina.
engaged_rate: visite con ≥2 pageview o permanenza >30s / visite totali.
Due denominatori diversi.
Intuitivamente i due dovrebbero essere complementari — una visita o rimbalza o si ingaggia, dovrebbero sommare a ~100%. La dashboard mostrava bounce 47% + engaged 31% = 78%. Dove era finito il restante 22%?
Nel secchio "Visits senza pageview" della Trappola 1. Erano cadute nel denominatore di engaged_rate (visite totali) ma erano fuori dal denominatore di bounce_rate (pageview-visit).
Fix: allineare i due denominatori a visits_with_any_pageview. A quel punto i due rapporti sommano davvero a ~100%, e si blocca la cosa con uno spec.
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)
Guardavo la dashboard da giorni e non mi ero accorto che quei due numeri non sommavano a 100% — perché li guardavo sempre separati. Quello che li ha messi affiancati è stato Claude, mentre montava un componente di overview admin, e mi ha chiesto: "perché questi due rapporti non sono complementari?"
start_with?("/up") ingoia silenziosamente interi utentiQuesta è la più assurda.
config/initializers/ahoy.rb ha un Ahoy.exclude_method che filtra i path che non voglio contare — health check, assets, admin, jobs interni. Originale:
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" è l'endpoint di health check di Rails 8. start_with?("/up") sembra a posto — finché non ti ricordi che il sito ha profili con vanity URL: la home di ogni utente è /<username>.
Quindi:
/update (l'utente) viene escluso — comincia per /up/administrator viene escluso/jobsworth viene escluso/cabletv viene escluso/ahoyo lo sarebbe pure, se quell'utente esistesseLe pageview di questi utenti non entrano in Ahoy. Vedono il proprio profilo, vedono quello degli altri, fanno refresh, lasciano commenti, e ogni hit di pagina viene scartato in silenzio. Dal punto di vista della dashboard, questi utenti non hanno mai usato il sito.
Lo strumento di terze parti non condivide quella lista di esclusione, quindi li vede. Questo spiega una parte del delta 5-10×.
Fix: uguaglianza esatta per /up; regex ancorata per segmento per i prefissi namespace — /admin/ escluso, /administrator no.
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
Lo spec riceve casi limite di path: /up escluso ma /update no, /admin/foo escluso ma /administrator no. Senza questi spec, la prossima volta che qualcuno refattora la lista di esclusione ricasca nella stessa classe di bug.
Non è il piatto principale, ma lo stesso PR ha sistemato anche questo.
C'è un rake task che fa il backfill di eventi storici di sign-in come visite. La logica di dedup metteva i timestamp in un secchio per secondo via Time#to_i:
existing_buckets = existing_visits.map { |v| v.started_at.to_i }
return if existing_buckets.include?(time.to_i)
Problema: l'utente fa doppio click su sign-in. I due timestamp cadono a 12:34:56.9 e 12:34:57.1. Dopo to_i diventano 56 e 57, non combaciano, il dedup fallisce, due visite vengono backfillate.
Fix: passare a una finestra temporale di 1 secondo:
return if existing_visits.any? { |v| (v.started_at - time).abs < 1.0 }
Di rimbalzo ha sistemato anche uno spec flaky che dipendeva dall'offset sub-secondo random di 1.hour.ago.
Se guardi un solo orologio, il numero sbagliato può rimanere sbagliato per sempre.
Guardavo la mia dashboard di admin da giorni senza accorgermi che "visitatori unici" stava 5-10× sopra il vero — perché non avevo riferimento. Nel momento in cui ho appeso lo strumento di terze parti accanto, è spuntata dal nulla una domanda: "perché questa differenza è così grande?". La marca specifica dello strumento di terze parti non importa — Simple Analytics, Plausible, qualsiasi cosa che ti dia visitatori unici e pageview va bene. Il suo scopo è calibrare, non sostituire il tuo tracking.
Per Ahoy nello specifico, dove puoi tracciare sia lato browser sia server-side, le tre trappole in cui sono caduto sono tutte qui:
server_side_visits = true, il numero di headline deve contare "Visits che hanno prodotto un pageview", non Visits totali.expect(bounce + engaged).to be_within(1.0).of(100).start_with? in exclude_method assume che tu non abbia vanity URL. Se le hai, passa a uguaglianza esatta più regex ancorata per segmento.L'analytics auto-ospitata è insolitamente vulnerabile al bias di conferma — il tracker l'hai scritto tu, il tracker te lo fidi. Far tirare a Claude i numeri di terze parti e confrontarli ha fatto venire a galla i tre errori insieme.
Se la tua dashboard di admin è da qualche giorno che non viene auditata, appendile accanto un secondo orologio. Più grande è il gap, più cose troverai.