DiagnosticsReport.swift (4604B)
1 import CloudKit 2 import Foundation 3 4 /// Builds the shared diagnostics dump used by both the Diagnostics screen's 5 /// "Share Full Log" and the Invite screen's "Report Error". Keeping one builder 6 /// means a report sent from either place carries the same header + event log. 7 enum DiagnosticsReport { 8 /// Standard header (version, account, sync state, last error) followed by the 9 /// full event log. `leadingLines` are prepended verbatim so a caller can pin 10 /// a specific failure that the sync monitor wouldn't otherwise capture (e.g. 11 /// a share error surfaced only in the invite sheet). 12 @MainActor 13 static func dump( 14 syncMonitor: SyncMonitor, 15 eventLog: EventLog, 16 leadingLines: [String] = [] 17 ) -> DiagnosticsDump { 18 var lines: [String] = [] 19 if !leadingLines.isEmpty { 20 lines.append(contentsOf: leadingLines) 21 lines.append("") 22 } 23 lines.append("Version: \(versionText)") 24 lines.append("Account Status: \(accountStatusText(syncMonitor))") 25 lines.append("Engine Running: \(boolText(syncMonitor.snapshot?.engineRunning))") 26 lines.append("Pending Changes: \(syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")") 27 lines.append("Last Success: \(syncMonitor.lastSuccessAt.map { utcFormatter.string(from: $0) } ?? "None")") 28 lines.append("Last Error Phase: \(syncMonitor.lastErrorPhase ?? "None")") 29 lines.append("Last Error Domain: \(syncMonitor.lastErrorDomain ?? "None")") 30 lines.append("Last Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")") 31 lines.append("Last Error Description: \(syncMonitor.lastErrorDescription ?? "None")") 32 lines.append("Recent Event Count: \(eventLog.entries.count)") 33 return DiagnosticsDump(headerLines: lines, entries: eventLog.entries) 34 } 35 36 /// Marketing version and build number from the bundle. The build number is 37 /// the commit count (set by the release script), so it pins a pasted log to 38 /// an exact commit — the key lever for debugging what a tester is running. 39 static var versionText: String { 40 let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" 41 let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" 42 return "\(version) (\(build))" 43 } 44 45 @MainActor 46 static func accountStatusText(_ syncMonitor: SyncMonitor) -> String { 47 guard let status = syncMonitor.snapshot?.accountStatus else { return "Unknown" } 48 switch status { 49 case .available: return "Available" 50 case .noAccount: return "No Account" 51 case .restricted: return "Restricted" 52 case .couldNotDetermine: return "Could Not Determine" 53 case .temporarilyUnavailable: return "Temporarily Unavailable" 54 @unknown default: return "Unknown" 55 } 56 } 57 58 static func boolText(_ value: Bool?) -> String { 59 guard let value else { return "Unknown" } 60 return value ? "Yes" : "No" 61 } 62 63 private static let utcFormatter: DateFormatter = { 64 let formatter = DateFormatter() 65 formatter.dateStyle = .none 66 formatter.timeStyle = .medium 67 formatter.dateFormat = "h:mm:ss a 'UTC'" 68 formatter.timeZone = TimeZone(secondsFromGMT: 0) 69 return formatter 70 }() 71 } 72 73 /// The dump's inputs, captured on the main actor so the expensive per-entry 74 /// formatting can run off it. `EventLogEntry` is a Sendable value type, so 75 /// taking the buffer is a cheap copy-on-write retain, not a data copy. 76 struct DiagnosticsDump: Sendable { 77 let headerLines: [String] 78 let entries: [EventLogEntry] 79 80 /// Formats the full dump. Runs nonisolated on purpose: tens of thousands 81 /// of entries make this the slow part of a share, and it sits between the 82 /// share tap and the sheet appearing — on the main actor it both stalls 83 /// the UI and queues behind it. The formatter is created locally so the 84 /// loop shares nothing across the isolation boundary. 85 func rendered() -> String { 86 let formatter = DateFormatter() 87 formatter.dateFormat = "h:mm:ss a 'UTC'" 88 formatter.timeZone = TimeZone(secondsFromGMT: 0) 89 90 var lines = headerLines 91 lines.append("") 92 lines.append("Recent Events (UTC):") 93 for entry in entries { 94 lines.append( 95 "\(formatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)" 96 ) 97 } 98 return lines.joined(separator: "\n") 99 } 100 }