Free

Lass Claude die Lügen in deiner Eigenbau-Analytics korrigieren

Eigenbau-Admin-Zahlen um 5-10× daneben: `server_side_visits`-Inflation, ungleiche Nenner, `start_with?` schluckt Vanity URLs


Ich habe ein Admin-Dashboard mit Ahoy für das Tracking. Ich hatte mich ursprünglich für Ahoy entschieden, weil ich alles in Rails haben wollte und keine externe Abhängigkeit hinzufügen wollte. Später habe ich daneben ein Drittanbieter-Tool gehängt (Simple Analytics, Plausible, was auch immer — der Punkt war, einen Vergleich zu haben).

Eines Tages habe ich beide Tabellen nebeneinander angeschaut und gesehen, dass sich die Unique-Visitor-Zahlen um den Faktor 5 bis 10 unterscheiden. Erste Reaktion: Das Drittanbieter-Tool verliert Daten. Ich habe Claude gebeten, einen Blick darauf zu werfen. Es stellte sich heraus, dass mein eigenes Dashboard nicht aus einem Grund log — es waren drei verschiedene Bugs übereinander gestapelt. Dieser Beitrag handelt von diesen drei Fallen.

Falle 1: server_side_visits = true

Ahoy trackt standardmäßig im Browser per JS. Ich hatte server_side_visits = true aktiviert, weil ich Requests einfangen wollte, die kein JS ausführen — Bots, API-Calls, cURL.

Was ich nicht eingepreist hatte: mit diesem Flag aktiv erzeugt jede Controller-Action eine Visit-Zeile.

  • Ein User kommt rein, liest zwei Artikel (2 Pageviews), löst einen Like-API (JSON) aus, bekommt einen Redirect zur Sign-in-Seite und zurück, plus eine Turbo-Frame-Sub-Request, die die nächste Seite lazy nachlädt. Reale Visit-Zeilen-Anzahl: locker 6–10.
  • "Unique Visitors" war früher Visit.distinct.count(:visitor_token). Diese Zahl war um den Faktor 5–10 aufgebläht.

Das Drittanbieter-Tool zählt nur Visits, die ein echtes $view-Event ausgelöst haben, also wurde dort nie etwas aufgebläht.

Der Fix ist nicht kompliziert, aber der Rahmen muss sich ändern: Die Headline-Zahl darf nicht die Gesamt-Visits nehmen, sondern muss "Visits, die mindestens einen Pageview erzeugt haben" nehmen. Die übrigen API- / Redirect- / Turbo-Frame-Sub-Requests landen in einem separaten Eimer, noise_visits_count — sie sind keine Besucher, aber echter Traffic, und sie sichtbar zu machen ist nützlich.

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

# Headline zählt nur das hier
unique_visitors = Ahoy::Visit.where(id: visits_with_any_pageview).distinct.count(:visitor_token)
noise_visits_count = total_visits - visits_with_any_pageview.size

Falle 2: bounce_rate und engaged_rate summieren sich nie zu 100%

Dieser Bug wirkt subtiler, aber er macht den Rücken kalt.

bounce_rate: Visits, die nur eine Seite gesehen haben / Visits, die mindestens eine Seite gesehen haben.
engaged_rate: Visits mit ≥2 Pageviews oder Aufenthalt >30s / Total Visits.

Zwei verschiedene Nenner.

Intuitiv sollten die beiden komplementär sein — ein Visit ist entweder Bounce oder Engaged, beide Anteile sollten sich zu ~100% summieren. Das Dashboard zeigte aber Bounce 47% + Engaged 31% = 78%. Wo waren die restlichen 22%?

Im Eimer "Visits ohne Pageview" aus Falle 1. Sie landeten im Nenner von engaged_rate (Total Visits), wurden aber aus dem Nenner von bounce_rate (Pageview-Visits) ausgeschlossen.

Fix: Beide Nenner auf visits_with_any_pageview ausrichten. Dann summieren sich die beiden Raten wirklich zu ~100%, und das verriegelt man per 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)

Ich hatte das Dashboard tagelang angeschaut und nie bemerkt, dass diese beiden Zahlen sich nicht zu 100% summierten — weil ich sie immer einzeln betrachtete. Wer sie nebeneinander gestellt hat, war Claude, beim Bauen einer Admin-Overview-Komponente, und es fragte mich: "Warum sind diese beiden Raten nicht komplementär?"

Falle 3: start_with?("/up") lässt ganze Nutzer leise verschwinden

