eugenemind.com
← กลับไปที่บล็อก

22 สิงหาคม 2569 · 16 นาทีในการอ่าน

Logging และ Analytics ใน iOS: คู่มือปฏิบัติจริง

#engineering #observability #mobile

หลายทีมเพิ่ม logging กับ analytics แบบทำให้ครบงาน ต่อ SDK ส่ง event สองสามตัว แล้วก็ไปทำอย่างอื่นต่อ แต่สองอย่างนี้มีหน้าที่ไม่เหมือนกัน ถ้าไม่ได้คิดเรื่องนี้ตั้งแต่แรก อีกไม่กี่เดือนข้อมูลอาจเต็มไปหมดแต่กลับตอบอะไรไม่ได้มาก

Logging มีไว้สำหรับนักพัฒนา

Log มีไว้ให้นักพัฒนาเป็นหลัก เราเก็บเหตุการณ์สำคัญกับ error ไว้ แล้วแทบไม่เปิดดูตอนที่ทุกอย่างทำงานดี ปัญหาจะเห็นชัดตอนมีบั๊ก ถ้า log ถูกใส่มาแบบไม่มีโครงสร้าง การหาบรรทัดที่มีประโยชน์จริงๆ จะเสียเวลามาก

ปกติเราจะนึกถึงเรื่องนี้ช้าไปหน่อย เช่น ตอนที่บั๊กขึ้น production แล้ว หรือ flow หนึ่งไปผูกกับหลายระบบจนทดสอบให้ครบยาก ตอนนั้นถึงจะรู้ว่าควรคิดเรื่อง logging กับ analytics ก่อนปล่อย ไม่ใช่หลังจากมีปัญหา

Log ไม่ได้ทำให้โค้ดแย่กลายเป็นโค้ดดี และ analytics ก็ไม่ได้กันบั๊ก สิ่งที่มันช่วยคือทำให้เราเจอสาเหตุได้เร็วขึ้น

Analytics มีไว้ทำอะไรกันแน่

Analytics มีไว้ตอบคำถามฝั่ง product เป็นหลัก มีคนใช้ฟีเจอร์นี้กี่คน หลุดออกจาก flow ตรงไหน conversion ตกที่ขั้นตอนไหน นี่คือสิ่งที่ analytics ควรช่วยตอบ

เครื่องมือหลักสำหรับ logging

จะมีเครื่องมือสามตัวที่พูดถึงบ่อยๆ ในโพสต์นี้ นี่คือสิ่งที่อยู่ข้างในแต่ละตัว

os.Logger (native, Apple)

  • ใช้ทำอะไร: structured log บนตัวเครื่องโดยตรง
  • ราคา: ฟรี เป็นส่วนหนึ่งของ iOS SDK
  • ต้องมี backend เอง: ไม่ต้อง ข้อมูลอยู่บนเครื่องทั้งหมด
  • การดูข้อมูล: ดูได้แค่ในเครื่อง (Console.app, Xcode, sysdiagnose) ไม่มีแดชบอร์ด ไม่มีการรวมข้อมูลข้ามผู้ใช้
  • ความสามารถขั้นสูง: subsystem/category และ level แยกกันอยู่แล้วตั้งแต่ระดับ API

Firebase Crashlytics

  • ใช้ทำอะไร: crash reporting
  • ราคา: ฟรี เป็นส่วนหนึ่งของ Firebase
  • ต้องมี backend เอง: ไม่ต้อง Google จัดการให้
  • การดูข้อมูล: แดชบอร์ดที่จัดกลุ่ม crash ตาม stack trace ค้นหาได้ตามเวอร์ชันและอุปกรณ์
  • ความสามารถขั้นสูง: -

Sentry

  • ใช้ทำอะไร: error tracking และ performance
  • ราคา: free tier ที่มีจำกัดจำนวน event เกินนั้นเสียเงิน
  • ต้องมี backend เอง: จะใช้แบบ managed (sentry.io) หรือ self-hosted (open source) ก็ได้
  • การดูข้อมูล: แดชบอร์ดที่กรองตาม release/environment ค้นหาผ่าน breadcrumbs
  • ความสามารถขั้นสูง: จับ App Hangs/ANR ได้ เก็บ breadcrumbs เองอัตโนมัติ ปรับ scrubbing เองได้

