Free

Biarkan Claude membenarkan angka bohong di analytics buatan sendiri

Angka admin buatan sendiri meleset 5-10×: inflasi `server_side_visits`, penyebut tidak selaras, `start_with?` menelan vanity URL


Saya punya admin dashboard yang tracking-nya pakai Ahoy. Awalnya pilih Ahoy karena ingin semuanya di dalam Rails dan tidak ingin nambah dependensi pihak ketiga. Belakangan saya gantungkan satu tool pihak ketiga di sebelahnya (Simple Analytics, Plausible, mana saja — intinya ada pembanding).

Suatu hari saya menatap dua tabel itu berdampingan dan melihat unique visitor berbeda 5 sampai 10 kali. Reaksi naluriah pertama: tool pihak ketiga sedang kehilangan data. Saya minta Claude meliriknya. Ternyata dashboard saya bohong bukan karena satu alasan — tiga bug berbeda menumpuk satu sama lain. Tulisan ini tentang tiga jebakan itu.

Jebakan 1: server_side_visits = true

Ahoy default-nya tracking di sisi browser via JS. Saya menyalakan server_side_visits = true karena mau menangkap request yang tidak menjalankan JS — bot, panggilan API, cURL.

Yang tidak saya hitung sebelumnya: dengan flag itu menyala, setiap controller action membuat satu baris Visit.

  • Pengguna masuk, baca dua artikel (2 pageview), pencet API like (JSON), terkena redirect ke sign in lalu balik, plus satu sub-request Turbo Frame yang lazy-load halaman berikutnya. Hitungan baris Visit nyata: santai 6–10.
  • "Unique visitor" dulunya Visit.distinct.count(:visitor_token). Angka itu kembung 5–10× dari kenyataan.

Tool pihak ketiga hanya menghitung kunjungan yang memicu event $view sungguhan, jadi tidak pernah kembung.

Perbaikannya tidak rumit, tapi framing-nya harus diubah: angka headline tidak bisa pakai total Visit, harus pakai "Visit yang menghasilkan setidaknya satu pageview". Sisanya — API / redirect / sub-request Turbo Frame — masuk ke bucket terpisah, noise_visits_count — bukan visitor, tapi traffic asli, dan menampilkannya berguna.

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

# headline cuma menghitung ini
unique_visitors = Ahoy::Visit.where(id: visits_with_any_pageview).distinct.count(:visitor_token)
noise_visits_count = total_visits - visits_with_any_pageview.size

Jebakan 2: bounce_rate dan engaged_rate tidak pernah jumlahnya 100%

Bug ini terlihat lebih halus, tapi ini yang bikin punggung dingin.

bounce_rate: kunjungan yang lihat hanya satu halaman / kunjungan yang lihat setidaknya satu halaman.
engaged_rate: kunjungan dengan ≥2 pageview atau durasi >30s / total kunjungan.

Dua penyebut yang berbeda.

Secara intuisi keduanya seharusnya komplementer — kunjungan itu bounce atau engaged, jumlahnya ~100%. Dashboard menunjukkan bounce 47% + engaged 31% = 78%. Sisa 22% itu pergi ke mana?

Ke bucket "Visit yang tidak menghasilkan pageview" dari Jebakan 1. Mereka jatuh ke penyebut engaged_rate (total kunjungan), tapi tidak masuk penyebut bounce_rate (pageview-visit).

Perbaikan: samakan dua penyebut ke visits_with_any_pageview. Maka kedua rasio benar-benar berjumlah ~100%, dan dikunci dengan 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)

Saya menatap dashboard berhari-hari tanpa menyadari bahwa dua angka ini tidak berjumlah 100% — karena saya selalu melihatnya terpisah. Yang menempatkan keduanya berdampingan adalah Claude, ketika sedang merakit komponen overview admin, dan bertanya: "kenapa dua rasio ini tidak komplementer?"

Jebakan 3: start_with?("/up") membuang pengguna seutuhnya secara senyap

Yang ini paling absurd.

