Free

Kendi yazdığınız analytics'in yalanlarını Claude'a düzelttirin

Kendi yazdığım admin sayıları 5-10× sapıyor: `server_side_visits` şişmesi, paydalar uyumsuz, `start_with?` vanity URL'leri yutuyor


Ahoy ile kendi tracking'imi yapan bir admin paneli var. İlk başta Ahoy'u seçtim çünkü her şey Rails içinde olsun, üçüncü taraf bağımlılığı eklemek istemiyordum. Sonra yanına bir üçüncü taraf araç astım (Simple Analytics, Plausible, hangisi olursa — amaç sadece bir karşılaştırma noktası).

Bir gün iki tabloyu yan yana izledim ve eşsiz ziyaretçi sayısının 5-10 kat farklı olduğunu gördüm. İlk içgüdüsel tepkim: üçüncü taraf araç veri kaçırıyor olmalı. Claude'dan bir göz atmasını istedim. Anlaşıldı ki kendi panelim tek bir nedenle yalan söylemiyor — üst üste binmiş üç farklı bug vardı. Bu yazı o üç tuzakla ilgili.

Tuzak 1: server_side_visits = true

Ahoy varsayılan olarak tarayıcı tarafında JS ile takip eder. Ben server_side_visits = true'yi açmıştım çünkü JS çalıştırmayan istekleri (botlar, API çağrıları, cURL) yakalamak istiyordum.

Hesaplayamadığım bedel: bu flag açıkken her controller action bir Visit satırı oluşturur.

  • Bir kullanıcı giriyor, iki makale okuyor (2 pageview), bir like API tetikliyor (JSON), oturum açma sayfasına yönlendirme oluyor ve geri dönüyor, bir de Turbo Frame'in lazy yüklediği sub-request var. Gerçek Visit satır sayısı: rahatlıkla 6-10.
  • "Eşsiz ziyaretçi" eskiden Visit.distinct.count(:visitor_token) idi. Bu sayı gerçek değerin 5-10 katına şişmişti.

Üçüncü taraf araç sadece gerçek bir $view olayı tetikleyen ziyaretleri sayar, dolayısıyla hiç şişmedi.

Düzeltme karmaşık değil ama çerçeveyi değiştirmek gerekiyor: headline sayısı toplam Visit kullanamaz, "en az bir pageview üreten Visit"i kullanmak zorunda. Geri kalan API / redirect / Turbo Frame sub-request'leri ayrı bir kovaya gider, noise_visits_count — onlar ziyaretçi değil ama gerçek trafik, ayrı göstermek faydalı.

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

# headline sadece bunu sayar
unique_visitors = Ahoy::Visit.where(id: visits_with_any_pageview).distinct.count(:visitor_token)
noise_visits_count = total_visits - visits_with_any_pageview.size

Tuzak 2: bounce_rate ve engaged_rate asla %100'e tamamlanmaz

Bu bug daha hafif görünüyor ama daha çok ürperten bu.

bounce_rate: yalnızca bir sayfa görüp ayrılan ziyaretler / en az bir sayfa gören ziyaretler.
engaged_rate: ≥2 pageview veya >30s kalan ziyaretler / toplam ziyaretler.

İki farklı payda.

Sezgisel olarak ikisi tümleyen olmalı — ziyaret ya bounce'tur ya engaged, toplamı ~%100 olmalı. Panel ise bounce %47 + engaged %31 = %78 gösteriyordu. Kalan %22 nereye gitti?

Tuzak 1'deki "pageview üretmemiş Visit" kovasına gitti. engaged_rate'in paydasına (toplam ziyaret) düştüler ama bounce_rate'in paydasından (pageview-visit) dışlandılar.

Düzeltme: her iki paydayı visits_with_any_pageview olarak hizala. O zaman iki oran gerçekten ~%100'e tamamlanır ve bunu spec ile sabitle.

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)

Paneli günlerdir izliyordum ve bu iki sayının %100'e tamamlanmadığını fark etmemiştim — çünkü hep ayrı ayrı bakıyordum. İkisini yan yana koyan kişi Claude oldu, bir admin overview bileşeni kurarken bana sordu: "neden bu iki oran tümleyen değil?"

Tuzak 3: start_with?("/up") kullanıcıları sessizce komple düşürüyor

Bu en absürd olanı.

