เมื่อสุดสัปดาห์ผมนั่งเปิด admin dashboard ของ Newton แล้วเจอเรื่องตลก — ช่อง "ลูกค้า" ขึ้น 11 คน แต่ช่อง "Active Servers" ขึ้น 12 ตัว 555 เฮ้ย ลูกค้าผมมีกี่คนกันแน่วะ?

มันแค่ต่างกัน 1 ตัวก็จริง แต่พอ dashboard ของตัวเองมันเริ่มขัดแย้งกันเอง ผมกล้าตัดสินใจอะไรจากตัวเลขในนั้นไม่ได้แล้ว เลยสั่งทิมให้ rewrite endpoint ที่ออก stat ทั้งหมดใหม่ — ใช้เวลาประมาณชั่วโมงเดียว ได้ funnel 5 ขั้นที่บวกแล้วต้องเท่ากับ total เป๊ะๆ ไม่งั้น API ก็ไม่ผ่าน

เริ่มจากการไล่บั๊กอีกเรื่อง — แล้วบังเอิญเจอเลขเพี้ยน

จริงๆ วันนั้นผมไม่ได้ตั้งใจจะ refactor dashboard เลย ผมแค่อยากให้ทิมตามแก้ปัญหา trial conversion ที่ Stripe webhook ขาด eventให้จบ

พอแก้เสร็จ refresh dashboard ดูตัวเลขใหม่ ผมก็สังเกตว่าช่อง "ลูกค้า" กับช่อง "Active Servers" มันต่างกัน — ลูกค้า 11 คน server 12 ตัว เอ๊ะ คนเดียวมีหลาย server เหรอ? หรือมีลูกค้าโดนนับตกหล่น?

ผมเลยถามทิม "นี่ตัวเลขมันต่างกันได้ไงวะ ลูกค้าน้อยกว่า server ได้ด้วยเหรอ?"

ทิมไล่ SQL ให้ดู — query 2 ตัวคนละ filter

ทิมเปิด main.py ของ Newton ดู endpoint /api/stats แล้วตอบกลับมาแบบนี้:

Query "customers" ใช้ WHERE status IN ('active','cancelling') — นับเฉพาะ subscription ที่จ่ายเงินแล้ว ไม่นับ trial

Query "active_servers" ใช้ WHERE status='active' ใน table servers — ซึ่งรวม trial server ที่จ่ายอยู่ในช่วงทดลอง 7 วันด้วย

เลยกลายเป็น 11 vs 12 — ตัว trial 1 คนนับใน server แต่ไม่นับใน customer 555

แล้วทิมก็เสนอเพิ่มอีกประเด็น "ปอนด์ครับ ใน table customers ยังมี email demo ของผมตอนทดสอบ [email protected] ปนอยู่ด้วย ตอนนี้ดันไปนับเป็น customer ที่ไม่มี subscription = abandon — แต่จริงๆ ไม่ใช่ลูกค้า"

ผมอ่านแล้วรู้สึกเลยว่าปัญหามันใหญ่กว่าที่คิด — ไม่ใช่แค่ต่าง 1 ตัว แต่ทั้ง dashboard มันนิยาม "ลูกค้า" ไม่เคยตรงกันสักหน้าจอ

สั่ง refactor ให้เป็น funnel ที่ self-validating

ผมเลยตัดสินใจสั่งให้ทิม rewrite endpoint นี้ใหม่ทั้งดุ้น โดยตั้งกฎเหล็ก 2 ข้อ:

กฎที่ 1: ทุก stage ต้อง mutually exclusive — ลูกค้า 1 คนอยู่ได้แค่ 1 stage เท่านั้น

กฎที่ 2: ผลรวมทุก stage ต้องเท่ากับ total customers — ถ้าบวกแล้วไม่ตรง แปลว่ามีบั๊กในการแบ่งกลุ่ม

ทิมเลยออกแบบ funnel 5 ขั้นมาให้ — แต่ละ stage จัดตาม "subscription ล่าสุดของลูกค้าคนนั้นมีสถานะอะไร" (subquery ORDER BY id DESC LIMIT 1):

  • Total — email ทั้งหมดใน table customers (ตัด demo ออกด้วย email NOT LIKE '%@jarvis-test.com')
  • Abandon — เคยกรอกฟอร์ม signup แต่ไม่เคยสร้าง subscription เลย
  • Trial — subscription ล่าสุด status = trialing
  • Paid — subscription ล่าสุด status = active หรือ cancelling (จ่ายอยู่แต่กดยกเลิก รอสิ้นรอบ)
  • Churned — subscription ล่าสุดถูก cancelled, past_due, unpaid ไปแล้ว

ทิมเขียนเป็น constant DEMO_EMAIL_FILTER ไว้ที่เดียว reuse ทุก query — ไม่ต้องไปไล่ filter ทีละจุดให้พลาด

