Números del admin casero desviados 5-10×: inflación de `server_side_visits`, denominadores no alineados, `start_with?` traga vanity URLs
Tengo un panel de admin con Ahoy haciendo el tracking. Elegí Ahoy al principio porque quería que todo estuviera dentro de Rails y no quería sumar una dependencia externa. Más tarde le colgué al lado una herramienta de terceros (Simple Analytics, Plausible, la que sea — el punto era tener un comparador).
Un día miré los dos tableros lado a lado y vi que los visitantes únicos diferían 5 a 10 veces. Mi primera reacción: la herramienta de terceros se está perdiendo datos. Le pedí a Claude que les echara un vistazo. Resultó que mi propio panel no mentía por una sola razón — eran tres bugs diferentes apilados encima. Este post va de esas tres trampas.
server_side_visits = trueAhoy por defecto trackea desde el lado del navegador con JS. Yo había activado server_side_visits = true porque quería capturar peticiones que no corren JS — bots, llamadas a API, cURL.
Lo que no había calculado: con esa flag activa, cada controller action crea una fila Visit.
Visit.distinct.count(:visitor_token). Ese número estaba inflado 5–10× respecto al real.La herramienta de terceros solo cuenta visitas que dispararon un evento $view real, así que nunca se infló.
El arreglo no es complicado, pero hay que cambiar el encuadre: el número headline no puede usar el total de Visits, tiene que usar "Visits que produjeron al menos un pageview". Las API / redirects / sub-peticiones de Turbo Frame restantes van a un cubo aparte, noise_visits_count — no son visitantes, pero son tráfico real, y mostrarlas tiene sentido.
visits_with_any_pageview = Ahoy::Event
.where(name: "$view", time: range)
.joins(:visit)
.distinct
.pluck("ahoy_visits.id")
# headline solo cuenta esto
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 y engaged_rate nunca suman 100%Este bug parece más sutil, pero es el que da más escalofríos.
bounce_rate: visitas que vieron solo una página / visitas que vieron al menos una página.
engaged_rate: visitas con ≥2 pageviews o estancia >30s / visitas totales.
Dos denominadores distintos.
Intuitivamente las dos deberían ser complementarias — una visita o rebota o se compromete, así que tendrían que sumar ~100%. El panel mostraba bounce 47% + engaged 31% = 78%. ¿Adónde fue el otro 22%?
Al cubo de "Visits que no produjeron ningún pageview" de la trampa 1. Cayeron en el denominador de engaged_rate (visitas totales) pero quedaron fuera del denominador de bounce_rate (pageview-visits).
Arreglo: alinear los dos denominadores a visits_with_any_pageview. Entonces los dos ratios suman ~100%, y se fija con un 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)
Llevaba días mirando el panel y no me había dado cuenta de que esos dos números no sumaban 100% — porque siempre los miraba por separado. Quien los puso uno al lado del otro fue Claude, mientras armaba un componente de overview de admin, y me preguntó: "¿por qué estos dos ratios no son complementarios?"
start_with?("/up") se traga usuarios enteros en silencioEsta es la más absurda.
config/initializers/ahoy.rb tiene un Ahoy.exclude_method que filtra rutas que no me interesa contar — health checks, assets, admin, jobs internos. La versión original:
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" es el endpoint de health check de Rails 8. start_with?("/up") parece bien — hasta que recuerdas que el sitio tiene perfiles de usuario con vanity URL: la home de cada usuario es /<username>.
O sea:
/update (el usuario) queda excluido — empieza por /up/administrator queda excluido/jobsworth queda excluido/cabletv queda excluido/ahoyo también quedaría excluido si ese usuario existieraLos pageviews de estos usuarios no entran en Ahoy. Ven su perfil, ven el de otros, refrescan, dejan comentarios, y cada hit de página se descarta en silencio. Desde el panel, esos usuarios parecen no haber usado nunca el sitio.
La herramienta de terceros no compartía esa lista de exclusión, así que veía a esos usuarios. Eso explica una parte del delta de 5–10×.
Arreglo: igualdad exacta para /up, regex anclada por segmento para los prefijos namespace — /admin/ excluido, /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
El spec recibe casos de borde de path: /up excluido pero /update no, /admin/foo excluido pero /administrator no. Sin esos specs, la próxima vez que alguien refactorice la lista de exclusión, vuelve a tropezar con la misma clase de bug.
No es el plato fuerte, pero el mismo PR lo limpió.
Hay un rake task que rellena retroactivamente eventos de sign-in históricos como visitas. Su lógica de dedup metía timestamps en un cubo por segundo vía Time#to_i:
existing_buckets = existing_visits.map { |v| v.started_at.to_i }
return if existing_buckets.include?(time.to_i)
Problema: el usuario hace doble click en sign-in. Los dos timestamps caen en 12:34:56.9 y 12:34:57.1. Tras to_i quedan en 56 y 57, no coinciden, falla el dedup, se rellenan dos visitas.
Arreglo: cambiar a una ventana temporal de 1 segundo:
return if existing_visits.any? { |v| (v.started_at - time).abs < 1.0 }
De paso se arregló un spec que fallaba a veces porque dependía del offset sub-segundo aleatorio de 1.hour.ago.
Si solo miras un reloj, el número equivocado puede quedarse equivocado para siempre.
Llevaba días mirando mi panel de admin sin notar que "visitantes únicos" iba 5–10× por encima del real — porque no tenía referencia. Al colgar la herramienta de terceros al lado, surgió una pregunta nueva de la nada: "¿por qué esta diferencia es tan grande?". La marca concreta de la herramienta de terceros da igual — Simple Analytics, Plausible, cualquiera que te dé visitantes únicos y pageviews sirve. Su función es calibrar, no reemplazar tu propio tracking.
Para Ahoy en concreto, donde puedes hacer tracking en navegador y server-side a la vez, las tres trampas que pisé están todas aquí:
server_side_visits = true, el número headline tiene que contar "Visits que produjeron un pageview", no Visits totales.expect(bounce + engaged).to be_within(1.0).of(100).start_with? en exclude_method asume que no tienes vanity URLs. Si las tienes, cámbialo a igualdad exacta más regex anclada por segmento.El analytics autohospedado es especialmente vulnerable al sesgo de confirmación — tú escribiste el tracker, tú confías en el tracker. Dejar que Claude trajera los números de la herramienta de terceros y los comparara hizo que los tres errores aflorasen juntos.
Si tu panel de admin lleva días sin auditarse, cuélgale al lado un segundo reloj. Cuanto mayor el desfase, más cosas encontrarás.