「健康第一！MetricKitで始めるアプリの健康診断」を見てMetricKitのラッパーを実装してみた

# はじめに
この記事は[and factory.inc Advent Calendar 2024](https://qiita.com/advent-calendar/2024/andfactory) 5日目の記事です。

iOSDC2024のセッション『[**健康第一！MetricKitで始めるアプリの健康診断**](https://fortee.jp/iosdc-japan-2024/proposal/ec8520ce-45d4-4552-b5db-4dcf39f3caf6)』の内容を参考に、MetricKitのWrapperクラスを実装してみました🙌 

本記事では、実際にどのようなコードになったのか一部提示した上で、実装時の課題や対策を簡単にご紹介できれば幸いです。

# TL;DR

- MetricKitのWrapperを実装しました
    - 実装の流れ
        - Wrapperクラスを実装
        - MetricKitから受け取れるデータのうち、Crashlyticsに送信したい内容をError型として用意
            - MetricKitWrapperMetricsData
            - MetricKitWrapperDiagnosticData
        - 利用側が、AsyncStreamもしくはDelegateパターンを用いてデータを受け取れるようにする
    - 実装時の課題
        - MetricKitから受け取ったデータのうち、何をどのような形式で送信するか
            - 指標の選定
            - ヒストグラム形式のデータの扱い方
        - Crashlyticsへの送信処理
            - 一度に送信できる、「致命的でない例外」の数は1セッションあたり8個
                - 仮に、MetricKitから1度に8件以上のデータが流れてきた場合に送信できない

## 基本的な実装内容について紹介

### MetricKitWrapper

- 責務: MetricKitに関する処理をカプセル化する

```swift
// MARK: - MetricKitWrapperDelegate
public protocol MetricKitWrapperDelegate: AnyObject {
    func didReceive(_ data: [MetricKitWrapperMetricsData])
    func didReceive(_ data: [MetricKitWrapperDiagnosticData])
}

// MARK: - MetricKitWrapper
public final class MetricKitWrapper: NSObject {

    private let metricManager = MXMetricManager.shared

    private var metricsContinuation: AsyncStream<[MetricKitWrapperMetricsData]>.Continuation?
    private var diagnosticContinuation: AsyncStream<[MetricKitWrapperDiagnosticData]>.Continuation?

    public weak var delegate: (any MetricKitWrapperDelegate)?

    public init(delegate: (any MetricKitWrapperDelegate)? = nil) {
        self.delegate = delegate
    }

    public func onAppLaunch() {
        metricManager.add(self)
    }

    public func onAppTerminate() {
        metricManager.remove(self)
    }
}

// MARK: - AsyncStream
extension MetricKitWrapper {

    public var metricsStream: AsyncStream<[MetricKitWrapperMetricsData]> {
        let (stream, continuation) = AsyncStream.makeStream(of: [MetricKitWrapperMetricsData].self)
        metricsContinuation = continuation
        return stream
    }

    public var diagnosticStream: AsyncStream<[MetricKitWrapperDiagnosticData]> {
        let (stream, continuation) = AsyncStream.makeStream(of: [MetricKitWrapperDiagnosticData].self)
        diagnosticContinuation = continuation
        return stream
    }
}

// MARK: MXMetricManagerSubscriber
extension MetricKitWrapper: MXMetricManagerSubscriber {

    public nonisolated func didReceive(_ payloads: [MXMetricPayload]) {
        // データソースを現在のアプリバージョンに絞る
        guard let currentAppVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else {
            return
        }
        let currentAppVersionPayloads = payloads.filter(for: currentAppVersion)

        let metricsDataFromHistorgramData = generateMetricsDataForHistogramData(from: currentAppVersionPayloads)
        let metricsDataFromNonHistorgramData = currentAppVersionPayloads.flatMap { generateDataForNonHistogramData(from: $0) }

        let data = metricsDataFromHistorgramData + metricsDataFromNonHistorgramData
        guard !data.isEmpty else {
            return
        }
        delegate?.didReceive(data)
        metricsContinuation?.yield(data)
    }

    public nonisolated func didReceive(_ payloads: [MXDiagnosticPayload]) {
        let data = generateDiagnostics(from: payloads)
        guard !data.isEmpty else {
            return
        }
        delegate?.didReceive(data)
        diagnosticContinuation?.yield(data)
    }
}

/* 以下略 */
```

### MetricKitWrapperMetricsData

- 責務: `MXMetricPayload` 由来のデータを、Crashlytcisに送信したい形式にモデル化したError型

```swift
// MARK: - MetricKitWrapperMetricsData
public enum MetricKitWrapperMetricsData: Error {
    /// アプリ終了理由(フォアグラウンド)
    case appExitForeground(ForegroundAppExitError)
    /// アプリ終了理由(バックグラウンド
    case appExitBackground(BackgroundAppExitError)
    /// 利用状況
    case appUsageMetrics(AppUsageMetrics)
    /// パフォーマンス
    case appPerformanceMetrics(AppPerformanceMetrics)
    
    /* 中略 */
}

// MARK: MetricKitWrapperMetricsData.ForegroundAppExitError
extension MetricKitWrapperMetricsData {

    public enum ForegroundAppExitError: Error {
        /// 正常終了
        case normal(count: Int)
        /// メモリ使用制限
        case memoryLimit(count: Int, averageSuspendedMemory: Double?, peakMemoryUsage: Double?)
        /// クラッシュ
        case crashed(count: Int)
        /// watchdogによる強制終了
        case watchdog(count: Int)
        /// その他 メモリ管理の問題
        case badAccess(count: Int)

        /* 中略 */
    }
}

// MARK: MetricKitWrapperMetricsData.BackgroundAppExitError
extension MetricKitWrapperMetricsData {

    public enum BackgroundAppExitError: Error {
        /// 正常終了
        case normal(count: Int)
        /// メモリ使用制限
        case memoryLimit(count: Int, averageSuspendedMemory: Double?, peakMemoryUsage: Double?)
        /// メモリ超過
        case jetsam(count: Int, averageSuspendedMemory: Double?, peakMemoryUsage: Double?)
        /// クラッシュ
        case crashed(count: Int)
        /// watchdogによる強制終了
        case watchdog(count: Int)
        /// その他 メモリ管理の問題
        case badAccess(count: Int)

        /* 中略 */
    }
}

// MARK: MetricKitWrapperMetricsData.AppUsageMetrics
extension MetricKitWrapperMetricsData {

    public enum AppUsageMetrics: Error {
        /// CPU利用に関するメトリクス
        case cpu(cumulativeCPUTime: Double, cumulativeCPUInstructions: Double)
        /// メモリ利用に関するメトリクス
        case memory(averageSuspendedMemory: Double, peakMemoryUsage: Double)
        /// ネットワーク利用に関するメトリクス
        case network(
            cumulativeCellularDownload: Double,
            cumulativeCellularUpload: Double,
            cumulativeWifiDownload: Double,
            cumulativeWifiUpload: Double
        )
        
        /* 中略 */
    }
}

// MARK: MetricKitWrapperMetricsData.AppPerformanceMetrics
extension MetricKitWrapperMetricsData {

    public enum AppPerformanceMetrics: Error {
        /// 起動時間
        case launchTime(averageTime: Double, unit: String)
        /// ハング時間
        case hangTime(averageTime: Double, unit: String)
        /// スクロールヒッチレート
        case scrollHitchRatio(value: Double, unit: String)
        /// ディスク書き込み
        case diskWrites(value: Double, unit: String)

        /* 中略 */
    }
}

```

### MetricKitWrapperDiagnosticData

- 責務: `MXDiagnosticPayload` 由来のデータを、Crashlytcisに送信したい形式にモデル化したError型

```swift
// MARK: - MetricKitWrapperMetricsData
public enum MetricKitWrapperDiagnosticData: Error {
    /// 起動時間に関するログ
    case appLaunchDiagnostics(value: Double, unit: String, data: Data)
    /// CPU利用に関するログ
    case cpuExceptionDiagnostics(value: Double, unit: String, data: Data)
    /// クラッシュに関するログ
    case crashDiagnostics(terminationReason: String?, data: Data)
    /// ディスク書き込みに関するログ
    case diskWriteExceptionDiagnostics(value: Double, unit: String, data: Data)
    
    /* 以下略 */
}
```

## Crashlytics上の見え方

### MetricKitWrapperMetricsDataの一例

![metrics_data.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605495/ca0e1aa6-9afd-ee7b-35ab-51bfb02c19dc.png)

### MetricKitWrapperDiagnosticDataの一例

![diagnstics_data.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605495/277c94f7-d51d-1e27-6c91-9772e63fa4e4.png)

※ 診断データのログは長すぎて、全て収まる形でCrashlyticsに送るのは現実的ではなさそうです


# 制作時に発生した課題とその対策

## MetricKitから受け取ったデータのうち、何をどのような形式で送信するか

### 指標の選定

- MetricKitから受け取れる指標は多岐に渡ります
    - 詳細は以下公式ドキュメントをご覧ください
        - [MXMetricPayload](https://developer.apple.com/documentation/metrickit/mxmetricpayload)
        - [MXDiagnosticPayload](https://developer.apple.com/documentation/metrickit/mxdiagnosticpayload)
- 前提として、どの指標をどのような形式で送るかについては、プロダクトの状況やニーズに合わせて変わってくるものだと考えています
- 今回は、取得できる指標のうち自社プロダクトの改善につながりそうな指標を幅広く取得対象にし、まずは可視化してみるという方針を取りました
    - 場合によっては、Crashlyticsに送信できる容量を超えてしまう可能性もあるかもしれませんが、一つの参考になれば幸いです
    - 具体的な内容については、[MetricKitWrapperMetricsData](https://qiita.com/ykkd/private/f610a691107f35670caa#metrickitwrappermetricsdata)と[MetricKitWrapperDiagnosticData](https://qiita.com/ykkd/private/f610a691107f35670caa#metrickitwrapperdiagnosticdata)のcaseをご覧ください

### ヒストグラム形式のデータの扱い方

- MetricKitから受け取れるデータの一部に `MXHistogram` 型があります
- ヒストグラムの情報を全てCrashlyticsに送信するのは現実的ではないですし、閲覧する際に手間がかかりそうです
- 今回は、[SwiftLee氏の記事](https://www.avanderlee.com/swift/metrickit-launch-time/)を参考にヒストグラムデータの平均値を計算し、送信する対応をしました
    - 実装例
    
    ```swift
    
    // MARK: - HistogrammedTimeMetric
    /// - Note:
    ///  ヒストグラムデータから平均時間を取得するためのProtocol
    ///  extensionなどの関連処理もこのProtocolが定義されているファイルにまとめています
    ///
    /// [reference](https://www.avanderlee.com/swift/metrickit-launch-time)
    protocol HistogrammedTimeMetric {
        var histogram: MXHistogram<UnitDuration> { get }
        var average: Measurement<UnitDuration> { get }
    }
    
    extension HistogrammedTimeMetric {
        /// Calculates the average duration in milliseconds for the given histogram values.
        var average: Measurement<UnitDuration> {
            let buckets = histogram.bucketEnumerator.compactMap { $0 as? MXHistogramBucket }
            let totalBucketsCount = buckets.reduce(0) { totalCount, bucket in
                var totalCount = totalCount
                totalCount += bucket.bucketCount
                return totalCount
            }
            let totalDurations: Double = buckets.reduce(0) { totalDuration, bucket in
                var totalDuration = totalDuration
                totalDuration += Double(bucket.bucketCount) * bucket.bucketEnd.value
                return totalDuration
            }
            let average = totalDurations / Double(totalBucketsCount)
            return Measurement(value: average, unit: UnitDuration.milliseconds)
        }
    }
    
    extension [MXMetricPayload] {
    
        /// Calculates the average Metric value for all payloads containing the given key path for the given application version.
        func average(for keyPath: KeyPath<MXMetricPayload, (some HistogrammedTimeMetric)?>) -> Measurement<UnitDuration>? {
            let averages = compactMap { payload in
                payload[keyPath: keyPath]?.average.value
            }
    
            guard !averages.isEmpty else {
                return nil
            }
    
            let average: Double = averages.reduce(0.0, +) / Double(averages.count)
            guard !average.isNaN else {
                return nil
            }
    
            return Measurement(value: average, unit: UnitDuration.milliseconds)
        }
    
        func filter(for applicationVersion: String) -> [MXMetricPayload] {
            filter { payload in
                guard !payload.includesMultipleApplicationVersions else {
                    // We only want to use payloads for the latest app version
                    return false
                }
                return payload.latestApplicationVersion == applicationVersion
            }
        }
    }
    
    // MARK: - MXAppLaunchMetric + HistogrammedTimeMetric
    extension MXAppLaunchMetric: HistogrammedTimeMetric {
        var histogram: MXHistogram<UnitDuration> {
            histogrammedTimeToFirstDraw
        }
    }
    
    // MARK: - MXAppResponsivenessMetric + HistogrammedTimeMetric
    extension MXAppResponsivenessMetric: HistogrammedTimeMetric {
        var histogram: MXHistogram<UnitDuration> {
            histogrammedApplicationHangTime
        }
    }
    ```
    

## Crashlyticsへの送信処理

### 一度に送信できる、「致命的でない例外」の数は1セッションあたり8個

- 今回の実装では、MetricKitから受け取った[MXMetricPayload](https://developer.apple.com/documentation/metrickit/mxmetricpayload)および[MXDiagnosticPayload](https://developer.apple.com/documentation/metrickit/mxdiagnosticpayload)を、欲しい指標に応じて特定のエラーケースに変換し、Crashlyticsに送信しています
- こちらの記事の通り、1セッションあたりCrashlyticsに送信できる8個の制限があるため、エラーケースの合計数が8件を超えた場合に、古いものから削除され、結果として送信されない問題がありました
    - 参照: [Firebase Crashlytics のクラッシュ レポートのカスタマイズ](https://firebase.google.com/docs/crashlytics/customize-crash-reports?hl=ja&platform=ios)
        
- 今回、最終的に取った対応は以下です
    - 生成されたエラーケースの配列の配列の中からランダムに、MXMetricPayloadの配列と、MXDiagnosticPaylodの配列から、最大4個づつ取り出し（合計8個）、送信しています
- なぜ上記対応を取ったか
    - 今回のケースでは、試験的に導入する段階であるため、取得する指標に優先度を設けていません
        - そのため、流れてきたデータの中からランダムに送信対象を選ぶことで、できるだけ多種のデータが取れるように考慮しています
        - 試験導入の結果可視化が進んできたら、取得する指標に優先度を設けたり、取得対象を絞るべきだと思います
- その他検討した方法
    - CrashlyticsのAPI `sendUnsentReports` の利用
        - 以下のように実装することで、都度送信してしまえば8件の制限を突破できるのではと考えて検証してみましたが、結果は変わりませんでした
            - 無限にデータを送信できてしまうことになるので制限があるのは納得です
        
        ```swift
        /// - Note: Crashlyticsの仕様上、セッションごとに保持するExceptionModelは8個までなので、最大8個ずつ送信する
        /// [reference](https://firebase.google.com/docs/crashlytics/customize-crash-reports?hl=ja&authuser=0&_gl=1*1eyz2cm*_up*MQ..*_ga*MTgxMzIyMDA0Ni4xNzI4MDI0NDUz*_ga_CW55HF8NVT*MTcyODU0NzQwMC45LjEuMTcyODU0ODE1Mi41MS4wLjA.&platform=ios#log-excepts)
        public static func record(_ errors: any ErrorRecordable) {
            let crashlytics = Crashlytics.crashlytics()
            if tryNotDrop {
                var remaining = errors
                Logger.log.debug("\(remaining.count)")
                
                while !remaining.isEmpty {
                    let maxElements = 8 // セッションごとに保持するExceptionModelは8個まで
                    let elementsToProcess = remaining.prefix(maxElements)
                    remaining.removeSubrange(0..<min(maxElements, remaining.count))
                    await sendUnsentReports {
                        try? await Task.sleep(nanoseconds: UInt64(100000000 * 1.0))
                        elementsToProcess.forEach { error in
                            crashlytics.record(error: error, userInfo: error.userInfo)
                        }
                    }
                }
            } else {
                errors.forEach { error in
                    crashlytics.record(error: error, userInfo: error.userInfo)
                }
            }
        }
        
        private static func sendUnsentReports(_ completion: () async -> Void) async {
            let crashlytics = Crashlytics.crashlytics()
            let hasUnsent = await crashlytics.checkForUnsentReports()
            if hasUnsent {
                crashlytics.sendUnsentReports()
            }
            await completion()
        }
        ```
        

# おわりに
Crashlyticsで継続的に指標をモニタリングするのであれば、取得する指標は絞っていく必要があるだろうなと改めて思いました。
全てのデータを可視化したい場合は自前サーバーが欲しくなります。Crashlyticsで運用するなら、セッションで紹介されていた、Jetsamイベントの取得などに絞りたいところ。
とはいえ、アプリ側の工数のみで気軽に着手できるのはFirebaseのおかげですね。

実装について振り返ると、取得できるデータについて把握し、送信したい形式にモデル化するのが少し面倒でした。

MetricKitの導入を検討している方の一助になれば幸いです！

明日の[Advent Calendar](https://qiita.com/advent-calendar/2024/andfactory)の記事もお楽しみに:santa:

# 追記

- 運用改善のために以下機能を追加しました🙌
  - Crashlyticsに送信する指標を指定できる機能を追加
  - 指標ごとに、閾値を指定できるようにしました
    - 閾値を超えた場合のみ、Crashlyticsに送信される

# 参照

[健康第一！MetricKitで始めるアプリの健康診断](https://fortee.jp/iosdc-japan-2024/proposal/ec8520ce-45d4-4552-b5db-4dcf39f3caf6)

[Using MetricKit to monitor user data like launch times](https://www.avanderlee.com/swift/metrickit-launch-time/)

[Firebase Crashlytics のクラッシュ レポートのカスタマイズ](https://firebase.google.com/docs/crashlytics/customize-crash-reports?hl=ja&platform=ios)