เริ่มจากตรงไหนดี

เวอร์ชันแอปกับ build number ส่วนใหญ่ SDK เก็บให้อยู่แล้ว ส่วนที่ต้องคิดเองมีอย่างอื่นมากกว่า

1. ID แบบไม่เปิดเผยตัวตนที่ยังอยู่หลังติดตั้งแอปใหม่ แอปควรระบุผู้ใช้ได้ทั้งตอนที่ login แล้วและยังไม่ได้ login สำหรับผู้ใช้ที่ login แล้ว ให้ใช้ user ID ที่ backend หรือระบบ authentication ส่งมาให้ ส่วนผู้ใช้ที่ยังไม่ได้ login ให้สร้าง anonymous ID เช่น UUID แล้วเก็บไว้ใน Keychain เพื่อให้ยังคงอยู่หลังจากติดตั้งแอปใหม่

enum AnonymousUserID {
    static var current: String {
        if let existing = Keychain.shared.string(forKey: "analytics_uid") {
            return existing
        }
        let id = UUID().uuidString
        Keychain.shared.set(id, forKey: "analytics_uid")
        return id
    }
}

คำถามต่อไปคือจะส่ง ID นี้ไปที่ไหน การเรียก Analytics.setUserId, Crashlytics.crashlytics().setUserID และ SentrySDK.setUser ตรงๆ ในทุกจุดของโค้ดคือไอเดียที่แย่ สาม SDK ของ vendor กระจัดกระจายอยู่ทั่วแอป พอต้องเปลี่ยนเครื่องมือตัวใดตัวหนึ่ง ก็ต้องไล่หาทีละจุดด้วยมือ ทางที่ดีกว่าคือซ่อน SDK ไว้หลัง interface เดียว แล้วเรียกใช้แค่ตัวนั้น:

protocol AppLoggerProtocol {
    func setUserId(_ id: String)
    func log(_ level: LogLevel, _ event: String, domain: LogDomain, metadata: [String: Any])
}

final class AppLogger {
    static let shared = AppLogger(destinations: [
        CrashlyticsDestination(), SentryDestination(), NativeLoggerDestination(),
    ])

    private let destinations: [AppLoggerProtocol]
    init(destinations: [AppLoggerProtocol]) { self.destinations = destinations }

    func setUserId(_ id: String) {
        destinations.forEach { $0.setUserId(id) }
    }

    func log(_ level: LogLevel, _ event: String, domain: LogDomain, metadata: [String: Any] = [:]) {
        destinations.forEach { $0.log(level, event, domain: domain, metadata: metadata) }
    }
}

AppLogger.shared.setUserId(AnonymousUserID.current)

แต่ละ *Destination คือ wrapper บางๆ คลุม SDK ตัวเดียว CrashlyticsDestination.setUserId ข้างในก็แค่เรียก Crashlytics.crashlytics().setUserID เท่านั้น มีเครื่องมือใหม่เข้ามาหรือตัวเก่าเลิกใช้ ก็แก้แค่ destination เดียว ไม่ต้องไล่แก้หลายสิบจุดทั่วแอป จากนี้ไปในโพสต์ AppLogger.shared คือ interface เดียวกันนี้แหละ

หลัง login ควรเรียก setUserId อีกรอบด้วย user ID จริง จะได้เชื่อมสิ่งที่เกิดก่อนและหลัง login ได้ง่ายขึ้น

2. Severity กับ domain เป็นคนละแกน อย่าเอามาปนกัน

Severity (debug/info/notice/error/fault) ตอบคำถามว่าเรื่องนี้ร้ายแรงแค่ไหน

Domain (checkout/auth/onboarding) ตอบอีกคำถามว่าเรื่องนี้เกิดตรงส่วนไหนของแอป ถ้าเอามารวมเป็นแกนเดียว จะได้แค่สามระดับทั้งโปรเจกต์โดยกรองตามฟีเจอร์ไม่ได้ หรือไม่ก็สิบ “domain” ปนกับ severity จนตามไม่ไหว ถ้าออกแบบ domain มาดีตั้งแต่แรก ทีหลังจะจัดกลุ่ม error บนแดชบอร์ดได้ง่าย เห็นเลยว่าส่วนไหนของแอปพังบ่อยที่สุด เพราะงั้นควรวางไว้ตั้งแต่ต้น ไม่ใช่ค่อยๆ เพิ่มไปเรื่อยๆ ตามสถานการณ์

