Free

Laissez Claude corriger les mensonges de votre analytics maison

Chiffres d'un admin maison décalés de 5-10× : inflation de `server_side_visits`, dénominateurs non alignés, `start_with?` avale les vanity URLs


J'ai un dashboard d'admin avec Ahoy pour le tracking. J'avais choisi Ahoy au départ parce que je voulais tout dans Rails et que je ne voulais pas ajouter de dépendance externe. Plus tard, j'ai accroché à côté un outil tiers (Simple Analytics, Plausible, n'importe lequel — l'idée était d'avoir un comparateur).

Un jour, j'ai regardé les deux tableaux côte à côte et j'ai vu que les visiteurs uniques différaient d'un facteur 5 à 10. Première réaction : l'outil tiers loupe des données. J'ai demandé à Claude d'y jeter un œil. Il s'est avéré que mon propre dashboard ne mentait pas pour une seule raison — c'étaient trois bugs différents empilés les uns sur les autres. Cet article est sur ces trois pièges.

Piège 1 : server_side_visits = true

Ahoy fait par défaut du tracking côté navigateur en JS. J'avais activé server_side_visits = true parce que je voulais capter les requêtes qui ne lancent pas de JS — bots, appels API, cURL.

Ce que je n'avais pas anticipé : avec ce flag, chaque controller action crée une ligne Visit.

  • Un utilisateur entre, lit deux articles (2 pageviews), déclenche un like via API (JSON), reçoit une redirection vers sign in et revient, plus une sub-request Turbo Frame qui charge en lazy la page suivante. Nombre réel de lignes Visit : facilement 6 à 10.
  • "Visiteurs uniques" valait Visit.distinct.count(:visitor_token). Ce nombre était gonflé d'un facteur 5–10 par rapport à la vérité.

L'outil tiers ne compte que les visites qui ont déclenché un événement $view réel, donc il n'a jamais gonflé.

Le correctif n'est pas compliqué, mais il faut changer le cadrage : le chiffre headline ne peut pas se baser sur le total des Visits, il doit se baser sur "Visits ayant produit au moins un pageview". Le reste — API / redirects / sub-requests Turbo Frame — va dans un seau séparé, noise_visits_count — ce ne sont pas des visiteurs, mais c'est du trafic réel, et l'afficher est utile.

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

# headline ne compte que ceci
unique_visitors = Ahoy::Visit.where(id: visits_with_any_pageview).distinct.count(:visitor_token)
noise_visits_count = total_visits - visits_with_any_pageview.size

Piège 2 : bounce_rate et engaged_rate ne font jamais 100%

Ce bug paraît plus subtil, mais c'est celui qui glace le dos.

bounce_rate : visites qui n'ont vu qu'une page / visites qui ont vu au moins une page.
engaged_rate : visites avec ≥2 pageviews ou durée >30s / total des visites.

Deux dénominateurs différents.

Intuitivement, les deux devraient être complémentaires — une visite est soit bounce, soit engaged, donc la somme devrait approcher 100%. Le dashboard affichait bounce 47% + engaged 31% = 78%. Où sont passés les 22% manquants ?

Dans le seau "Visits sans aucun pageview" du Piège 1. Elles tombent dans le dénominateur de engaged_rate (total des visites) mais sont exclues du dénominateur de bounce_rate (pageview-visits).

Correctif : aligner les deux dénominateurs sur visits_with_any_pageview. Les deux taux somment alors vraiment ~100%, et on verrouille ça par 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)

Je regardais le dashboard depuis des jours sans m'apercevoir que ces deux nombres ne sommaient pas à 100% — parce que je les regardais toujours séparément. Celui qui les a mis côte à côte, c'est Claude, en branchant un composant overview admin, et il m'a demandé : "pourquoi ces deux taux ne sont-ils pas complémentaires ?"

Piège 3 : start_with?("/up") engloutit silencieusement des utilisateurs entiers

Celui-là est le plus absurde.

