
iOS MetricKit Crash Diagnostics: MXCrashDiagnostic Guide
When an iOS app crashes in production, most teams reach for a third-party SDK before they ever inspect what the operating system already hands them for free. Since iOS 14, Apple's MetricKit framework has delivered on-device, first-party diagnostics — MXCrashDiagnostic, MXHangDiagnostic, MXCPUExceptionDiagnostic, and MXDiskWriteExceptionDiagnostic — through a single MXDiagnosticPayload. Learning to read these iOS MetricKit crash diagnostics gives you a privacy-friendly, zero-binary-size window into production stability before you ever pay for a crash SDK. This guide covers subscription, parsing, and the gaps you should close with a real-time reporter.
What MetricKit Sends You, and When
MetricKit launched in iOS 13 as a metrics framework — battery, performance, and memory aggregates surfaced in Xcode's Organizer. The diagnostics half arrived in iOS 14, adding MXDiagnosticPayload and the MXMetricManager delivery mechanism that pushes crash, hang, CPU-exception, and disk-write-exception objects to your code.
Two attributes define the entire mental model. First, delivery is batched and delayed: Apple aggregates diagnostics on the device and delivers them roughly once every 24 hours, bucketed by app version and device model. Second, the data is anonymized and aggregated — no user IDs, no session identifiers, and no breadcrumbs. That is precisely what makes MetricKit genuinely privacy-preserving, and it is also what makes it a complement to, rather than a replacement for, a session-scoped crash reporter. We will return to both limits.
Setting Up MXMetricManager
Subscribing takes a few lines. Create a subscriber class, register it, and hold onto it:
import MetricKit
final class DiagnosticsSubscriber: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
handle(payload)
}
}
}
// AppDelegate or SwiftUI App init:
let subscriber = DiagnosticsSubscriber()
MXMetricManager.shared.add(subscriber)The single most common failure is a dangling reference. MXMetricManager holds subscribers weakly, so a locally-scoped instance is deallocated immediately and you silently stop receiving payloads. Store the subscriber in a property that lives for the app's lifetime — AppDelegate or a long-lived singleton — and call MXMetricManager.shared.remove(subscriber) only if you explicitly need to stop collection.
Each payload carries timeStampBegin and timeStampEnd for its aggregation window plus four typed properties: crashDiagnostics, hangDiagnostics, cpuExceptionDiagnostics, and diskWriteExceptionDiagnostics. Every diagnostic object also exposes jsonRepresentation(), so you can serialize a whole payload and forward it to your backend or write it to disk for offline analysis.
One more note on lifecycle: MXMetricManager also calls didReceive(_ payloads: [MXMetricPayload]) on the same subscriber for the older metrics types, and diagnostics are only available on iOS 14 and later. If you still support iOS 13, guard your subscriber behind an availability check so older devices simply skip registration rather than crash on an unknown selector. The framework is also available on macOS, watchOS, and tvOS, so one shared subscriber can power diagnostics across your whole Apple ecosystem with the same code.
MXCrashDiagnostic: Reading the Crash Payload
MXCrashDiagnostic is the centerpiece of the family. Each object carries a reason — the human-readable termination cause — plus exceptionType and exceptionCode for Mach-level signals, and a structured MXCallStackTree for the crashed thread. It also exposes virtualMemoryRegionInfo so you can see whether the crash was a write to read-only memory or an access outside a mapped region.
func handle(_ payload: MXDiagnosticPayload) {
for crash in payload.crashDiagnostics ?? [] {
let reason = crash.reason ?? "unknown"
let frames = crash.callStackTree.callStackPerThread
.flatMap { $0.flattenedFrames }
print("Crash reason: \(reason)")
for frame in frames.prefix(10) {
print(frame.binaryName, frame.offsetIntoBinaryTextSegment)
}
}
}The exceptionType distinguishes an EXC_BAD_ACCESS-style Mach exception from an Objective-C NSException or a Swift runtime trap. A Swift fatalError() and a force-unwrap both land here as language-level exceptions, while a malloc corruption or a wild pointer shows up as a Mach exception with a nonzero exceptionCode. Reading reason together with exceptionType is usually enough to route the crash to the right owner on your team before you even symbolicate the stack.
Two realities bite immediately. First, frames are reported as binary offsets, not symbol names: Apple symbolicates its own frameworks but leaves your frames as addresses inside your app binary. You still have to run them through your dSYM pipeline to recover function names. If that pipeline is unfamiliar, our guide on mobile crash stack trace symbolication walks through dSYMs and ProGuard mapping end to end. Second, MXCrashDiagnostic only captures terminations the OS can attribute to your process after the fact — watchdog kills from a blocked main thread surface as hangs or CPU exceptions instead. For the 0x8badf00d family specifically, see our iOS watchdog termination guide.
Understanding MXCallStackTree and MXFrame
The MXCallStackTree is not a raw stack trace — it is a structured tree of MXFrame objects, each carrying binaryName, binaryUUID, address, offsetIntoBinaryTextSegment, and an optional sampleCount. flattenedFrames walks the tree in order, giving you the crashed or hung thread's frames from leaf to root. The offsetIntoBinaryTextSegment is the value you feed to atos or your symbolication tool together with the matching dSYM, because MetricKit deliberately omits symbol names for your own code.
A practical trick: since binaryName and binaryUUID are present on every frame, you can bucket crashes by the exact binary slice that faulted. A high sampleCount on one frame across many hang payloads is a strong signal of a hot blocking path even before you symbolicate a single address. Keep the raw jsonRepresentation() of each payload around — re-symbolicating a stored payload later is often faster than waiting for the next 24-hour batch.
MXHangDiagnostic: Freezes the Crash Reporter Misses
Not every failure is a crash. A main thread blocked on a synchronous network call or a deadlock produces an ANR-style hang, and MXHangDiagnostic is where the OS reports it. Each object exposes a hangDuration and a callStackTree capturing exactly where the main thread was stuck.
for hang in payload.hangDiagnostics ?? [] {
let seconds = hang.hangDuration.value
let frames = hang.callStackTree.callStackPerThread
.flatMap { $0.flattenedFrames }
print("Hang of \(seconds)s:")
for frame in frames.prefix(8) {
print(frame.binaryName, frame.offsetIntoBinaryTextSegment)
}
}hangDuration is an NSMeasurement, so check its .unit before assuming seconds. The captured stack points at the blocking frame, which is often exactly the dispatch-semaphore wait or synchronous Data(contentsOf:) you left in a hot path. Because hangs do not produce a crash report in most third-party tools, MetricKit is frequently the only signal you have that a hang is happening at scale. That makes it a natural companion to stability SLOs: our crash-free metrics guide shows how to fold hang data into the same session-rate view.
MXCPUExceptionDiagnostic & MXDiskWriteExceptionDiagnostic
The remaining two types flag the pathologies the OS polices proactively, often right before a hard kill.
MXCPUExceptionDiagnostic captures two conditions: exceeding the CPU-time budget during app startup (a classic driver of 0x8badf00d terminations) and burning CPU in the background beyond the permitted allowance. It exposes totalCPUTime and totalSampledTime, which let you distinguish a genuinely slow launch from background energy abuse. Treat these as leading indicators: a spike here usually precedes the watchdog kill you are about to see.
MXDiskWriteExceptionDiagnostic fires when the app writes more to disk than the system considers reasonable — a runaway logging loop, a cache that never evicts, or a database vacuum storm. totalWritesCaused gives the byte count to correlate with the offending subsystem.
for cpu in payload.cpuExceptionDiagnostics ?? [] {
print("CPU time:", cpu.totalCPUTime)
}
for disk in payload.diskWriteExceptionDiagnostics ?? [] {
print("Disk writes caused:", disk.totalWritesCaused)
}Read these four types as one family: the crash and hang objects tell you what died, while the CPU and disk exceptions tell you what was about to.
Where MetricKit Falls Short
MetricKit is powerful, but it has hard limits you should design around. Delivery is delayed up to 24 hours, so it is useless for incident response. Payloads are aggregated and anonymized — no session IDs, no user identifiers, and no breadcrumbs showing the steps that led to a crash. You cannot attach custom context, and you only observe your own process, not crashes in extensions or the wider device. Finally, your frames arrive unsymbolicated, so you still own the dSYM pipeline.
That gap is exactly what a real-time crash reporter fills. Bugspulse pairs session-scoped, symbolicated crash reporting with breadcrumbs and release monitoring, so the same crash that MetricKit surfaces a day later appears on your dashboard in seconds with the full sequence of events that produced it. MetricKit is the free, privacy-first baseline; Bugspulse is the real-time layer on top.
A Practical Production Setup
A sensible pipeline ships with MetricKit enabled to capture Apple's aggregated, privacy-preserving signal for free, and forwards each payload's JSON to your backend for offline trend analysis. Forwarding is a one-liner on the payload:
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
if let data = try? payload.jsonRepresentation() {
upload(data) // POST to your ingestion endpoint
}
}
}Correlate MetricKit's hang and CPU-exception trends against session-level crash reports and you will catch regressions a crash dashboard alone would miss — like a hang that never crashes but quietly drives uninstalls.
Privacy and Data Retention
Because MetricKit never sends device identifiers or user data off the device in its diagnostics, it clears the privacy bar that blocks many telemetry SDKs in regulated apps. What you get back is structural — call stacks, durations, and byte counts — which is why health and finance teams often enable MetricKit even when they forbid third-party analytics. The trade-off is retention: Apple aggregates and rolls up diagnostics, so the per-crash detail you see in a crash reporter is intentionally unavailable here. Treat MetricKit as your always-on, consent-free baseline signal, and layer a compliant crash reporter on top where session detail is genuinely required.
Start Reading Apple's Diagnostics Today
MetricKit's crash diagnostics are the most underused first-party tool in iOS debugging. Enable MXMetricManager, keep a strong reference to your subscriber, and start collecting MXCrashDiagnostic, MXHangDiagnostic, MXCPUExceptionDiagnostic, and MXDiskWriteExceptionDiagnostic payloads today. Then close the real-time gap: create a free Bugspulse account to get session-scoped, symbolicated crash reports alongside Apple's aggregated diagnostics.