“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:

  1. Launch your app and trigger a crash
  2. Fully close the app (swipe up in app switcher)
  3. 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:

  1. Go to File > Swift Packages > Add Package Dependency
  2. Enter the repository URL: https://github.com/zoho/Apptics.git
  3. 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:

  1. Dashboard for crashes reported by MetricKit
  2. User session information alongside crash data
  3. 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