เพื่อความง่าย ตัวอย่างด้านล่าง domain เป็นแค่ enum case ไม่กี่ตัว ในโปรเจกต์จริงควรคิดเรื่อง type ให้ดี จะใช้ enum เดียวทั้งแอป แยกตาม module หรือแบบอื่น ก็ขึ้นกับโครงสร้างโปรเจกต์นั้นๆ แต่ typed value แทบจะดีกว่า raw string เสมอ: ค้นโค้ดง่ายกว่า refactor ง่ายกว่า

AppLogger.shared.log(.error, "payment_failed", domain: .checkout, metadata: ["reason": "timeout"])
AppLogger.shared.log(.info, "screen_appeared", domain: .checkout)

os.Logger ของ Apple เองก็แยกสองแกนนี้ไว้ให้แล้วโดยดีฟอลต์: category คือ domain นั่นเอง ส่วนการเรียก .error/.info คือ severity แยกกันตั้งแต่ระดับ API ไม่ใช่แค่ข้อตกลงภายในทีม:

import os

private let checkoutLog = Logger(subsystem: "com.example.app", category: "checkout")
checkoutLog.error("payment_failed reason=timeout")
checkoutLog.info("screen_appeared")

นี่คือเหตุผลที่ในรายชื่อ destination ตอนสร้าง AppLogger มี NativeLoggerDestination อยู่ด้วย มันแค่ส่งต่อ domain ไปเป็น category และ severity ไปเป็น level ของ os.Logger แล้ว structured log ก็ยังอยู่บนเครื่องได้ (Console.app, sysdiagnose) แม้ไม่มีเครือข่ายและไม่มี vendor SDK เลย

3. Log การเปลี่ยนสถานะของแอปอย่างชัดเจน บั๊กที่น่าปวดหัวหลายตัวมาจาก lifecycle ผู้ใช้สลับแอปไปมา กลับเข้ามาจาก push แล้วเปิด flow เดิมอีกทางหนึ่ง เราทดสอบทุก combination ด้วยมือไม่ได้ แต่ผู้ใช้จะเจอเองในที่สุด จะ hook ผ่าน NotificationCenter ก็ได้ หรือถ้าแอปสร้างบน UIScene ก็ทำตรงๆ ในเมธอดของ UIWindowSceneDelegate ได้เลย ซึ่งแม่นยำกว่าสำหรับแอปที่มีหลาย scene:

NotificationCenter.default.addObserver(
    forName: UIApplication.didEnterBackgroundNotification,
    object: nil, queue: .main
) { _ in AppLogger.shared.log(.info, "app.entered_background", domain: .lifecycle) }

หรือแบบเดียวกันนี้ในเมธอดของ delegate ถ้าแอปสร้างบน UIScene:

func sceneDidEnterBackground(_ scene: UIScene) {
    AppLogger.shared.log(.info, "app.entered_background", domain: .lifecycle)
}

ก่อนจะเพิ่ม hook ของตัวเอง ควรเช็กก่อนว่า SDK ที่ติดตั้งไว้ log สิ่งนี้อยู่แล้วหรือเปล่า Sentry มี automatic session tracking ที่ผูกกับ foreground/background transition พอดี ไม่ต้องทำซ้ำเองด้วยมือ

4. Breadcrumbs แต่ต้องขัดข้อมูลลับออกก่อน ไม่ใช่ปล่อยดิบๆ Sentry เก็บ breadcrumbs อัตโนมัติ network request, การนำทาง, การแตะหน้าจอ ซึ่งมีประโยชน์ แต่นี่คือเหตุผลที่ต้องขัดข้อมูลลับไว้ก่อน: ถ้า token ถูกส่งเป็น query parameter Sentry ก็จะ log มันไปด้วยอย่างไม่ลังเล ต้องผูก logic ขัดข้อมูลไว้ก่อนส่ง ไม่ใช่หลังส่ง อย่าหวังว่าระบบจะจัดการเองได้:

