```html SwiftUI: How to Build Crash Reporting (iOS 17+, 2026)

How to build crash reporting in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: MetricKit Updated: May 11, 2026
TL;DR

Conform a singleton class to MXMetricManagerSubscriber, register it via MXMetricManager.shared.add(_:) at launch, then implement didReceive(_:) to persist MXCrashDiagnostic payloads to disk and surface them in a SwiftUI view through an @Observable model.

import MetricKit

@Observable
final class CrashReporter: NSObject, MXMetricManagerSubscriber {
    static let shared = CrashReporter()
    private(set) var crashLogs: [String] = []

    func register() {
        MXMetricManager.shared.add(self)
    }

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            for crash in payload.crashDiagnostics ?? [] {
                let log = crash.dictionaryRepresentation().description
                crashLogs.append(log)
            }
        }
    }
}

Full implementation

MetricKit delivers crash diagnostics the next time your app launches after a crash — not in real time. The pattern below uses a file-backed persistence layer so logs survive app restarts, an @Observable reporter that SwiftUI can observe directly, and a lightweight debug screen you can hide behind a feature flag or shake gesture. Wire CrashReporter.shared.register() in your App.init before the first scene appears.

import SwiftUI
import MetricKit

// MARK: - Model

@Observable
final class CrashReporter: NSObject, MXMetricManagerSubscriber {
    static let shared = CrashReporter()

    private(set) var crashLogs: [CrashRecord] = []

    private let storageURL: URL = {
        let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        return docs.appendingPathComponent("crash_logs.json")
    }()

    override private init() {
        super.init()
        load()
    }

    // Call once at app launch
    func register() {
        MXMetricManager.shared.add(self)
    }

    // MARK: MXMetricManagerSubscriber

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            guard let crashes = payload.crashDiagnostics else { continue }
            for crash in crashes {
                let record = CrashRecord(
                    date: payload.timeStampEnd,
                    signal: crash.signal.description,
                    exceptionType: crash.exceptionType?.description ?? "n/a",
                    callStack: crash.callStackTree.jsonRepresentation().description
                )
                crashLogs.insert(record, at: 0)
            }
        }
        persist()
    }

    func clearLogs() {
        crashLogs.removeAll()
        persist()
    }

    // MARK: - Persistence

    private func persist() {
        guard let data = try? JSONEncoder().encode(crashLogs) else { return }
        try? data.write(to: storageURL, options: .atomic)
    }

    private func load() {
        guard
            let data = try? Data(contentsOf: storageURL),
            let records = try? JSONDecoder().decode([CrashRecord].self, from: data)
        else { return }
        crashLogs = records
    }
}

// MARK: - Model type

struct CrashRecord: Identifiable, Codable {
    var id = UUID()
    let date: Date
    let signal: String
    let exceptionType: String
    let callStack: String
}

// MARK: - App entry point

@main
struct MyApp: App {
    init() {
        CrashReporter.shared.register()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(CrashReporter.shared)
        }
    }
}

// MARK: - Debug UI

struct CrashLogView: View {
    @Environment(CrashReporter.self) private var reporter

    var body: some View {
        NavigationStack {
            Group {
                if reporter.crashLogs.isEmpty {
                    ContentUnavailableView(
                        "No crash logs",
                        systemImage: "checkmark.shield",
                        description: Text("Crash data appears here after the next launch following a crash.")
                    )
                } else {
                    List(reporter.crashLogs) { record in
                        NavigationLink(value: record) {
                            VStack(alignment: .leading, spacing: 4) {
                                Text(record.signal)
                                    .font(.headline)
                                Text(record.date.formatted(.dateTime))
                                    .font(.caption)
                                    .foregroundStyle(.secondary)
                            }
                        }
                    }
                    .navigationDestination(for: CrashRecord.self) { record in
                        CrashDetailView(record: record)
                    }
                }
            }
            .navigationTitle("Crash Logs")
            .toolbar {
                ToolbarItem(placement: .destructiveAction) {
                    Button("Clear", role: .destructive) {
                        reporter.clearLogs()
                    }
                    .disabled(reporter.crashLogs.isEmpty)
                }
            }
        }
    }
}

struct CrashDetailView: View {
    let record: CrashRecord

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                LabeledContent("Signal", value: record.signal)
                LabeledContent("Exception type", value: record.exceptionType)
                LabeledContent("Date", value: record.date.formatted(.dateTime))
                Divider()
                Text("Call stack")
                    .font(.headline)
                Text(record.callStack)
                    .font(.system(.caption, design: .monospaced))
                    .textSelection(.enabled)
            }
            .padding()
        }
        .navigationTitle("Crash detail")
        .navigationBarTitleDisplayMode(.inline)
    }
}

#Preview {
    CrashLogView()
        .environment({
            let r = CrashReporter.shared
            return r
        }())
}

How it works

  1. Subscriber registration. MXMetricManager.shared.add(self) in register() tells MetricKit to call your subscriber on the next cold launch after a crash. Registration should happen before the first WindowGroup renders — that's why it lives in App.init.
  2. Payload delivery. didReceive(_:) receives an array of MXDiagnosticPayload objects. Each payload covers a 24-hour window and may contain multiple MXCrashDiagnostic items if the app crashed several times before launch.
  3. @Observable state. Marking CrashReporter with the @Observable macro (iOS 17+) means any SwiftUI view holding a reference to it automatically re-renders when crashLogs changes — no ObservableObject boilerplate needed.
  4. File-backed persistence. persist() writes encoded [CrashRecord] to the Documents directory with .atomic writes, preventing partial-write corruption. load() restores them in init so logs survive restarts.
  5. ContentUnavailableView empty state. iOS 17 introduced this native component; it renders a centred icon + text when the log list is empty, matching the system style of the Files and Mail apps.

Variants

Upload crashes to a remote endpoint

// Inside didReceive(_:), after building each CrashRecord:
func upload(_ record: CrashRecord) async {
    guard let url = URL(string: "https://api.yourservice.com/crashes") else { return }
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try? JSONEncoder().encode(record)
    _ = try? await URLSession.shared.data(for: request)
}

// Call from the subscriber:
func didReceive(_ payloads: [MXDiagnosticPayload]) {
    for payload in payloads {
        for crash in payload.crashDiagnostics ?? [] {
            let record = CrashRecord(
                date: payload.timeStampEnd,
                signal: crash.signal.description,
                exceptionType: crash.exceptionType?.description ?? "n/a",
                callStack: crash.callStackTree.jsonRepresentation().description
            )
            crashLogs.insert(record, at: 0)
            Task { await upload(record) }
        }
    }
    persist()
}

Trigger a test crash in debug builds

MetricKit does not deliver diagnostics in the Simulator or for DEBUG builds by default. To validate your UI, inject synthetic CrashRecord data in the #Preview or in a hidden debug button: call reporter.crashLogs.append(CrashRecord(date: .now, signal: "SIGSEGV", exceptionType: "EXC_BAD_ACCESS", callStack: "frame 0 ...")). For a real crash on device, you can call fatalError() from a debug-only button, then relaunch — MetricKit will deliver it on the second launch.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement crash reporting in SwiftUI for iOS 17+.
Use MetricKit (MXMetricManagerSubscriber, MXCrashDiagnostic, MXMetricManager).
Persist crash logs to disk as JSON using Codable.
Expose logs via @Observable so SwiftUI views update automatically.
Make it accessible (VoiceOver labels on all list rows).
Add a #Preview with realistic sample data.

In Soarias's Build phase, paste this prompt into the active session after scaffolding your app structure — Claude Code will wire the MetricKit subscriber, persistence layer, and debug view as discrete, reviewable file edits you can accept or modify before moving to the Polish phase.

Related

FAQ

Does this work on iOS 16?

MXMetricManagerSubscriber and MXCrashDiagnostic are available from iOS 14, so the MetricKit parts compile on iOS 16. However, this guide uses @Observable (iOS 17+) and ContentUnavailableView (iOS 17+). To support iOS 16 swap @Observable for ObservableObject / @Published and replace ContentUnavailableView with a custom empty-state view.

Can I use this instead of Crashlytics or Sentry?

MetricKit is a good zero-dependency, privacy-respecting baseline: no SDK, no third-party data sharing, no App Store review risk. The trade-off is that payloads arrive with up to 24-hour latency and contain less contextual data (no breadcrumbs, no custom attributes) than commercial SDKs. For solo or small-team indie apps it is often sufficient; for high-volume production apps you may want Sentry or Crashlytics alongside it.

What is the UIKit equivalent?

In UIKit you would conform your AppDelegate to MXMetricManagerSubscriber and call MXMetricManager.shared.add(self) inside application(_:didFinishLaunchingWithOptions:). The diagnostic handling logic is identical — MetricKit is framework-agnostic. The only SwiftUI-specific parts of this guide are the @Observable wrapper and the SwiftUI list views.

Last reviewed: 2026-05-11 by the Soarias team.

```