Free

Deixe o Claude consertar as mentiras do seu analytics caseiro

Números do admin caseiro desviados 5-10×: inflação de `server_side_visits`, denominadores desalinhados, `start_with?` engole vanity URLs


Tenho um painel de admin com Ahoy fazendo o tracking. Escolhi o Ahoy no início porque queria tudo dentro do Rails e não queria adicionar uma dependência externa. Depois pendurei do lado uma ferramenta de terceiros (Simple Analytics, Plausible, qualquer uma — o ponto era ter um comparador).

Um dia olhei os dois painéis lado a lado e notei que os visitantes únicos diferiam em 5 a 10 vezes. Minha primeira reação: a ferramenta de terceiros está perdendo dados. Pedi pro Claude dar uma olhada. Acabou que meu próprio painel não mentia por um único motivo — eram três bugs diferentes empilhados em cima. Esse post é sobre essas três armadilhas.

Armadilha 1: server_side_visits = true

O Ahoy por padrão faz tracking pelo lado do navegador via JS. Eu tinha ligado server_side_visits = true porque queria capturar requests que não rodam JS — bots, chamadas de API, cURL.

O que eu não tinha calculado: com essa flag ligada, toda controller action cria uma linha Visit.

  • Um usuário entra, lê duas matérias (2 pageviews), aciona uma like API (JSON), recebe um redirect pra sign in e volta, mais uma sub-request de Turbo Frame que carrega lazy a próxima página. Contagem real de linhas Visit: tranquilo 6 a 10.
  • "Visitantes únicos" antes era Visit.distinct.count(:visitor_token). Esse número estava inflado em 5–10× sobre o real.

A ferramenta de terceiros só conta visitas que dispararam um evento $view real, então nunca inflou.

O fix não é complicado, mas a moldura tem que mudar: o número headline não pode usar Visits totais, tem que usar "Visits que produziram pelo menos um pageview". As demais APIs / redirects / sub-requests de Turbo Frame vão pra um bucket separado, noise_visits_count — não são visitantes, mas são tráfego real, e expor isso é útil.

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

# headline conta só esses
unique_visitors = Ahoy::Visit.where(id: visits_with_any_pageview).distinct.count(:visitor_token)
noise_visits_count = total_visits - visits_with_any_pageview.size

Armadilha 2: bounce_rate e engaged_rate nunca somam 100%

Esse bug parece mais sutil, mas é o que dá mais arrepio.

bounce_rate: visitas que viram só uma página / visitas que viram pelo menos uma página.
engaged_rate: visitas com ≥2 pageviews ou estadia >30s / visitas totais.

Dois denominadores diferentes.

Intuitivamente os dois deveriam ser complementares — uma visita ou bounce ou engaged, deveriam somar ~100%. O painel mostrava bounce 47% + engaged 31% = 78%. Pra onde foram os outros 22%?

Foi pro bucket de "Visits que não produziram nenhum pageview" da Armadilha 1. Caíram no denominador de engaged_rate (visitas totais), mas ficaram fora do denominador de bounce_rate (pageview-visits).

Fix: alinhar os dois denominadores em visits_with_any_pageview. Os dois ratios passam a somar ~100% de verdade, e a gente trava isso num 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)

Eu tava olhando o painel há dias e nunca tinha reparado que esses dois números não somavam 100% — porque eu sempre olhava cada um separado. Quem colocou os dois lado a lado foi o Claude, enquanto montava um componente de overview de admin, e me perguntou: "por que esses dois ratios não são complementares?"

Armadilha 3: start_with?("/up") engole usuários inteiros em silêncio

Essa é a mais absurda.

config/initializers/ahoy.rb tem um Ahoy.exclude_method que filtra rotas que não quero contar — health checks, assets, admin, jobs internos. A versão 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" é o endpoint de health check do Rails 8. start_with?("/up") parece bem — até você lembrar que o site tem perfis de usuário com vanity URL: a home de cada usuário é /<username>.

Ou seja:

  • /update (o usuário) fica excluído — começa com /up
  • /administrator fica excluído
  • /jobsworth fica excluído
  • /cabletv fica excluído
  • /ahoyo também ficaria, se esse usuário existisse

Os pageviews desses usuários não entram no Ahoy. Eles veem o próprio perfil, veem o dos outros, dão refresh, deixam comentário, e todo hit de página é descartado em silêncio. Da perspectiva do painel, esses usuários nunca usaram o site.

A ferramenta de terceiros não compartilhava essa lista de exclusão, então enxergava esses usuários. Isso responde por uma parte do delta de 5–10×.

Fix: igualdade exata pra /up, regex ancorada por segmento pros prefixos namespace — /admin/ excluído, /administrator não.

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

O spec ganha casos de borda de path: /up excluído mas /update não, /admin/foo excluído mas /administrator não. Sem esses specs, na próxima vez que alguém refatorar a lista de exclusão, cai na mesma classe de bug.

Limpeza bônus: dedup na fronteira de segundos do rake de backfill

Não é o prato principal, mas o mesmo PR limpou isso.

Tem um rake task que faz backfill de eventos históricos de sign-in como visitas. A lógica de dedup colocava timestamps num bucket por segundo via Time#to_i:

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

Problema: o usuário dá double-click no sign-in. Os dois timestamps caem em 12:34:56.9 e 12:34:57.1. Depois de to_i, viram 56 e 57, não batem, dedup falha, duas visitas são feitas.

Fix: trocar pra uma janela de tempo de 1 segundo:

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

De quebra, consertou um spec flaky que dependia do offset sub-segundo aleatório do 1.hour.ago.

A lição de verdade: você precisa de um segundo relógio

Se você só olha pra um relógio, o número errado pode ficar errado pra sempre.

Eu tava olhando meu painel de admin há dias sem perceber que "visitantes únicos" estava 5–10× acima do real — porque não tinha referência. No momento que pendurei a ferramenta de terceiros do lado, apareceu do nada uma pergunta: "por que essa diferença é tão grande?". A marca específica da ferramenta de terceiros não importa — Simple Analytics, Plausible, qualquer coisa que te dê visitantes únicos e pageviews resolve. A função dela é calibrar, não substituir seu próprio tracking.

Pro Ahoy especificamente, onde dá pra fazer tracking pelo navegador e server-side, as três armadilhas que eu pisei estão todas aqui:

  1. Com server_side_visits = true, o número headline tem que contar "Visits que produziram pageview", não Visits totais.
  2. Ratios tipo bounce / engaged precisam ter os denominadores alinhados explicitamente — escreva o spec: expect(bounce + engaged).to be_within(1.0).of(100).
  3. start_with? em exclude_method assume que você não tem vanity URLs. Se tem, troque por igualdade exata mais regex ancorada por segmento.

Analytics auto-hospedado é particularmente vulnerável a viés de confirmação — você escreveu o tracker, você confia no tracker. Deixar o Claude puxar os números da ferramenta de terceiros e comparar fez os três erros aflorarem juntos.

Se o seu painel de admin tá há dias sem alguém revisar, pendura um segundo relógio do lado. Quanto maior o gap, mais coisa você encontra.