config/initializers/ahoy.rb contient un Ahoy.exclude_method qui filtre les chemins que je ne veux pas compter — health checks, assets, admin, jobs internes. Version d'origine :

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" est l'endpoint de health check de Rails 8. start_with?("/up") semble correct — jusqu'à ce qu'on se rappelle que le site a des profils utilisateur en vanity URL : la home de chaque utilisateur est /<username>.

C'est-à-dire :

  • /update (l'utilisateur) est exclu — commence par /up
  • /administrator est exclu
  • /jobsworth est exclu
  • /cabletv est exclu
  • /ahoyo le serait aussi, si cet utilisateur existait

Les pageviews de ces utilisateurs n'entrent jamais dans Ahoy. Ils consultent leur profil, celui des autres, rafraîchissent, laissent des commentaires, et chaque hit de page est silencieusement jeté. Du point de vue du dashboard, ces utilisateurs n'ont jamais utilisé le site.

L'outil tiers ne partage pas cette liste d'exclusion, donc il les voyait. Cela explique une partie du delta 5–10×.

Correctif : égalité stricte pour /up ; regex ancrée par segment pour les préfixes namespace — /admin/ exclu mais /administrator non.

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

Le spec récupère des cas limites de chemin : /up exclu mais pas /update, /admin/foo exclu mais pas /administrator. Sans ces specs, la prochaine personne qui refactorise la liste d'exclusion retombera sur la même classe de bug.

Nettoyage bonus : dedup à la frontière de seconde du rake de backfill

Pas le plat principal, mais le même PR a aussi nettoyé ça.

Un rake task fait du backfill d'événements sign-in historiques en visites. Sa logique de dedup mettait les timestamps dans un seau par seconde via Time#to_i :

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

Problème : l'utilisateur double-clique sur sign-in. Les deux timestamps tombent à 12:34:56.9 et 12:34:57.1. Après to_i, ça donne 56 et 57, ne matche pas, le dedup échoue, deux visites sont backfillées.

Correctif : passer à une fenêtre temporelle d'1 seconde :

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

Ça a aussi corrigé un spec flaky qui dépendait de l'offset sub-seconde aléatoire de 1.hour.ago.

La vraie leçon : il faut une seconde horloge

Si vous ne regardez qu'une horloge, le mauvais nombre peut rester mauvais éternellement.

Je regardais mon dashboard d'admin depuis des jours sans remarquer que les "visiteurs uniques" valaient 5–10× le vrai chiffre — parce que je n'avais pas de référence. Au moment où j'ai accroché l'outil tiers à côté, une nouvelle question est sortie de nulle part : "pourquoi cet écart est-il aussi grand ?". La marque concrète de l'outil tiers n'a aucune importance — Simple Analytics, Plausible, n'importe lequel donnant visiteurs uniques et pageviews fait l'affaire. Sa fonction est de calibrer, pas de remplacer votre tracking maison.

Pour Ahoy spécifiquement, où l'on peut faire du tracking côté navigateur ET côté serveur, les trois pièges qui m'ont eu sont tous ici :

  1. Avec server_side_visits = true, le chiffre headline doit compter "Visits ayant produit un pageview", pas le total des Visits.
  2. Les ratios type bounce / engaged doivent aligner explicitement leurs dénominateurs — écrivez le spec : expect(bounce + engaged).to be_within(1.0).of(100).
  3. start_with? dans exclude_method suppose que vous n'avez pas de vanity URLs. Si vous en avez, passez à l'égalité stricte plus regex ancrée par segment.

L'analytics auto-hébergé est singulièrement vulnérable au biais de confirmation — vous avez écrit le tracker, vous faites confiance au tracker. Faire tirer à Claude les chiffres tiers et les comparer a fait remonter les trois erreurs en même temps.

Si votre dashboard d'admin n'a pas été audité depuis quelques jours, accrochez une seconde horloge à côté. Plus l'écart est grand, plus vous trouverez de choses.