config/initializers/ahoy.rb punya Ahoy.exclude_method yang menyaring path yang tidak ingin saya hitung — health check, assets, admin, jobs internal. Versi lama:

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" adalah endpoint health check Rails 8. start_with?("/up") kelihatan baik-baik saja — sampai kamu ingat bahwa situs punya profil pengguna dengan vanity URL: home setiap pengguna adalah /<username>.

Yang artinya:

  • /update (pengguna itu) di-exclude — diawali /up
  • /administrator di-exclude
  • /jobsworth di-exclude
  • /cabletv di-exclude
  • /ahoyo juga akan di-exclude jika pengguna itu ada

Pageview pengguna-pengguna ini tidak pernah masuk Ahoy. Mereka lihat profil sendiri, lihat profil orang, refresh, kasih komentar, dan tiap hit halaman dibuang senyap. Dari sudut pandang dashboard, pengguna-pengguna ini seperti tidak pernah pakai situs ini.

Tool pihak ketiga tidak berbagi daftar exclude itu, jadi bisa melihat mereka. Itu menjelaskan sebagian dari delta 5–10×.

Perbaikan: kecocokan persis untuk /up; regex segment-anchored untuk prefiks namespace — /admin/ di-exclude, /administrator tidak.

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 dapat kasus batas path: /up di-exclude tapi /update tidak, /admin/foo di-exclude tapi /administrator tidak. Tanpa spec ini, di refactor berikutnya pada daftar exclude, orang akan tergelincir di kelas bug yang sama.

Bonus pembersihan: dedup batas detik di rake backfill

Bukan hidangan utama, tapi PR yang sama membersihkannya juga.

Ada rake task yang mem-backfill event sign-in lama menjadi kunjungan. Logika dedup-nya memasukkan timestamp ke bucket per detik via Time#to_i:

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

Masalah: pengguna double-click sign-in. Dua timestamp jatuh di 12:34:56.9 dan 12:34:57.1. Setelah to_i jadi 56 dan 57, tidak cocok, dedup gagal, dua kunjungan ter-backfill.

Perbaikan: ganti ke jendela waktu 1 detik:

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

Bonus: ini juga memperbaiki spec flaky yang mengandalkan offset sub-detik acak dari 1.hour.ago.

Pelajaran sebenarnya: kamu butuh jam kedua

Kalau kamu cuma melihat satu jam, angka yang salah bisa bertahan salah selamanya.

Saya menatap admin dashboard berhari-hari tanpa menyadari bahwa "unique visitor" 5–10× lebih besar dari aslinya — karena tidak ada referensi. Saat saya gantungkan tool pihak ketiga di sebelah, muncul pertanyaan baru entah dari mana: "kenapa selisih ini sebesar ini?". Merek spesifik tool pihak ketiga itu tidak penting — Simple Analytics, Plausible, apa pun yang memberimu unique visitor dan pageview cocok. Fungsinya adalah kalibrasi, bukan pengganti tracking sendiri.

Khusus untuk Ahoy, yang bisa tracking di browser dan server-side sekaligus, tiga jebakan yang saya injak semuanya ada di sini:

  1. Setelah server_side_visits = true, angka headline harus menghitung "Visit yang menghasilkan pageview", bukan total Visit.
  2. Rasio macam bounce / engaged harus eksplisit menyamakan penyebut — tulis spec-nya: expect(bounce + engaged).to be_within(1.0).of(100).
  3. start_with? di exclude_method mengasumsikan kamu tidak punya vanity URL. Kalau punya, ganti ke kecocokan persis ditambah regex segment-anchored.

Self-hosted analytics secara aneh rentan terhadap confirmation bias — kamu yang menulis tracker-nya, kamu yang percaya tracker-nya. Membiarkan Claude menarik angka pihak ketiga dan membandingkannya membuat ketiga error tersebut muncul bersamaan.

Kalau admin dashboard kamu sudah berhari-hari tidak diaudit, gantungkan jam kedua di sebelahnya. Semakin besar gap-nya, semakin banyak yang akan kamu temukan.