SentrySDK.configureScope { scope in
    scope.setBeforeBreadcrumb { crumb in
        if var url = crumb.data?["url"] as? String {
            url = redactQueryParams(url, keys: ["token", "access_token"])
            crumb.data?["url"] = url
        }
        return crumb
    }
}

5. Property ของผู้ใช้ ถ้าทำได้ แต่เอาเฉพาะที่ใช้จริงในแดชบอร์ด ไม่ใช่ใส่ไว้ “เผื่อ”

6. Stack ของหน้าจอ ระดับ advanced ตามด้วยมือยาก พอมี transition แบบ custom, modal ที่แสดงทับ tab bar และ navigation controller ซ้อนกันหลายชั้น การรู้ว่า “ผู้ใช้อยู่หน้าไหนจริงๆ” ไม่ใช่เรื่องง่าย บาง SDK จัดการให้อัตโนมัติ Firebase Analytics แอบ swizzle viewDidAppear แล้วยิง screen_view เอง แต่ชื่อหน้าจอที่ได้จะเป็นชื่อ class ซึ่งไม่ใช่สิ่งที่อยากเห็นในแดชบอร์ดเสมอไป

// screen_view อัตโนมัติจาก Firebase จะขึ้นเป็น "CheckoutReviewViewController"
// override ตรงๆ ในจุดที่ชื่อ class ไม่สื่อความหมายอะไรเลย:
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
    AnalyticsParameterScreenName: "checkout_review",
    AnalyticsParameterScreenClass: String(describing: type(of: self)),
])

7. จุดที่ผู้ใช้หรือ tester กดส่ง log ได้เอง การส่งอัตโนมัติอย่างเดียวไม่พอเสมอ ถ้า tester เจอบั๊กแปลกๆ ปุ่ม “ส่ง log” มีประโยชน์กว่าการให้เขาย้อนเล่าทุกขั้นตอนจากความจำ จุดเข้าถึงนี้ต้องเข้าถึงได้ในทุกสถานะของแอป ทั้งตอน login แล้วและยังไม่ login บั๊กอาจเกิดขึ้นตรงหน้าจอ login เองก็ได้ ก่อนที่ผู้ใช้จะยืนยันตัวตนด้วยซ้ำ นั่นหมายความว่าต้องเก็บ log ไว้ในเครื่องล่วงหน้าก่อนที่มันจะถูกส่งไปไหน แล้วค่อย flush ออกตอนมีการกดปุ่มชัดเจน:

enum DiagnosticLogBuffer {
    static func append(_ line: String) {
        // ring buffer จำกัดขนาด เก็บลงดิสก์
    }

    static func flushAndSend(reason: String) {
        SupportAPI.uploadDiagnostics(readAll(), reason: reason)
    }
}

// จุดเข้าถึงที่มองเห็นได้ debug menu, หน้าซัพพอร์ต, shake gesture
Button("ส่งข้อมูลวินิจฉัย") {
    DiagnosticLogBuffer.flushAndSend(reason: "user_initiated")
}

เรื่องนี้ไม่จำเป็นต้องมีเซิร์ฟเวอร์ของตัวเองเสมอไป สำหรับทีมเล็กมีวิธีที่ง่ายกว่า ง่ายกว่าคือ export buffer ออกมาเป็นไฟล์แล้วโยนให้ native share sheet จัดการ ใช้ได้ทั้งกับ tester และผู้ใช้ทั่วไป ถ้าบั๊กหลุดไปถึง production จริงๆ ใครส่งก็ตัดสินใจเองว่าจะส่งไป Slack, AirDrop หรืออีเมล ปกติใน Slack ก็มีที่รับอยู่แล้วชัดเจน เช่นแชนแนลซัพพอร์ตหรือแชนแนลทีมที่ข้อความแบบนี้ไหลเข้าไปอยู่แล้ว เลยไม่ต้องคิดปลายทางใหม่ และไม่ต้องตั้ง infrastructure อะไรเพิ่มเลย:

func shareDiagnosticLogs(from viewController: UIViewController) {
    let fileURL = DiagnosticLogBuffer.exportToFile()
    let activity = UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
    viewController.present(activity, animated: true)
}

จะส่งอะไรเข้า analytics

พอมี interface เดียวแล้วจะเริ่มอยากส่งทุกอย่างเข้า analytics ทั้ง tap ทั้ง screen ทั้ง state เล็กๆ ผมไม่แนะนำแบบนั้น ถ้าส่งทุกอย่างเข้าไปหมด แดชบอร์ดจะกลายเป็นสัญญาณรบกวนแบบเดียวกับที่พูดถึงตอนต้นโพสต์ แค่เป็นฝั่งโปรดักต์แทนที่จะเป็นฝั่งเทคนิค

ควรเลือกอย่างมีเหตุผล: event สำคัญด้านโปรดักต์ (checkout_started, payment_failed, onboarding_completed) และควรใส่ error ที่วิกฤตจริงๆ ที่ทำให้ flow ของผู้ใช้พังเข้าไปด้วย เพราะเห็นมันอยู่ใน funnel ตรงขั้นตอนที่เกิดขึ้นจะมีประโยชน์กว่าอยู่ใน log แยกที่ตัดขาดจาก context ของผู้ใช้คนนั้น ส่วนรายละเอียดทางเทคนิคดิบๆ stack trace, สถานะระหว่างทาง, ข้อมูล debug ไม่ควรเอาเข้าไปตรงนี้ เพราะมี logging และ crash reporting ที่พูดถึงไปแล้วข้างบนทำหน้าที่นั้นอยู่

เครื่องมือทุกตัวมีข้อจำกัด

เครื่องมือแต่ละตัวมีข้อจำกัด บางตัวแพงเร็ว บางตัวค้นข้อมูลไม่สะดวก และบางตัวเหมาะกับปัญหาบางแบบมากกว่าอีกแบบ โดยปกติแล้วการใช้หลายระบบพร้อมกันฉลาดกว่าการฝากทุกอย่างไว้กับระบบเดียว เพราะแต่ละระบบมีจุดแข็งจุดอ่อนต่างกัน และจะรู้จริงๆ ก็ตอนใช้งานในโปรเจกต์เชิงพาณิชย์จริงเท่านั้น

มีอีกเรื่องที่ควรรู้เกี่ยวกับ crash reporting รายงานไม่ได้ขึ้น server ในวินาทีที่แอป crash ตอนนั้น process กำลังพังอยู่ SDK เลยมักเก็บรายงานไว้ในเครื่องก่อน Firebase Crashlytics เช่น จะอัปโหลดมันตอนที่แอปเปิดครั้งถัดไปเท่านั้น ถ้าผู้ใช้ไม่เปิดแอปอีกเลย รายงานนั้นอาจไม่มีวันไปถึงเซิร์ฟเวอร์เลย หรือไปถึงช้ามาก

Sentry ถูกออกแบบมาต่างออกไป และจับสิ่งที่ crash reporter ทั่วไปมองไม่เห็นตั้งแต่แรก อาการแอปค้าง (App Hangs บน iOS, ANR บน Android) คือกรณีที่ main thread ถูกบล็อกอยู่หลายวินาทีแต่แอปไม่ได้ crash จริงๆ

สำหรับเคสที่วิกฤตจริงๆ การรอ “เปิดแอปครั้งถัดไป” ไม่ได้สะดวกเสมอไป และตรงนี้เองที่กลไกเดียวกับข้อ 7 ด้านบนมีประโยชน์ ถ้าแอปมีจุดที่เก็บข้อมูลทั้งหมดแล้วส่งออกไปอยู่แล้ว (share sheet ตัวเดิมนั่นแหละ) ก็ใช้ตัวนั้นได้เลยแม้ในเคสนี้ ไม่ต้องสร้างช่องทางแยกไว้เฉพาะเหตุฉุกเฉิน

และควรจำเรื่อง limit ไว้ต่างหาก โดยเฉพาะกับเครื่องมือแบบเสียเงิน พร้อมทั้งมีทางปิดระบบ logging ได้เสมอ ถ้ามันเริ่มกลายเป็นตัวปัญหาเองซะเอง