How to build crash reporting in SwiftUI
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
-
Subscriber registration.
MXMetricManager.shared.add(self)inregister()tells MetricKit to call your subscriber on the next cold launch after a crash. Registration should happen before the firstWindowGrouprenders — that's why it lives inApp.init. -
Payload delivery.
didReceive(_:)receives an array ofMXDiagnosticPayloadobjects. Each payload covers a 24-hour window and may contain multipleMXCrashDiagnosticitems if the app crashed several times before launch. -
@Observablestate. MarkingCrashReporterwith the@Observablemacro (iOS 17+) means any SwiftUI view holding a reference to it automatically re-renders whencrashLogschanges — noObservableObjectboilerplate needed. -
File-backed persistence.
persist()writes encoded[CrashRecord]to the Documents directory with.atomicwrites, preventing partial-write corruption.load()restores them ininitso logs survive restarts. -
ContentUnavailableViewempty 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
-
Simulator silently drops payloads. MetricKit crash diagnostics are
only delivered on a physical device running a release or profiling build. Never
expect
didReceiveto fire in the Simulator — use injected mock data for UI development. - Payloads arrive on the next launch, not immediately. Developers often expect real-time crash callbacks. MetricKit batches diagnostics and delivers them 24 hours after collection or on the next app launch — whichever comes first. Design your UX accordingly (e.g., a "last session's crashes" label).
-
Persisting raw
dictionaryRepresentation()inflates storage. The full dictionary for a complex crash can exceed 50 KB. Store only the fields you need (signal, exception type, top N frames) and truncate the call stack to avoid bloating the Documents directory for end users.
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.