Skillshub axiom-metrickit-ref
MetricKit API reference for field diagnostics - MXMetricPayload, MXDiagnosticPayload, MXCallStackTree parsing, crash and hang collection
git clone https://github.com/ComeOnOliver/skillshub
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/CharlesWiltgen/Axiom/axiom-metrickit-ref" ~/.claude/skills/comeonoliver-skillshub-axiom-metrickit-ref && rm -rf "$T"
skills/CharlesWiltgen/Axiom/axiom-metrickit-ref/SKILL.mdMetricKit API Reference
Complete API reference for collecting field performance metrics and diagnostics using MetricKit.
Overview
MetricKit provides aggregated, on-device performance and diagnostic data from users who opt into sharing analytics. Data is delivered daily (or on-demand in development).
When to Use This Reference
Use this reference when:
- Setting up MetricKit subscriber in your app
- Parsing MXMetricPayload or MXDiagnosticPayload
- Symbolicating MXCallStackTree crash data
- Understanding background exit reasons (jetsam, watchdog)
- Integrating MetricKit with existing crash reporters
For hang diagnosis workflows, see
axiom-hang-diagnostics.
For general profiling with Instruments, see axiom-performance-profiling.
For memory debugging including jetsam, see axiom-memory-debugging.
Common Gotchas
- 24-hour delay — MetricKit data arrives once daily; it's not real-time debugging
- Call stacks require symbolication — MXCallStackTree frames are unsymbolicated; keep dSYMs
- Opt-in only — Only users who enable "Share with App Developers" contribute data
- Aggregated, not individual — You get counts and averages, not per-user traces
- Simulator doesn't work — MetricKit only collects on physical devices
iOS Version Support:
| Feature | iOS Version |
|---|---|
| Basic metrics (battery, CPU, memory) | iOS 13+ |
| Diagnostic payloads | iOS 14+ |
| Hang diagnostics | iOS 14+ |
| Launch diagnostics | iOS 16+ |
| Immediate delivery in dev | iOS 15+ |
Part 1: Setup
Basic Integration
import MetricKit class AppMetricsSubscriber: NSObject, MXMetricManagerSubscriber { override init() { super.init() MXMetricManager.shared.add(self) } deinit { MXMetricManager.shared.remove(self) } // MARK: - MXMetricManagerSubscriber func didReceive(_ payloads: [MXMetricPayload]) { for payload in payloads { processMetrics(payload) } } func didReceive(_ payloads: [MXDiagnosticPayload]) { for payload in payloads { processDiagnostics(payload) } } }
Registration Timing
Register subscriber early in app lifecycle:
@main struct MyApp: App { @StateObject private var metricsSubscriber = AppMetricsSubscriber() var body: some Scene { WindowGroup { ContentView() } } }
Or in AppDelegate:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { metricsSubscriber = AppMetricsSubscriber() return true }
Development Testing
In iOS 15+, trigger immediate delivery via Debug menu:
Xcode > Debug > Simulate MetricKit Payloads
Or programmatically (debug builds only):
#if DEBUG // Payloads delivered immediately in development // No special code needed - just run and wait #endif
Part 2: MXMetricPayload
MXMetricPayload contains aggregated performance metrics from the past 24 hours.
Payload Structure
func processMetrics(_ payload: MXMetricPayload) { // Time range for this payload let start = payload.timeStampBegin let end = payload.timeStampEnd // App version that generated this data let version = payload.metaData?.applicationBuildVersion // Access specific metric categories if let cpuMetrics = payload.cpuMetrics { processCPU(cpuMetrics) } if let memoryMetrics = payload.memoryMetrics { processMemory(memoryMetrics) } if let launchMetrics = payload.applicationLaunchMetrics { processLaunches(launchMetrics) } // ... other categories }
CPU Metrics (MXCPUMetric)
func processCPU(_ metrics: MXCPUMetric) { // Cumulative CPU time let cpuTime = metrics.cumulativeCPUTime // Measurement<UnitDuration> // iOS 14+: CPU instruction count if #available(iOS 14.0, *) { let instructions = metrics.cumulativeCPUInstructions // Measurement<Unit> } }
Memory Metrics (MXMemoryMetric)
func processMemory(_ metrics: MXMemoryMetric) { // Peak memory usage let peakMemory = metrics.peakMemoryUsage // Measurement<UnitInformationStorage> // Average suspended memory let avgSuspended = metrics.averageSuspendedMemory // MXAverage<UnitInformationStorage> }
Launch Metrics (MXAppLaunchMetric)
func processLaunches(_ metrics: MXAppLaunchMetric) { // First draw (cold launch) histogram let firstDrawHistogram = metrics.histogrammedTimeToFirstDraw // Resume time histogram let resumeHistogram = metrics.histogrammedApplicationResumeTime // Optimized time to first draw (iOS 15.2+) if #available(iOS 15.2, *) { let optimizedLaunch = metrics.histogrammedOptimizedTimeToFirstDraw } // Parse histogram buckets for bucket in firstDrawHistogram.bucketEnumerator { if let bucket = bucket as? MXHistogramBucket<UnitDuration> { let start = bucket.bucketStart // e.g., 0ms let end = bucket.bucketEnd // e.g., 100ms let count = bucket.bucketCount // Number of launches in this range } } }
Application Exit Metrics (MXAppExitMetric) — iOS 14+
@available(iOS 14.0, *) func processExits(_ metrics: MXAppExitMetric) { let fg = metrics.foregroundExitData let bg = metrics.backgroundExitData // Foreground (onscreen) exits let fgNormal = fg.cumulativeNormalAppExitCount let fgWatchdog = fg.cumulativeAppWatchdogExitCount let fgMemoryLimit = fg.cumulativeMemoryResourceLimitExitCount let fgMemoryPressure = fg.cumulativeMemoryPressureExitCount let fgBadAccess = fg.cumulativeBadAccessExitCount let fgIllegalInstruction = fg.cumulativeIllegalInstructionExitCount let fgAbnormal = fg.cumulativeAbnormalExitCount // Background exits let bgSuspended = bg.cumulativeSuspendedWithLockedFileExitCount let bgTaskTimeout = bg.cumulativeBackgroundTaskAssertionTimeoutExitCount let bgCPULimit = bg.cumulativeCPUResourceLimitExitCount }
Scroll Hitch Metrics (MXAnimationMetric) — iOS 14+
@available(iOS 14.0, *) func processHitches(_ metrics: MXAnimationMetric) { // Scroll hitch rate (hitches per scroll) let scrollHitchRate = metrics.scrollHitchTimeRatio // Double (0.0 - 1.0) }
Disk I/O Metrics (MXDiskIOMetric)
func processDiskIO(_ metrics: MXDiskIOMetric) { let logicalWrites = metrics.cumulativeLogicalWrites // Measurement<UnitInformationStorage> }
Network Metrics (MXNetworkTransferMetric)
func processNetwork(_ metrics: MXNetworkTransferMetric) { let cellUpload = metrics.cumulativeCellularUpload let cellDownload = metrics.cumulativeCellularDownload let wifiUpload = metrics.cumulativeWifiUpload let wifiDownload = metrics.cumulativeWifiDownload }
Signpost Metrics (MXSignpostMetric)
Track custom operations with signposts:
// In your code: emit signposts import os.signpost let log = MXMetricManager.makeLogHandle(category: "ImageProcessing") func processImage(_ image: UIImage) { mxSignpost(.begin, log: log, name: "ProcessImage") // ... do work ... mxSignpost(.end, log: log, name: "ProcessImage") } // In metrics subscriber: read signpost data func processSignposts(_ metrics: MXSignpostMetric) { let name = metrics.signpostName let category = metrics.signpostCategory // Histogram of durations let histogram = metrics.signpostIntervalData.histogrammedSignpostDurations // Total count let count = metrics.totalCount }
Exporting Payload as JSON
func exportPayload(_ payload: MXMetricPayload) { // JSON representation for upload to analytics let jsonData = payload.jsonRepresentation() // Or as Dictionary if let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { uploadToAnalytics(json) } }
Part 3: MXDiagnosticPayload — iOS 14+
MXDiagnosticPayload contains diagnostic reports for crashes, hangs, disk write exceptions, and CPU exceptions.
Payload Structure
@available(iOS 14.0, *) func processDiagnostics(_ payload: MXDiagnosticPayload) { // Crash diagnostics if let crashes = payload.crashDiagnostics { for crash in crashes { processCrash(crash) } } // Hang diagnostics if let hangs = payload.hangDiagnostics { for hang in hangs { processHang(hang) } } // Disk write exceptions if let diskWrites = payload.diskWriteExceptionDiagnostics { for diskWrite in diskWrites { processDiskWriteException(diskWrite) } } // CPU exceptions if let cpuExceptions = payload.cpuExceptionDiagnostics { for cpuException in cpuExceptions { processCPUException(cpuException) } } }
MXCrashDiagnostic
@available(iOS 14.0, *) func processCrash(_ diagnostic: MXCrashDiagnostic) { // Call stack tree (needs symbolication) let callStackTree = diagnostic.callStackTree // Crash metadata let signal = diagnostic.signal // e.g., SIGSEGV let exceptionType = diagnostic.exceptionType // e.g., EXC_BAD_ACCESS let exceptionCode = diagnostic.exceptionCode let terminationReason = diagnostic.terminationReason // Virtual memory info let virtualMemoryRegionInfo = diagnostic.virtualMemoryRegionInfo // Unique identifier for grouping similar crashes // (not available - use call stack signature) }
MXHangDiagnostic
@available(iOS 14.0, *) func processHang(_ diagnostic: MXHangDiagnostic) { // How long the hang lasted let duration = diagnostic.hangDuration // Measurement<UnitDuration> // Call stack when hang occurred let callStackTree = diagnostic.callStackTree }
MXDiskWriteExceptionDiagnostic
@available(iOS 14.0, *) func processDiskWriteException(_ diagnostic: MXDiskWriteExceptionDiagnostic) { // Total bytes written that triggered exception let totalWrites = diagnostic.totalWritesCaused // Measurement<UnitInformationStorage> // Call stack of writes let callStackTree = diagnostic.callStackTree }
MXCPUExceptionDiagnostic
@available(iOS 14.0, *) func processCPUException(_ diagnostic: MXCPUExceptionDiagnostic) { // Total CPU time that triggered exception let totalCPUTime = diagnostic.totalCPUTime // Measurement<UnitDuration> // Total sampled time let totalSampledTime = diagnostic.totalSampledTime // Call stack of CPU-intensive code let callStackTree = diagnostic.callStackTree }
Part 4: MXCallStackTree
MXCallStackTree contains stack frames from diagnostics. Frames are NOT symbolicated—you must symbolicate using your dSYM.
Structure
@available(iOS 14.0, *) func parseCallStackTree(_ tree: MXCallStackTree) { // JSON representation let jsonData = tree.jsonRepresentation() // Parse the JSON guard let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], let callStacks = json["callStacks"] as? [[String: Any]] else { return } for callStack in callStacks { guard let threadAttributed = callStack["threadAttributed"] as? Bool, let frames = callStack["callStackRootFrames"] as? [[String: Any]] else { continue } // threadAttributed = true means this thread caused the issue if threadAttributed { parseFrames(frames) } } } func parseFrames(_ frames: [[String: Any]]) { for frame in frames { // Binary image UUID (match to dSYM) let binaryUUID = frame["binaryUUID"] as? String // Address offset within binary let offsetIntoBinaryTextSegment = frame["offsetIntoBinaryTextSegment"] as? Int // Binary name (e.g., "MyApp", "UIKitCore") let binaryName = frame["binaryName"] as? String // Address (for symbolication) let address = frame["address"] as? Int // Sample count (how many times this frame appeared) let sampleCount = frame["sampleCount"] as? Int // Sub-frames (tree structure) let subFrames = frame["subFrames"] as? [[String: Any]] } }
JSON Structure Example
{ "callStacks": [ { "threadAttributed": true, "callStackRootFrames": [ { "binaryUUID": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890", "offsetIntoBinaryTextSegment": 123456, "binaryName": "MyApp", "address": 4384712345, "sampleCount": 10, "subFrames": [ { "binaryUUID": "F1E2D3C4-B5A6-7890-1234-567890ABCDEF", "offsetIntoBinaryTextSegment": 78901, "binaryName": "UIKitCore", "address": 7234567890, "sampleCount": 10 } ] } ] } ] }
Symbolication
MetricKit call stacks are unsymbolicated. To symbolicate:
- Keep your dSYM files for every App Store build
- Match UUID from
to your dSYMbinaryUUID - Use atos to symbolicate:
# Find dSYM for binary UUID mdfind "com_apple_xcode_dsym_uuids == A1B2C3D4-E5F6-7890-ABCD-EF1234567890" # Symbolicate address atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x100000000 0x105234567
Or use a crash reporting service that handles symbolication (Crashlytics, Sentry, etc.).
Part 5: MXBackgroundExitData
Track why your app was terminated in the background:
@available(iOS 14.0, *) func analyzeBackgroundExits(_ data: MXBackgroundExitData) { // Normal exits (user closed, system reclaimed) let normal = data.cumulativeNormalAppExitCount // Memory issues let memoryLimit = data.cumulativeMemoryResourceLimitExitCount // Exceeded memory limit let memoryPressure = data.cumulativeMemoryPressureExitCount // Jetsam // Crashes let badAccess = data.cumulativeBadAccessExitCount // SIGSEGV let illegalInstruction = data.cumulativeIllegalInstructionExitCount // SIGILL let abnormal = data.cumulativeAbnormalExitCount // Other crashes // System terminations let watchdog = data.cumulativeAppWatchdogExitCount // Timeout during transition let taskTimeout = data.cumulativeBackgroundTaskAssertionTimeoutExitCount // Background task timeout let cpuLimit = data.cumulativeCPUResourceLimitExitCount // Exceeded CPU quota let lockedFile = data.cumulativeSuspendedWithLockedFileExitCount // File lock held }
Exit Type Interpretation
| Exit Type | Meaning | Action |
|---|---|---|
| Clean exit | None (expected) |
| Used too much memory | Reduce footprint |
| Jetsam (system reclaimed) | Reduce background memory to <50MB |
| SIGSEGV crash | Check null pointers, invalid memory |
| SIGILL crash | Check invalid function pointers |
| Other crash | Check crash diagnostics |
| Hung during transition | Reduce launch/background work |
| Didn't end background task | Call properly |
| Too much background CPU | Move to BGProcessingTask |
| Held file lock while suspended | Release locks before suspend |
Part 6: Integration Patterns
Upload to Analytics Service
class MetricsUploader { func upload(_ payload: MXMetricPayload) { let jsonData = payload.jsonRepresentation() var request = URLRequest(url: analyticsEndpoint) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = jsonData URLSession.shared.dataTask(with: request) { _, response, error in if let error = error { // Queue for retry self.queueForRetry(jsonData) } }.resume() } }
Combine with Crash Reporter
class HybridCrashReporter: MXMetricManagerSubscriber { let crashlytics: Crashlytics // or Sentry, etc. func didReceive(_ payloads: [MXDiagnosticPayload]) { for payload in payloads { // MetricKit captures crashes that traditional reporters might miss // (e.g., watchdog kills, memory pressure exits) if let crashes = payload.crashDiagnostics { for crash in crashes { crashlytics.recordException( name: crash.exceptionType?.description ?? "Unknown", reason: crash.terminationReason ?? "MetricKit crash", callStack: parseCallStack(crash.callStackTree) ) } } } } }
Alert on Regressions
class MetricsMonitor: MXMetricManagerSubscriber { let thresholds = MetricThresholds( launchTime: 2.0, // seconds hangRate: 0.01, // 1% of sessions memoryPeak: 200 // MB ) func didReceive(_ payloads: [MXMetricPayload]) { for payload in payloads { checkThresholds(payload) } } private func checkThresholds(_ payload: MXMetricPayload) { // Check launch time if let launches = payload.applicationLaunchMetrics { let p50 = calculateP50(launches.histogrammedTimeToFirstDraw) if p50 > thresholds.launchTime { sendAlert("Launch time regression: \(p50)s > \(thresholds.launchTime)s") } } // Check memory if let memory = payload.memoryMetrics { let peakMB = memory.peakMemoryUsage.converted(to: .megabytes).value if peakMB > Double(thresholds.memoryPeak) { sendAlert("Memory peak regression: \(peakMB)MB > \(thresholds.memoryPeak)MB") } } } }
Part 7: Best Practices
Do
- Register subscriber early — In
or App initapplication(_:didFinishLaunchingWithOptions:) - Keep dSYM files — Required for symbolicating call stacks
- Upload payloads to server — Local processing loses data on uninstall
- Set up alerting — Detect regressions before users report them
- Test with simulated payloads — Xcode Debug menu in iOS 15+
Don't
- Don't rely solely on MetricKit — 24-hour delay, requires user opt-in
- Don't ignore background exits — Jetsam and task timeouts affect UX
- Don't skip symbolication — Raw addresses are unusable
- Don't process on main thread — Payload processing can be expensive
Privacy Considerations
- MetricKit data is aggregated and anonymized
- Data only from users who opted into sharing analytics
- No personally identifiable information
- Safe to upload to your servers
Part 8: MetricKit vs Xcode Organizer
| Feature | MetricKit | Xcode Organizer |
|---|---|---|
| Data source | Devices running your app | App Store Connect aggregation |
| Delivery | Daily to your subscriber | On-demand in Xcode |
| Customization | Full access to raw data | Predefined views |
| Symbolication | You must symbolicate | Pre-symbolicated |
| Historical data | Only when subscriber active | Last 16 versions |
| Requires code | Yes | No |
Use both: Organizer for quick overview, MetricKit for custom analytics and alerting.
Resources
WWDC: 2019-417, 2020-10081, 2021-10087
Docs: /metrickit, /metrickit/mxmetricmanager, /metrickit/mxdiagnosticpayload
Skills: axiom-hang-diagnostics, axiom-performance-profiling, axiom-testflight-triage