เปิด dashboard ตัวเลขใหม่ — บวกเป็น total เป๊ะ

Deploy เสร็จ refresh ปุ๊บ ผมเห็นชุดตัวเลขนี้:

  • Total: 15 คน
  • Abandon: 2 คน
  • Trial: 1 คน
  • Paid: 11 คน
  • Churned: 1 คน

2 + 1 + 11 + 1 = 15 ✅ — บวกแล้วตรง total เป๊ะ ไม่มีคนหายไปที่ไหน ไม่มีคนถูกนับซ้ำ

ฝั่ง EN ที่ผมเพิ่งเปิด landing ทิมก็ออกตัวเลขเดียวกันกฎเดียวกัน: 1/1/0/0/0 — sum = 1 ✅

ที่ตลกคือ ผมเพิ่งเข้าใจว่าระยะเวลาที่ผ่านมาผมเคยเห็น "Total = 11" บน dashboard — แต่จริงๆ ผม collect email มา 15 คน มี 2 คนที่กรอกฟอร์ม signup แต่ไม่เคยกดเริ่ม trial เลย ผมไม่เคยเห็นเลขนี้มาก่อนตลอด 2 เดือน 555 — ที่ผ่านมามองเห็นแต่ลูกค้าที่จ่ายเงินอย่างเดียว ลืมไปว่ายังมีคนที่ "เกือบเป็นลูกค้า" ลอยอยู่ในระบบ

ของแถม 3 อย่างที่ทิมจัดให้ในรอบเดียวกัน

ผมไม่ได้ขอ แต่ทิมเสนอเพิ่มมาเองเพราะมันต่อยอดมาจากการ refactor นี้แบบฟรี ๆ:

1. ฝัง trial conversion rate 30 วันไว้ในการ์ด Trial — แทนที่จะเป็นการ์ดแยก ก็ฝัง % conversion 30 วันล่าสุดไว้ใต้ตัวเลข ใครเปิดมาเจอการ์ด trial ก็เห็น "1 คน · 75% convert" เลย ไม่ต้องตามอ่าน 2 จุด

2. ฝัง churn rate (cumulative + 30d) ในการ์ด Churned — เหตุผลเดียวกัน metric ของ stage ไหนควรอยู่ในการ์ดของ stage นั้น

3. Activation denominator — เดิมที่ทิมสร้างการ์ด Activation ตอนแรกใช้ทั้งหมดที่ active หาร แต่ตอนนั้นมีบาง server ที่ status = provisioning (กำลังสร้างยังไม่เสร็จ chat ไม่ได้อยู่แล้ว) ทำให้เลขเป็น 11/11 ทั้งที่จริง active server มี 12 ตัว — ทิมแก้ให้ denominator ใช้ status IN ('active','cancelling') เท่านั้น เลขออกมาเป็น 12/12 ตรงกับการ์ด Active Servers ทันที

แล้วก็ rename การ์ด "Lapsed (7d no chat)" เป็น "Idle (7d)" เพราะปรับวิธีวัดให้ดู workspace activity ด้วยในรอบเดียวกัน — false positive จาก 3 → 1 ทันที

Auto-refresh dashboard ด้วย — เลิกกด F5 เอง

ก่อนหน้านี้ admin dashboard ผม load /api/stats แค่ตอนเปิดหน้าครั้งแรก หลังจากนั้นไม่ poll เลย — ผมเคยนั่งเปิดทิ้งไว้เป็นชั่วโมง คิดว่าเลขมันยังเด้งอยู่ตลอด

ทิมเลยใส่ auto-refresh ทุก 30 วินาที แต่ฉลาดพอที่จะ skip ตอน:

  • หน้าไม่ active (Page Visibility API)
  • มี modal หรือ form เปิดอยู่

ไม่งั้นมัน reload ตอนผมพิมพ์ตอบ ticket อยู่จะรำคาญมาก 555

บทเรียนใหญ่ที่ผมได้จากเรื่องนี้

Dashboard ที่ตัวเลขไม่ตรงกัน = dashboard ที่กำลังโกหกคุณ — ไม่ใช่เพราะ engineer ขี้เกียจ แต่เพราะแต่ละ metric ถูกเขียนคนละช่วงเวลา คนละจุดประสงค์ ทำให้นิยาม "ลูกค้า" ของแต่ละช่องไม่เหมือนกัน พอวันหนึ่งเลขมัน drift ไปคุณจะตัดสินใจผิดทันที

กฎ "บวกแล้วต้องเท่ากับ total" คือ self-test ที่ดีที่สุด — ถ้า funnel มันบวกไม่ตรง = บั๊กในการแบ่ง stage แน่ๆ คุณไม่ต้องไปเขียน unit test เพิ่ม ตัวเลขมันเตือนตัวมันเอง

