DiagnosticsReport.swift (4764B)
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( 28 "Pending Invitations: " + 29 (syncMonitor.snapshot.map { String($0.pendingInvitationCount) } ?? "Unknown") 30 ) 31 lines.append("Last Success: \(syncMonitor.lastSuccessAt.map { utcFormatter.string(from: $0) } ?? "None")") 32 lines.append("Last Error Phase: \(syncMonitor.lastErrorPhase ?? "None")") 33 lines.append("Last Error Domain: \(syncMonitor.lastErrorDomain ?? "None")") 34 lines.append("Last Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")") 35 lines.append("Last Error Description: \(syncMonitor.lastErrorDescription ?? "None")") 36 lines.append("Recent Event Count: \(eventLog.entries.count)") 37 return DiagnosticsDump(headerLines: lines, entries: eventLog.entries) 38 } 39 40 /// Marketing version and build number from the bundle. The build number is 41 /// the commit count (set by the release script), so it pins a pasted log to 42 /// an exact commit — the key lever for debugging what a tester is running. 43 static var versionText: String { 44 let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" 45 let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" 46 return "\(version) (\(build))" 47 } 48 49 @MainActor 50 static func accountStatusText(_ syncMonitor: SyncMonitor) -> String { 51 guard let status = syncMonitor.snapshot?.accountStatus else { return "Unknown" } 52 switch status { 53 case .available: return "Available" 54 case .noAccount: return "No Account" 55 case .restricted: return "Restricted" 56 case .couldNotDetermine: return "Could Not Determine" 57 case .temporarilyUnavailable: return "Temporarily Unavailable" 58 @unknown default: return "Unknown" 59 } 60 } 61 62 static func boolText(_ value: Bool?) -> String { 63 guard let value else { return "Unknown" } 64 return value ? "Yes" : "No" 65 } 66 67 private static let utcFormatter: DateFormatter = { 68 let formatter = DateFormatter() 69 formatter.dateStyle = .none 70 formatter.timeStyle = .medium 71 formatter.dateFormat = "h:mm:ss a 'UTC'" 72 formatter.timeZone = TimeZone(secondsFromGMT: 0) 73 return formatter 74 }() 75 } 76 77 /// The dump's inputs, captured on the main actor so the expensive per-entry 78 /// formatting can run off it. `EventLogEntry` is a Sendable value type, so 79 /// taking the buffer is a cheap copy-on-write retain, not a data copy. 80 struct DiagnosticsDump: Sendable { 81 let headerLines: [String] 82 let entries: [EventLogEntry] 83 84 /// Formats the full dump. Runs nonisolated on purpose: tens of thousands 85 /// of entries make this the slow part of a share, and it sits between the 86 /// share tap and the sheet appearing — on the main actor it both stalls 87 /// the UI and queues behind it. The formatter is created locally so the 88 /// loop shares nothing across the isolation boundary. 89 func rendered() -> String { 90 let formatter = DateFormatter() 91 formatter.dateFormat = "h:mm:ss a 'UTC'" 92 formatter.timeZone = TimeZone(secondsFromGMT: 0) 93 94 var lines = headerLines 95 lines.append("") 96 lines.append("Recent Events (UTC):") 97 for entry in entries { 98 lines.append( 99 "\(formatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)" 100 ) 101 } 102 return lines.joined(separator: "\n") 103 } 104 }