// ── Crash Reporter ──────────────────────────────────────────── // MetricKit-based crash and performance reporting for TestFlight/Production import Foundation import MetricKit @MainActor final class CrashReporter: NSObject, ObservableObject, MXMetricManagerSubscriber { static let shared = CrashReporter() @Published var lastCrashReport: Date? @Published var diagnosticCount: Int = 0 private let persistenceKey = "chronomind-crash-reports" override private init() { super.init() MXMetricManager.shared.add(self) loadStats() } deinit { MXMetricManager.shared.remove(self) } // MARK: - MXMetricManagerSubscriber nonisolated func didReceive(_ payloads: [MXMetricPayload]) { // MetricKit delivers daily aggregated metrics Task { @MainActor in for payload in payloads { processMetricPayload(payload) } } } nonisolated func didReceive(_ payloads: [MXDiagnosticPayload]) { // Crash diagnostics — delivered after app restart following crash Task { @MainActor in for payload in payloads { processDiagnosticPayload(payload) } self.diagnosticCount += payloads.count self.lastCrashReport = Date() self.saveStats() } } // MARK: - Processing private func processMetricPayload(_ payload: MXMetricPayload) { // Log key performance metrics if let launchTime = payload.applicationLaunchMetrics { let resumeTime = launchTime.histogrammedResumeTime logMetric("app_resume_time", histogram: resumeTime) } if let responsiveness = payload.applicationResponsivenessMetrics { let hangTime = responsiveness.histogrammedApplicationHangTime logMetric("hang_time", histogram: hangTime) } } private func processDiagnosticPayload(_ payload: MXDiagnosticPayload) { // Store crash data locally for feedback form if let crashDiagnostics = payload.crashDiagnostics { for crash in crashDiagnostics { let report = CrashReport( date: Date(), exceptionType: crash.exceptionType?.description, signal: crash.signal?.description, terminationReason: crash.terminationReason?.description, callStackTree: crash.callStackTree.jsonRepresentation() ) storeCrashReport(report) } } if let hangDiagnostics = payload.hangDiagnostics { for hang in hangDiagnostics { let report = CrashReport( date: Date(), exceptionType: nil, signal: nil, terminationReason: "Hang: \(hang.hangDuration.description)", callStackTree: hang.callStackTree.jsonRepresentation() ) storeCrashReport(report) } } } // MARK: - Storage private func storeCrashReport(_ report: CrashReport) { var reports = loadCrashReports() reports.append(report) // Keep last 50 reports if reports.count > 50 { reports = Array(reports.suffix(50)) } if let data = try? JSONEncoder().encode(reports) { UserDefaults.standard.set(data, forKey: persistenceKey) } } func loadCrashReports() -> [CrashReport] { guard let data = UserDefaults.standard.data(forKey: persistenceKey), let reports = try? JSONDecoder().decode([CrashReport].self, from: data) else { return [] } return reports } func clearReports() { UserDefaults.standard.removeObject(forKey: persistenceKey) diagnosticCount = 0 } private func loadStats() { diagnosticCount = loadCrashReports().count } private func saveStats() { // Stats are derived from stored reports } private func logMetric(_ name: String, histogram: MXHistogram) { // In production, send to analytics service // For now, just track locally } } // MARK: - Crash Report Model struct CrashReport: Codable, Identifiable { let id: String let date: Date let exceptionType: String? let signal: String? let terminationReason: String? let callStackData: Data? init(date: Date, exceptionType: String?, signal: String?, terminationReason: String?, callStackTree: Data) { self.id = UUID().uuidString self.date = date self.exceptionType = exceptionType self.signal = signal self.terminationReason = terminationReason self.callStackData = callStackTree } }