Số liệu admin tự dựng lệch 5-10×: `server_side_visits` phồng lên, mẫu số lệch nhau, `start_with?` nuốt vanity URL
Tôi có một admin dashboard tự cắm tracking bằng Ahoy. Ban đầu chọn Ahoy vì muốn mọi thứ nằm trong Rails và không muốn thêm phụ thuộc vào bên ngoài. Sau này tôi treo thêm một công cụ bên thứ ba ở bên cạnh (Simple Analytics, Plausible, cái nào cũng được — chỉ là một cái để đối chứng).
Một hôm tôi nhìn hai bảng kề nhau và thấy số khách duy nhất lệch 5 đến 10 lần. Phản xạ đầu tiên: bên thứ ba đang bỏ sót dữ liệu. Tôi bảo Claude liếc qua. Hóa ra dashboard của tôi nói dối không phải vì một lý do — mà là ba bug khác nhau xếp chồng lên nhau. Bài viết này nói về ba cái bẫy đó.
server_side_visits = trueMặc định Ahoy tracking phía trình duyệt bằng JS. Tôi đã bật server_side_visits = true vì muốn bắt cả các request không chạy JS — bot, gọi API, cURL.
Cái giá tôi chưa tính tới: bật cờ đó lên thì mỗi controller action tạo một dòng Visit.
Visit.distinct.count(:visitor_token). Con số đó phồng lên 5–10× so với sự thật.Công cụ bên thứ ba chỉ đếm những lượt thực sự phát ra event $view, nên không bao giờ bị phồng.
Cách sửa không phức tạp, nhưng cần đổi cách đóng khung: số headline không thể dùng tổng Visit, phải dùng "Visit có tạo ra ít nhất một pageview". Phần còn lại — API / redirect / sub-request Turbo Frame — đẩy vào một cái xô riêng, noise_visits_count — đó không phải khách, nhưng vẫn là traffic thật, để hiển thị riêng cũng có ích.
visits_with_any_pageview = Ahoy::Event
.where(name: "$view", time: range)
.joins(:visit)
.distinct
.pluck("ahoy_visits.id")
# headline chỉ đếm phần này
unique_visitors = Ahoy::Visit.where(id: visits_with_any_pageview).distinct.count(:visitor_token)
noise_visits_count = total_visits - visits_with_any_pageview.size
bounce_rate và engaged_rate không bao giờ cộng đủ 100%Bug này nhìn tinh tế hơn, nhưng làm lạnh sống lưng hơn.
bounce_rate: số visit chỉ xem một trang rồi rời / số visit xem ít nhất một trang.
engaged_rate: số visit có ≥2 pageview hoặc ở lại >30s / tổng số visit.
Hai mẫu số khác nhau.
Trực giác mà nói thì hai cái này phải bù trừ — visit hoặc bounce hoặc engaged, cộng lại ~100%. Dashboard hiển thị bounce 47% + engaged 31% = 78%. 22% còn lại đi đâu?
Nó vào cái xô "Visit không tạo pageview" của Bẫy 1. Chúng rơi vào mẫu số của engaged_rate (tổng visit), nhưng bị loại khỏi mẫu số của bounce_rate (pageview-visit).
Sửa: làm cho cả hai mẫu số trùng nhau ở visits_with_any_pageview. Hai tỷ lệ thực sự cộng được ~100%, rồi chốt bằng 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)
Tôi đã nhìn dashboard mấy ngày mà chưa bao giờ nhận ra hai con số đó không cộng thành 100% — vì lúc nào cũng nhìn riêng. Người đặt chúng cạnh nhau là Claude, trong lúc dựng một component overview của admin, nó hỏi tôi: "tại sao hai tỷ lệ này không bù trừ nhau?"
start_with?("/up") lặng lẽ vứt nguyên cả người dùngCái này là lố nhất.
config/initializers/ahoy.rb có Ahoy.exclude_method lọc ra những path không muốn tính — health check, assets, admin, jobs nội bộ. Bản gốc:
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" là endpoint health check của Rails 8. start_with?("/up") thoạt nhìn chẳng có vấn đề — cho đến khi bạn nhớ ra trang có profile vanity URL: home của mỗi người dùng là /<username>.
Tức là:
/update (người dùng tên này) bị loại — bắt đầu bằng /up/administrator bị loại/jobsworth bị loại/cabletv bị loại/ahoyo cũng bị loại nếu có người dùng đóPageview của những người này không bao giờ vào Ahoy. Họ xem profile của mình, xem của người khác, refresh, để lại bình luận, mọi cú nhấn trang đều bị bỏ trong im lặng. Từ góc nhìn của dashboard, những người này chưa bao giờ dùng trang web.
Công cụ bên thứ ba không chia sẻ danh sách exclude đó nên nhìn thấy họ. Đó là một phần của khoảng cách 5–10×.
Sửa: /up so trùng chính xác; các tiền tố namespace dùng regex neo theo segment — /admin/ bị loại còn /administrator thì không.
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 thêm các ca biên path: /up bị loại nhưng /update thì không, /admin/foo bị loại nhưng /administrator thì không. Không có spec này, lần sau ai đó refactor danh sách exclude vẫn vấp đúng kiểu bug ấy.
Không phải món chính, nhưng cùng PR đã dọn luôn.
Có một rake task backfill các sự kiện sign-in lịch sử thành visit. Logic dedup nhét timestamp vào bucket theo giây bằng Time#to_i:
existing_buckets = existing_visits.map { |v| v.started_at.to_i }
return if existing_buckets.include?(time.to_i)
Vấn đề: người dùng double-click sign-in. Hai mốc thời gian rơi ở 12:34:56.9 và 12:34:57.1. Sau to_i thành 56 và 57, không trùng, dedup hỏng, hai visit được backfill.
Sửa: chuyển sang cửa sổ thời gian 1 giây:
return if existing_visits.any? { |v| (v.started_at - time).abs < 1.0 }
Tiện thể sửa luôn một spec hay rớt vì phụ thuộc vào offset dưới giây ngẫu nhiên của 1.hour.ago.
Nếu chỉ nhìn một đồng hồ, con số sai có thể ở yên trong sai hoài.
Tôi đã nhìn admin dashboard mấy ngày mà không nhận ra "khách duy nhất" cao gấp 5–10× con số thật — vì không có hệ quy chiếu. Khoảnh khắc treo công cụ bên thứ ba lên cạnh nó, tự dưng nảy ra một câu hỏi: "tại sao chênh lớn vậy?". Cụ thể là hãng nào không quan trọng — Simple Analytics, Plausible, cái gì cho bạn số khách duy nhất và pageview là được. Vai trò của nó là hiệu chuẩn, không phải thay thế cái tracking nhà bạn.
Riêng với Ahoy — công cụ "vừa tracking phía trình duyệt vừa tracking server-side đều được" — ba cái bẫy tôi vấp đều có ở đây:
server_side_visits = true, số headline phải đếm "Visit có tạo pageview", không phải tổng Visit.expect(bounce + engaged).to be_within(1.0).of(100).start_with? trong exclude_method ngầm giả định bạn không có vanity URL. Nếu có, đổi sang so trùng chính xác cộng regex neo theo segment.Analytics tự host đặc biệt dễ dính bias xác nhận — bạn viết tracker, bạn tin tracker. Để Claude kéo số bên thứ ba về so sánh, ba lỗi khác nhau cùng nổi lên một lượt.
Nếu admin dashboard của bạn đã mấy ngày không có ai soát lại, hãy treo thêm một cái đồng hồ thứ hai cạnh nó. Khoảng cách càng lớn, càng tìm ra nhiều thứ.