ตัวเลข admin ที่ทำเองคลาดเคลื่อน 5-10×: `server_side_visits` พอง, ตัวหารไม่ตรง, `start_with?` กลืน vanity URL
ผมมี admin dashboard ที่ทำ tracking เองด้วย Ahoy ที่เลือก Ahoy ตอนแรกเพราะอยากให้ทุกอย่างอยู่ใน Rails และไม่อยากเพิ่ม dependency ภายนอก ภายหลังผมเอาเครื่องมือของบุคคลที่สามมาแขวนข้าง ๆ (Simple Analytics, Plausible หรือตัวไหนก็ได้ — ประเด็นคือมีตัวเทียบ)
วันหนึ่งผมจ้องตารางทั้งสองวางคู่กันแล้วเห็นว่าตัวเลข unique visitor ต่างกัน 5 ถึง 10 เท่า ปฏิกิริยาแรก: เครื่องมือบุคคลที่สามคงพลาดข้อมูล เลยให้ Claude เหลือบดู ปรากฏว่า dashboard ของผมโกหกไม่ใช่เพราะเหตุผลเดียว — แต่เป็น bug สามแบบที่ทับกัน บทความนี้คือเรื่องของกับดักทั้งสามนั้น
server_side_visits = trueAhoy โดยปกติทำ tracking ฝั่ง browser ด้วย JS ผมเปิด server_side_visits = true เพราะอยากเก็บ request ที่ไม่รัน JS ด้วย — bot, API, cURL
สิ่งที่ผมไม่ได้คำนวณให้ครบ: เปิด flag นี้ปุ๊บ ทุก controller action จะสร้างแถว Visit หนึ่งแถว
Visit.distinct.count(:visitor_token) ตัวเลขนี้พองตัว 5–10 เท่ากว่าความจริงเครื่องมือบุคคลที่สามนับเฉพาะ visit ที่ยิง event $view จริง ๆ จึงไม่เคยพอง
วิธีแก้ไม่ซับซ้อนแต่ต้องเปลี่ยนกรอบคิด: ตัวเลข headline ใช้จำนวน Visit ทั้งหมดไม่ได้ ต้องใช้ "Visit ที่ทำให้เกิด pageview อย่างน้อยหนึ่งครั้ง" ส่วน API / redirect / sub-request Turbo Frame ที่เหลือไปอยู่ในถังแยก noise_visits_count — ไม่ใช่ visitor แต่คือ traffic จริง โชว์แยกออกมาก็มีประโยชน์
visits_with_any_pageview = Ahoy::Event
.where(name: "$view", time: range)
.joins(:visit)
.distinct
.pluck("ahoy_visits.id")
# headline นับเฉพาะตัวนี้
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 กับ engaged_rate ไม่เคยรวมเป็น 100%bug นี้ดูละเอียดอ่อนกว่า แต่เป็นอันที่ทำให้หลังเย็น
bounce_rate: visit ที่ดูแค่หน้าเดียวแล้วออก / visit ที่ดูอย่างน้อยหนึ่งหน้า
engaged_rate: visit ที่มี ≥2 pageview หรืออยู่นาน >30 วินาที / visit รวม
ตัวหารคนละแบบ
โดยสัญชาตญาณสองสัดส่วนนี้ควรเป็นสมบูรณภาพ — visit หนึ่ง ๆ ต้องเป็น bounce หรือ engaged รวมกันได้ ~100% แต่ dashboard กลับโชว์ bounce 47% + engaged 31% = 78% อีก 22% หายไปไหน?
ไปอยู่ในถัง "Visit ที่ไม่มี pageview" ของกับดัก 1 มันตกเข้าไปอยู่ในตัวหารของ engaged_rate (visit รวม) แต่ถูกตัดออกจากตัวหารของ bounce_rate (pageview-visits)
วิธีแก้: ปรับให้ตัวหารทั้งสองเท่ากันเป็น visits_with_any_pageview แล้วสองสัดส่วนนี้ก็จะรวมได้ ~100% จริง และล็อคไว้ด้วย 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)
ผมจ้อง dashboard มาหลายวันแล้วไม่เคยสังเกตว่าสองตัวเลขนี้ไม่รวมเป็น 100% — เพราะดูแยกกันตลอด คนที่เอามาวางคู่กันคือ Claude ตอนกำลังประกอบ component overview ของ admin แล้วถามผมว่า: "ทำไมสองสัดส่วนนี้ไม่เป็น complement กัน?"
start_with?("/up") กลืนผู้ใช้ทั้งคนแบบเงียบ ๆอันนี้บ้าที่สุด
ใน config/initializers/ahoy.rb มี Ahoy.exclude_method ที่ filter path ที่ผมไม่อยากนับ — health check, assets, admin, jobs ภายใน เวอร์ชันเดิม:
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" คือ endpoint health check ของ Rails 8 start_with?("/up") ดูเหมือนจะไม่มีปัญหา — จนคุณนึกได้ว่าเว็บมี profile ผู้ใช้เป็น vanity URL: หน้า home ของผู้ใช้แต่ละคนคือ /<username>
ซึ่งหมายความว่า:
/update (ผู้ใช้คนนั้น) ถูก exclude — เริ่มต้นด้วย /up/administrator ถูก exclude/jobsworth ถูก exclude/cabletv ถูก exclude/ahoyo ก็จะถูก exclude ถ้ามีผู้ใช้ชื่อนั้นpageview ของผู้ใช้พวกนี้ ไม่เข้า Ahoy เลย พวกเขาดู profile ตัวเอง ดู profile คนอื่น refresh comment ทุกครั้งที่เข้าหน้าก็ถูกทิ้งแบบเงียบ ๆ จากมุมมอง dashboard ผู้ใช้พวกนี้เหมือนไม่เคยใช้เว็บไซต์เลย
เครื่องมือบุคคลที่สามไม่ได้แชร์ exclude list นี้ จึงเห็นพวกเขา นั่นคือส่วนหนึ่งของ delta 5–10 เท่า
วิธีแก้: ใช้การเทียบเท่าตรง ๆ สำหรับ /up; ใช้ regex ที่ anchor ตาม segment สำหรับ namespace prefix — /admin/ ถูก exclude แต่ /administrator ไม่ถูก exclude
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 ได้ case ขอบของ path เพิ่ม: /up ถูก exclude แต่ /update ไม่ถูก exclude, /admin/foo ถูก exclude แต่ /administrator ไม่ถูก exclude ถ้าไม่เขียน spec พวกนี้ ครั้งหน้าที่ใครจะ refactor exclude list ก็จะตกในคลาส bug เดียวกันอีก
ไม่ใช่จานหลัก แต่ PR เดียวกันเก็บกวาดด้วย
มี rake task ที่ backfill event sign-in เก่าให้กลายเป็น visit logic dedup เอา timestamp ใส่ bucket ตามวินาทีผ่าน Time#to_i:
existing_buckets = existing_visits.map { |v| v.started_at.to_i }
return if existing_buckets.include?(time.to_i)
ปัญหา: ผู้ใช้ double-click ปุ่ม sign-in สอง timestamp ตกที่ 12:34:56.9 กับ 12:34:57.1 หลังจาก to_i กลายเป็น 56 กับ 57 ไม่ตรงกัน dedup ล้มเหลว visit สองรายการถูก backfill
วิธีแก้: เปลี่ยนเป็นหน้าต่างเวลา 1 วินาที:
return if existing_visits.any? { |v| (v.started_at - time).abs < 1.0 }
ผลพลอยได้: spec ที่บางครั้งจะเสียเพราะอิง offset ใต้วินาทีแบบสุ่มของ 1.hour.ago ก็หายตามไปด้วย
ถ้ามองนาฬิกาเรือนเดียว ตัวเลขผิดสามารถผิดตลอดไปได้
ผมจ้อง admin dashboard มาหลายวันโดยไม่สังเกตว่า "unique visitor" เป็น 5–10 เท่าของจำนวนจริง — เพราะไม่มีจุดอ้างอิง ทันทีที่เอาเครื่องมือบุคคลที่สามมาแขวนข้าง ๆ คำถามใหม่ก็โผล่มาจากที่ไหนก็ไม่รู้: "ทำไม gap นี้ใหญ่ขนาดนี้?" ยี่ห้อเฉพาะของเครื่องมือบุคคลที่สามไม่สำคัญ — Simple Analytics, Plausible, อะไรก็ได้ที่ให้ตัวเลข unique visitor และ pageview ใช้ได้หมด หน้าที่ของมันคือ calibrate ไม่ใช่แทนที่ tracking ของคุณ
โดยเฉพาะ Ahoy ซึ่ง "tracking ฝั่ง browser ก็ได้ server-side ก็ได้" กับดักทั้งสามที่ผมเหยียบอยู่ในบทความนี้ทั้งหมด:
server_side_visits = true ตัวเลข headline ต้องนับ "Visit ที่สร้าง pageview" ไม่ใช่ Visit ทั้งหมดexpect(bounce + engaged).to be_within(1.0).of(100)start_with? ใน exclude_method สมมติแบบเงียบ ๆ ว่าคุณ ไม่มี vanity URL ถ้ามี ต้องเปลี่ยนเป็นการเทียบเท่าตรง ๆ บวก regex ที่ anchor ตาม segmentself-hosted analytics เปราะบางต่อ confirmation bias เป็นพิเศษ — คุณเขียน tracker คุณก็เชื่อ tracker ปล่อยให้ Claude ดึงตัวเลขบุคคลที่สามมาเทียบ ทำให้ error ทั้งสามผุดขึ้นมาพร้อมกัน
ถ้า admin dashboard ของคุณไม่ได้ถูก audit มาหลายวันแล้ว เอานาฬิกาเรือนที่สองมาแขวนข้าง ๆ gap ยิ่งใหญ่ ยิ่งเจอเยอะ