Free

ให้ Claude แก้ตัวเลขที่โกหกใน analytics ที่ทำเองในบ้าน

ตัวเลข 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 สามแบบที่ทับกัน บทความนี้คือเรื่องของกับดักทั้งสามนั้น

กับดัก 1: server_side_visits = true

Ahoy โดยปกติทำ tracking ฝั่ง browser ด้วย JS ผมเปิด server_side_visits = true เพราะอยากเก็บ request ที่ไม่รัน JS ด้วย — bot, API, cURL

สิ่งที่ผมไม่ได้คำนวณให้ครบ: เปิด flag นี้ปุ๊บ ทุก controller action จะสร้างแถว Visit หนึ่งแถว

  • ผู้ใช้เข้ามา อ่านบทความสองหน้า (2 pageview), กดปุ่ม like ที่เรียก API (JSON), โดน redirect ไปหน้า sign in แล้วกลับมา, บวกกับ sub-request จาก Turbo Frame ที่โหลด lazy หน้าถัดไป จำนวนแถว Visit จริง ๆ: 6–10 แบบสบาย ๆ
  • "unique visitor" เคยเป็น 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

กับดัก 2: 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 กัน?"

กับดัก 3: 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 เดียวกันอีก

ทำความสะอาดแถม: dedup ที่ขอบวินาทีของ rake backfill

ไม่ใช่จานหลัก แต่ 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 ก็ได้" กับดักทั้งสามที่ผมเหยียบอยู่ในบทความนี้ทั้งหมด:

  1. หลัง server_side_visits = true ตัวเลข headline ต้องนับ "Visit ที่สร้าง pageview" ไม่ใช่ Visit ทั้งหมด
  2. สัดส่วนแบบ bounce / engaged ต้องปรับตัวหารให้ตรงกันโดยชัดเจน — เขียน spec: expect(bounce + engaged).to be_within(1.0).of(100)
  3. start_with? ใน exclude_method สมมติแบบเงียบ ๆ ว่าคุณ ไม่มี vanity URL ถ้ามี ต้องเปลี่ยนเป็นการเทียบเท่าตรง ๆ บวก regex ที่ anchor ตาม segment

self-hosted analytics เปราะบางต่อ confirmation bias เป็นพิเศษ — คุณเขียน tracker คุณก็เชื่อ tracker ปล่อยให้ Claude ดึงตัวเลขบุคคลที่สามมาเทียบ ทำให้ error ทั้งสามผุดขึ้นมาพร้อมกัน

ถ้า admin dashboard ของคุณไม่ได้ถูก audit มาหลายวันแล้ว เอานาฬิกาเรือนที่สองมาแขวนข้าง ๆ gap ยิ่งใหญ่ ยิ่งเจอเยอะ