config/initializers/ahoy.rb içinde saymak istemediğim path'leri filtreleyen bir Ahoy.exclude_method var — health check, assets, admin, dahili jobs. Orijinal hali:

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" Rails 8'in health check endpoint'i. start_with?("/up") sorunsuz görünüyor — ta ki sitenin vanity username profilleri olduğunu hatırlayana kadar: her kullanıcının ana sayfası /<username>.

Yani:

  • /update (kullanıcı) dışlanıyor — /up ile başlıyor
  • /administrator dışlanıyor
  • /jobsworth dışlanıyor
  • /cabletv dışlanıyor
  • /ahoyo da, eğer o kullanıcı varsa dışlanırdı

Bu kullanıcıların pageview'leri Ahoy'a hiç girmiyor. Kendi profillerini görseler, başkalarınınkini görseler, refresh atsalar, yorum bıraksalar — her sayfa hit'i sessizce çöpe atılıyor. Panel açısından bu kullanıcılar siteyi hiç kullanmamış gibi görünüyor.

Üçüncü taraf araç bu dışlama listesini paylaşmıyor, dolayısıyla bu kullanıcıları görüyordu. 5-10× farkın bir kısmı bundan.

Düzeltme: /up için tam eşleşme; namespace prefix'leri için segment-anchored regex — /admin/ dışlanıyor ama /administrator dışlanmıyor.

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'e path sınır vakaları eklendi: /up dışlanıyor ama /update dışlanmıyor, /admin/foo dışlanıyor ama /administrator dışlanmıyor. Bu spec'ler olmazsa, biri exclude listesini bir sonraki refactor ettiğinde aynı sınıf bug'a yine takılır.

Bonus temizlik: rake backfill'in saniye sınırı dedup'ı

Ana yemek değil ama aynı PR bunu da temizledi.

Tarihsel sign-in olaylarını ziyaret olarak backfill eden bir rake task var. Dedup mantığı timestamp'leri Time#to_i ile saniye kovasına koyuyordu:

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

Sorun: kullanıcı sign-in'i hızlıca iki kez tıklıyor. İki timestamp 12:34:56.9 ve 12:34:57.1 oluyor. to_i sonrası 56 ve 57 oluyorlar, eşleşmiyorlar, dedup başarısız oluyor, iki ziyaret backfill ediliyor.

Düzeltme: 1 saniyelik zaman penceresine geçmek:

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

Bu arada, 1.hour.ago'nun rastgele alt-saniye offset'ine bağlı, ara sıra patlayan bir spec de düzeldi.

Asıl ders: ikinci bir saat lazım

Tek saate bakıyorsanız, yanlış sayı sonsuza kadar yanlış kalabilir.

Admin paneline günlerdir bakıyordum ama "eşsiz ziyaretçi"nin gerçeğin 5-10 katı olduğunu fark etmemiştim — çünkü referansım yoktu. Üçüncü taraf aracı yanına astığım anda bir soru havadan ortaya çıktı: "bu fark neden bu kadar büyük?". Üçüncü taraf aracın spesifik markası önemli değil — Simple Analytics, Plausible, eşsiz ziyaretçi ve pageview sayısı veren herhangi bir şey iş görür. Onun amacı kalibrasyon, kendi tracking'inizin yerini almak değil.

Özellikle Ahoy gibi "hem tarayıcı hem server-side tracking" yapabilen araçlarda, basıp geçtiğim üç tuzak burada:

  1. server_side_visits = true sonrası, headline sayısı "pageview üreten Visit"i saymak zorunda, toplam Visit'i değil.
  2. bounce / engaged gibi oranların paydaları açıkça hizalanmalı — spec'i yaz: expect(bounce + engaged).to be_within(1.0).of(100).
  3. exclude_method'daki start_with? sizin vanity URL'iniz olmadığını varsayar. Varsa, tam eşleşme artı segment-anchored regex'e geçin.

Self-hosted analytics confirmation bias'a alışılmadık şekilde açıktır — tracker'ı siz yazdınız, tracker'a güvenirsiniz. Claude'a üçüncü taraf rakamları çekip karşılaştırtmak, üç farklı hatanın aynı anda yüzeye çıkmasını sağladı.

Eğer admin dashboard'unuz bir süredir denetlenmediyse, yanına ikinci bir saat asın. Boşluk ne kadar büyükse o kadar çok şey bulursunuz.