ของฟรีที่ดีที่สุดมาจากการ refactor ที่ตั้งใจ — ตอนแรกผมแค่อยากให้ทิม fix เลข 11 vs 12 แต่ทิมเสนอ refactor ทั้งก้อน เพราะรู้ว่าถ้าไม่จัดเรียงใหม่ บั๊กแบบนี้จะกลับมาอีก แล้วของแถม 3 อย่าง (auto-refresh, conversion rate ในการ์ด, activation denominator) ก็มาในรอบเดียวกัน — เพราะ context มันต่อกันหมด

ทำไมต้องเป็น AI ของผม ไม่ใช่ analytics SaaS?

ลองคิดว่าถ้าผมต้องแก้ปัญหานี้โดยใช้ Mixpanel หรือ ChartMogul แทน ผมต้อง:

  1. ส่ง event ทุกอย่างเข้า SaaS เอง (signup, trial start, paid, cancel, etc.)
  2. ไป config funnel ใน UI ของ SaaS
  3. หวังว่ามันรองรับ "subscription ล่าสุดของลูกค้า" แบบที่ Newton ของผมต้องการ

แต่ทิมแค่อ่าน schema DB ของ Newton ตรงๆ — รู้ว่า table customers กับ subscriptions เชื่อมกันยังไง รู้ว่า status field มีค่าอะไรบ้าง — แล้วเขียน SQL 5 ตัวรวมเป็น endpoint เดียวให้ ไม่ต้อง integrate ไม่ต้องส่ง event ไม่ต้อง config UI

ที่สำคัญ — ทิมเห็น [email protected] ในข้อมูลผมและรู้ว่าเป็น demo ของผมเอง เพราะมันมีความจำของผมและ Newton ทั้งหมด SaaS สำเร็จรูปไม่มีทางรู้ว่า email ไหนคือ demo ไหนคือลูกค้าจริงครับ

คำถามที่พบบ่อย

ทำไม dashboard ของ SaaS ถึงมักแสดงตัวเลขไม่ตรงกัน?

เพราะแต่ละ metric ถูกเขียนขึ้นคนละช่วงเวลา คนละจุดประสงค์ ทำให้นิยาม "ลูกค้า" ของแต่ละช่องไม่เหมือนกัน วิธีแก้ที่ดีที่สุดคือออกแบบ funnel ที่ mutually exclusive — ลูกค้า 1 คนอยู่ได้แค่ 1 stage และผลรวมทุก stage ต้องเท่ากับ total เสมอครับ

5-stage customer funnel สำหรับ SaaS มีอะไรบ้าง?

แนวทางหนึ่งที่ผมใช้คือ Total (ทุกคนใน DB), Abandon (กรอกฟอร์มแต่ไม่สร้าง subscription), Trial (กำลังทดลองใช้), Paid (จ่ายเงินแล้ว) และ Churned (ยกเลิกไปแล้ว) ทั้งห้า stage นี้บวกกันแล้วต้องเท่ากับ Total เป๊ะ — ถ้าไม่ตรงแปลว่ามีบั๊กในการแบ่ง stage ครับ

ควรเก็บ demo account ออกจาก dashboard ยังไง?

วิธีที่ใช้ได้ผลคือเพิ่ม filter ตายตัวไว้ใน query ทุกตัว เช่น WHERE email NOT LIKE '%@yourtest.com' และเขียนเป็น constant ไว้ที่เดียว (เช่น DEMO_EMAIL_FILTER) เพื่อให้ reuse ทุก query โดยไม่ต้องไปไล่แก้ทีละจุดครับ

ทำไม admin dashboard ถึงควรมี auto-refresh?

เพราะ dashboard ที่ refresh แค่ตอนเปิดหน้าครั้งแรก จะทำให้คุณเห็นข้อมูลเก่าถ้าเปิดค้างไว้ ผมใส่ auto-refresh ทุก 30 วินาที แต่ skip ตอนหน้าไม่ active หรือมี modal เปิดอยู่ เพื่อไม่ให้รบกวนตอนกำลังตอบ ticket ครับ

ถ้าคุณกำลังทำ SaaS เล็ก ๆ ของตัวเอง หรือมีร้านค้าออนไลน์ที่อยากรู้ว่าลูกค้าค้างอยู่ stage ไหน — ลองถามตัวเองดูครับว่าเลขใน dashboard ของคุณมันบวกแล้วเท่ากันรึเปล่า ถ้ายังไม่ ผมแนะนำให้ลองให้ AI Agent ของตัวเอง (ที่มี SSH เข้า server มี access DB ได้จริง) สร้าง dashboard ที่ตรงกับธุรกิจคุณเองดู — เร็วกว่า ถูกกว่า แม่นกว่า SaaS สำเร็จรูปเยอะ Newton ที่ผมใช้ทำเรื่องนี้ทั้งหมดเปิดให้ลองได้แล้วที่ newton.incomeinclick.in.th — เซิร์ฟเวอร์ส่วนตัวพร้อม AI Agent พร้อมใช้ใน 10 นาที

— ปอนด์