Das ist die absurdeste.

In config/initializers/ahoy.rb gibt es ein Ahoy.exclude_method, das Pfade rausfiltert, die ich nicht zählen will — Health Checks, Assets, Admin, interne Jobs. Ursprünglich:

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" ist der Health-Check-Endpoint von Rails 8. start_with?("/up") sieht okay aus — bis einem einfällt, dass die Site Vanity-URL-Profile hat: Die Home jedes Nutzers ist /<username>.

Heißt:

  • /update (der User) wird ausgeschlossen — beginnt mit /up
  • /administrator wird ausgeschlossen
  • /jobsworth wird ausgeschlossen
  • /cabletv wird ausgeschlossen
  • /ahoyo würde, wenn der User existiert, auch ausgeschlossen

Die Pageviews dieser User landen nie in Ahoy. Sie sehen ihr Profil an, das anderer, refreshen, hinterlassen Kommentare, und jeder Seitenaufruf wird leise verworfen. Aus Sicht des Dashboards haben diese User die Site nie benutzt.

Das Drittanbieter-Tool teilt diese Exclude-Liste nicht und sieht sie deswegen. Das erklärt einen Teil des 5–10×-Deltas.

Fix: Exakte Gleichheit für /up, segment-anchored Regex für die Namespace-Präfixe — /admin/ ausgeschlossen, /administrator nicht.

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

Spec bekommt Pfad-Grenzfälle: /up ausgeschlossen, /update nicht; /admin/foo ausgeschlossen, /administrator nicht. Ohne diese Specs läuft der Nächste, der die Exclude-Liste refactort, in dieselbe Bug-Klasse.

Bonus-Aufräumen: Sekunden-Grenz-Dedup im Backfill-Rake

Nicht das Hauptgericht, aber dasselbe PR hat es mitsaniert.

Ein Rake-Task befüllt historische Sign-in-Events nachträglich als Visits. Die Dedup-Logik steckte Timestamps via Time#to_i in einen Sekunden-Eimer:

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

Problem: Der User klickt zweimal schnell auf Sign-in. Die beiden Timestamps fallen auf 12:34:56.9 und 12:34:57.1. Nach to_i werden daraus 56 und 57 — kein Match, Dedup schlägt fehl, zwei Visits werden befüllt.

Fix: Auf ein 1-Sekunden-Zeitfenster umstellen:

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

Hat nebenbei auch ein flakiges Spec gefixt, das vom zufälligen Sub-Sekunden-Offset von 1.hour.ago abhing.

Die eigentliche Lehre: Du brauchst eine zweite Uhr

Wenn du nur auf eine Uhr schaust, kann die falsche Zahl ewig falsch bleiben.

Ich hatte mein Admin-Dashboard tagelang angesehen und nicht bemerkt, dass "Unique Visitors" 5–10× über der echten Zahl lag — weil mir die Referenz fehlte. In dem Moment, in dem ich das Drittanbieter-Tool danebengehängt habe, tauchte aus dem Nichts eine neue Frage auf: "Warum ist die Differenz so groß?". Die konkrete Marke des Drittanbieter-Tools ist egal — Simple Analytics, Plausible, irgendwas, das Unique Visitors und Pageviews liefert, reicht. Sein Zweck ist Kalibrierung, nicht Ersatz für deinen eigenen Tracker.

Speziell für Ahoy, wo man sowohl Browser-seitig als auch server-seitig tracken kann, sind die drei Fallen, in die ich getappt bin, alle hier:

  1. Mit server_side_visits = true muss die Headline-Zahl "Visits, die einen Pageview produziert haben" zählen, nicht die Gesamt-Visits.
  2. Verhältnisse wie Bounce / Engaged müssen ihre Nenner explizit angleichen — schreib das Spec: expect(bounce + engaged).to be_within(1.0).of(100).
  3. start_with? in exclude_method setzt voraus, dass du keine Vanity URLs hast. Hast du sie, dann auf exakten Vergleich plus segment-anchored Regex umstellen.

Selbst gehostete Analytics ist ungewöhnlich anfällig für Bestätigungsfehler — du hast den Tracker geschrieben, du vertraust dem Tracker. Claude die Drittanbieterzahlen ziehen und vergleichen zu lassen, hat alle drei Fehler gemeinsam ans Licht gespült.

Wenn dein Admin-Dashboard seit ein paar Tagen nicht überprüft wurde, häng eine zweite Uhr daneben. Je größer die Lücke, desto mehr findest du.