Debug crashes in iOS using MetricKit
“Production only” crashes in iOS apps are notoriously difficult to debug. Traditional in-process crash reporting tools install handlers within your app to capture failure data, but if the app crashes hard enough, these reporters themselves may fail.
Apple introduced MetricKit in iOS 13 to address these limitations. Unlike conventional tools, it operates outside your app’s process, collecting diagnostics at the system level. This approach captures crashes that traditional reporters miss, including those from memory pressure, background terminations, and OS signals.
In this article, we’ll explore how MetricKit helps debug stubborn crashes and complements traditional crash reporting approaches with its system-level capabilities.
What is MetricKit for Crash Debugging?
MetricKit’s crash reporting capabilities provide a system-level framework to collect, analyze, and deliver crash reports to your app. Beyond crashes, MetricKit offers comprehensive diagnostics including performance metrics (CPU/memory usage, launch time, hangs), battery consumption patterns, animation hitches detection, and disk I/O monitoring—all using the same implementation pattern. For crash reporting, it captures detailed diagnostic information including call stacks, exception types, and timestamps of crashes that occur on user devices. This data is collected by iOS and delivered to your app differently depending on which iOS version your users are running:
- iOS 13-14: Crash reports are aggregated by iOS and delivered once per day when the user launches your app after the collection period.
- iOS 15 and later: Crash reports are delivered immediately on the next launch of your app after a crash occurs.
This improvement in iOS 15 significantly reduces the feedback loop for crash diagnostics, allowing you to identify and fix issues much faster.
Setting up MetricKit for Crash Reporting
To begin receiving crash reports through MetricKit, follow these steps:
First, import MetricKit in your AppDelegate:
import MetricKit
Next, make your AppDelegate conform to the MXMetricManagerSubscriber
protocol, focusing on crash diagnostics:
class AppDelegate: UIResponder, UIApplicationDelegate, MXMetricManagerSubscriber {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Register as a subscriber to receive metrics and diagnostics
MXMetricManager.shared.add(self)
return true
}
// Implement the required delegate method for diagnostics
func didReceive(_ payloads: [MXDiagnosticPayload]) {
// Process crash reports
for payload in payloads {
if let crashDiagnostics = payload.crashDiagnostics {
processCrashReports(crashDiagnostics)
}
}
}
func applicationWillTerminate(_ application: UIApplication) {
// Unsubscribe when the app terminates
MXMetricManager.shared.remove(self)
}
}
Processing Crash Reports
Now, let’s implement a method to effectively process the crash reports:
private func processCrashReports(_ crashDiagnostics: [MXCrashDiagnostic]) {
for diagnostic in crashDiagnostics {
print("🚨 CRASH DETECTED 🚨")
// Basic crash information
print("Timestamp: \(diagnostic.timeStamp)")
print("App version: \(diagnostic.applicationVersion)")
print("iOS version: \(diagnostic.metaData.osVersion)")
print("Device type: \(diagnostic.metaData.deviceType)")
// Crash type information
if let exceptionType = diagnostic.exceptionType {
print("Exception type: \(exceptionType)")
}
if let exceptionCode = diagnostic.exceptionCode {
print("Exception code: \(exceptionCode)")
}
if let signal = diagnostic.signal {
print("Signal: \(signal)")
}
// Analyze the call stack
if let callStackTree = diagnostic.callStackTree {
analyzeCrashCallStack(callStackTree)
}
// Log or send to your backend
archiveCrashReport(diagnostic)
}
}
Understanding Crash Call Stacks
The call stack tree is crucial for understanding what led to a crash. Let’s implement a focused analysis method:
private func analyzeCrashCallStack(_ callStackTree: MXCallStackTree) {
// Convert the call stack tree to JSON for easier analysis
if let callStackData = try? JSONSerialization.data(withJSONObject: callStackTree.jsonRepresentation(), options: [.prettyPrinted]),
let jsonString = String(data: callStackData, encoding: .utf8) {
print("🔍 Call Stack Analysis:")
// Look for common crash patterns
if jsonString.contains("EXC_BAD_ACCESS") {
print("Memory access issue detected - likely accessing deallocated memory")
} else if jsonString.contains("EXC_BREAKPOINT") {
print("Exception breakpoint - possibly an unhandled Swift error or assertion")
} else if jsonString.contains("EXC_CRASH") {
print("Kernel terminated process - often due to memory pressure or watchdog timeout")
}
// Find problematic frames in your code
findProblematicFrames(in: jsonString)
}
}
private func findProblematicFrames(in callStackJSON: String) {
// Look for your app's frames in the call stack
if let appNameRange = callStackJSON.range(of: "YourAppName") {
// Extract the surrounding context (simplified example)
let startIndex = callStackJSON.index(appNameRange.lowerBound, offsetBy: -100, limitedBy: callStackJSON.startIndex) ?? callStackJSON.startIndex
let endIndex = callStackJSON.index(appNameRange.upperBound, offsetBy: 100, limitedBy: callStackJSON.endIndex) ?? callStackJSON.endIndex
let frameContext = callStackJSON[startIndex..<endIndex]
print("Potential problem in your code: \(frameContext)")
}
}
Common iOS Crash Types and Their Interpretation
Understanding the crash type is crucial for debugging. Here’s how to interpret common crash signals in iOS:
private func interpretCrashSignal(_ signal: String, code: String?) -> String {
switch signal {
case "SIGSEGV":
return "Segmentation fault: Invalid memory access (likely accessing a nil pointer)"
case "SIGABRT":
return "Abort: Process terminated by system (common in unhandled exceptions, assertions)"
case "SIGBUS":
return "Bus error: Misaligned memory access or non-existent memory address"
case "SIGILL":
return "Illegal instruction: Invalid instruction attempted (rare, possibly corrupted memory)"
case "SIGTRAP":
return "Trap: Often from debug breakpoints or exceptions"
default:
return "Unknown signal: \(signal)"
}
}
Archiving Crash Reports for Analysis
To properly track and analyze crashes over time, implement an archiving system:
private func archiveCrashReport(_ diagnostic: MXCrashDiagnostic) {
// Generate a unique ID for the crash
let crashID = UUID().uuidString
// Create a dictionary with the important crash information
var crashInfo: [String: Any] = [
"id": crashID,
"timestamp": diagnostic.timeStamp.description,
"appVersion": diagnostic.applicationVersion,
"osVersion": diagnostic.metaData.osVersion,
"deviceType": diagnostic.metaData.deviceType
]
// Add optional crash details if available
if let exceptionType = diagnostic.exceptionType {
crashInfo["exceptionType"] = exceptionType
}
if let exceptionCode = diagnostic.exceptionCode {
crashInfo["exceptionCode"] = exceptionCode
}
if let signal = diagnostic.signal {
crashInfo["signal"] = signal
}
// Add call stack if available
if let callStackTree = diagnostic.callStackTree,
let callStackData = try? JSONSerialization.data(withJSONObject: callStackTree.jsonRepresentation(), options: []),
let callStackString = String(data: callStackData, encoding: .utf8) {
crashInfo["callStack"] = callStackString
}
// Save or send the crash info
sendCrashToAnalyticsService(crashInfo)
}
private func sendCrashToAnalyticsService(_ crashInfo: [String: Any]) {
// Here you would implement your specific analytics service integration
// Example:
print("Sending crash to analytics service: \(crashInfo["id"] ?? "unknown")")
// YourAnalyticsService.send(event: "app_crash", properties: crashInfo)
}
Simulating Crashes for Testing MetricKit
While implementing MetricKit’s crash reporting, you’ll need to test whether your implementation works correctly. Let’s explore a few simple ways to simulate crashes in your development builds.
Different Types of Crashes You Can Simulate
Here are some quick and easy ways to simulate various crash types:
1. Array Index Out of Bounds
let array = [1, 2, 3]
let item = array[10] // This crashes immediately
2. Force Unwrapping nil
let text: String? = nil
let unwrapped = text! // Crash!
Testing the Full MetricKit Pipeline
After simulating a crash, follow these steps:
- Launch your app and trigger a crash
- Fully close the app (swipe up in app switcher)
- Launch your app again
That’s it! If you’re testing on iOS 15 or later, MetricKit will deliver the crash report immediately when your app launches again. The didReceive(_ payloads: [MXDiagnosticPayload])
method will be called with the crash data right away.
If you’re testing on iOS 13-14, you’ll need to be more patient as MetricKit only delivers reports once per day on those older iOS versions.
Remember that crashes in the debug environment might behave differently than in production. Some crashes might be caught by the debugger, making them harder to test. For the most accurate results, test crashes on a device with a release configuration but still including the debug crash button.
Integrating with Analytics Services like Zoho Apptics
While MetricKit provides powerful native crash reporting, you might want to integrate it with an analytics service for a more comprehensive solution. Zoho Apptics SDK is a good example as it already leverages MetricKit to collect crash data when available:
Installing Apptics SDK
You can integrate Apptics SDK in your project using either CocoaPods or Swift Package Manager.
Using CocoaPods
Add the following to your Podfile:
pod 'Apptics-Swift', '3.1.0'
Then run:
pod install
Using Swift Package Manager
You can also add Apptics as a dependency in your Package.swift
file:
dependencies: [
.package(url: "https://github.com/zoho/Apptics.git", from: "3.1.0")
]
Or directly in Xcode:
- Go to File > Swift Packages > Add Package Dependency
- Enter the repository URL: https://github.com/zoho/Apptics.git
- Select the version you want to use
Configuring Zoho Apptics
Once installed, you can configure Apptics for crash reporting:
// First, include Apptics SDK in your app. Usually it is done in the AppDelegate
func configureAppticsForCrashReporting() {
// Initialize Apptics SDK with your app's configuration
Apptics.initialize(withVerbose: true)
// Enable crash reporting
Apptics.enableAutomaticCrashTracking(true)
// The SDK will automatically collect MetricKit crash diagnostics
// when available on iOS 14 and above
}
That’s it! It just takes two lines of code. You don’t have to manually handle the MetricKit delegates or process the unstructured data. The Zoho Apptics SDK does it for you!
Benefits of integrating with Apptics:
- Dashboard for crashes reported by MetricKit
- User session information alongside crash data
- Automatic symbolication of crash reports
Conclusion
I hope you’ve learned about the internal workings of MetricKit through this article. I really enjoyed writing about it and sharing this knowledge at IndeHub x Zoho Apptics Chapter 9 and the Swift Summer event at Microsoft Noida. If you’ve read this far, I truly appreciate your dedication to mastering these concepts! Do try implementing these techniques in your projects and let me know your thoughts in the comments below.
References
[1] https://developer.apple.com/documentation/metrickit
[2] https://developer.apple.com/documentation/metrickit/mxcrashdiagnostic
[3] https://developer.apple.com/videos/play/wwdc2020/10089/
About the author
- Rizwan Ahmed -
Developer Relations at Zoho Apptics | Tech Evangelist at Zoho | AI Researcher | iOS Engineer | Blogger at ohmyswift.com | Speaker | Mentor
LinkedIn - https://www.linkedin.com/in/rizwan95/
Twitter - https://twitter.com/rizwanasifahmed
Social media - https://bento.me/rizwan95
More articles
- SwiftUI in 2024: Bridging Perception and Reality
- From viewWillAppear to viewIsAppearing - Perfecting Your iOS View Transitions
- Customizing UIButton in iOS 15
- Different methods to remove the last item from an array in Swift
- Exploring Deque in Swift Collections
- Replacing UIImagePickerController with PHPickerViewController
- Simulating remote push notifications in a simulator
Like our articles? Support us!