DebuggingMonitors.swift (7928B)
1 import CloudKit 2 import Foundation 3 import Observation 4 5 struct PerformanceDiagnosticEntry: Identifiable, Sendable { 6 let id = UUID() 7 let timestamp: Date 8 let name: String 9 let durationMS: Double 10 let detail: String 11 } 12 13 @MainActor 14 @Observable 15 final class PerformanceMonitor { 16 private(set) var entries: [PerformanceDiagnosticEntry] = [] 17 private(set) var totalEvents = 0 18 private(set) var suppressedEvents = 0 19 20 private let maxEntries = 120 21 private var nextRenderProbeID = 1 22 private var pendingRenderProbes: [Int: RenderProbe] = [:] 23 @ObservationIgnored private var viewBodyCounts: [String: Int] = [:] 24 @ObservationIgnored private var viewBodyDetails: [String: String] = [:] 25 @ObservationIgnored private var scheduledViewBodyReports: Set<String> = [] 26 27 private struct RenderProbe { 28 let name: String 29 let position: GridPosition 30 let expectedEntry: String 31 let startedAt: ContinuousClock.Instant 32 let detail: String 33 } 34 35 func record( 36 _ name: String, 37 durationMS: Double, 38 detail: String = "", 39 thresholdMS: Double = 8 40 ) { 41 totalEvents += 1 42 guard durationMS >= thresholdMS else { 43 suppressedEvents += 1 44 return 45 } 46 entries.append(PerformanceDiagnosticEntry( 47 timestamp: Date(), 48 name: name, 49 durationMS: durationMS, 50 detail: detail 51 )) 52 if entries.count > maxEntries { 53 entries.removeFirst(entries.count - maxEntries) 54 } 55 } 56 57 func note(_ name: String, detail: String = "") { 58 entries.append(PerformanceDiagnosticEntry( 59 timestamp: Date(), 60 name: name, 61 durationMS: 0, 62 detail: detail 63 )) 64 if entries.count > maxEntries { 65 entries.removeFirst(entries.count - maxEntries) 66 } 67 } 68 69 func clear() { 70 entries.removeAll() 71 totalEvents = 0 72 suppressedEvents = 0 73 pendingRenderProbes.removeAll() 74 viewBodyCounts.removeAll() 75 viewBodyDetails.removeAll() 76 scheduledViewBodyReports.removeAll() 77 } 78 79 func beginRenderProbe( 80 name: String, 81 position: GridPosition, 82 expectedEntry: String, 83 detail: String = "" 84 ) -> Int { 85 let id = nextRenderProbeID 86 nextRenderProbeID += 1 87 pendingRenderProbes[id] = RenderProbe( 88 name: name, 89 position: position, 90 expectedEntry: expectedEntry, 91 startedAt: .now, 92 detail: detail 93 ) 94 return id 95 } 96 97 func renderProbeDetailPrefix(for id: Int) -> String? { 98 guard let probe = pendingRenderProbes[id] else { return nil } 99 return "probe=\(id) row=\(probe.position.row) col=\(probe.position.col)" 100 } 101 102 func completeRenderProbe(id: Int, position: GridPosition, entry: String) { 103 guard let probe = pendingRenderProbes[id], 104 probe.position == position, 105 probe.expectedEntry == entry 106 else { return } 107 pendingRenderProbes.removeValue(forKey: id) 108 let duration = probe.startedAt.duration(to: .now) 109 let milliseconds = Double(duration.components.seconds) * 1000 110 + Double(duration.components.attoseconds) / 1_000_000_000_000_000 111 let baseDetail = "probe=\(id) row=\(probe.position.row) col=\(probe.position.col)" 112 let detail = probe.detail.isEmpty ? baseDetail : "\(baseDetail) \(probe.detail)" 113 record( 114 probe.name, 115 durationMS: milliseconds, 116 detail: detail, 117 thresholdMS: 6 118 ) 119 } 120 121 func recordDeferred( 122 _ name: String, 123 start: ContinuousClock.Instant, 124 detail: String = "", 125 thresholdMS: Double = 8 126 ) { 127 let milliseconds = Self.milliseconds(from: start.duration(to: .now)) 128 guard milliseconds >= thresholdMS else { return } 129 Task { @MainActor [weak self] in 130 self?.record( 131 name, 132 durationMS: milliseconds, 133 detail: detail, 134 thresholdMS: thresholdMS 135 ) 136 } 137 } 138 139 func markViewBodyDeferred( 140 _ name: String, 141 key: String = "", 142 detail: String = "" 143 ) { 144 Task { @MainActor [weak self] in 145 self?.markViewBody(name, key: key, detail: detail) 146 } 147 } 148 149 private func markViewBody(_ name: String, key: String, detail: String) { 150 let reportKey = key.isEmpty ? name : "\(name)|\(key)" 151 viewBodyCounts[reportKey, default: 0] += 1 152 viewBodyDetails[reportKey] = detail 153 154 guard !scheduledViewBodyReports.contains(reportKey) else { return } 155 scheduledViewBodyReports.insert(reportKey) 156 Task { @MainActor [weak self] in 157 try? await Task.sleep(for: .milliseconds(50)) 158 self?.flushViewBodyReport(name: name, reportKey: reportKey) 159 } 160 } 161 162 private func flushViewBodyReport(name: String, reportKey: String) { 163 scheduledViewBodyReports.remove(reportKey) 164 let count = viewBodyCounts.removeValue(forKey: reportKey) ?? 0 165 guard count > 0 else { return } 166 let detail = viewBodyDetails.removeValue(forKey: reportKey) ?? "" 167 let countDetail = detail.isEmpty ? "count=\(count)" : "count=\(count) \(detail)" 168 note("\(name).body", detail: countDetail) 169 } 170 171 private static func milliseconds(from duration: Duration) -> Double { 172 Double(duration.components.seconds) * 1000 173 + Double(duration.components.attoseconds) / 1_000_000_000_000_000 174 } 175 } 176 177 struct SyncDiagnosticEntry: Identifiable, Sendable { 178 let id = UUID() 179 let timestamp: Date 180 let level: String 181 let message: String 182 } 183 184 @MainActor 185 @Observable 186 final class SyncMonitor { 187 private(set) var lastSuccessAt: Date? 188 private(set) var lastErrorPhase: String? 189 private(set) var lastErrorDomain: String? 190 private(set) var lastErrorCode: Int? 191 private(set) var lastErrorDescription: String? 192 private(set) var entries: [SyncDiagnosticEntry] = [] 193 private(set) var snapshot: SyncEngine.DiagnosticSnapshot? 194 195 private let maxEntries = 80 196 197 func recordStart(_ phase: String) { 198 append(level: "info", "Starting \(phase)") 199 } 200 201 func recordSuccess(_ phase: String) { 202 lastSuccessAt = Date() 203 append(level: "info", "\(phase) succeeded") 204 } 205 206 func recordError(_ phase: String, _ error: Error) { 207 let nsError = error as NSError 208 lastErrorPhase = phase 209 lastErrorDomain = nsError.domain 210 lastErrorCode = nsError.code 211 lastErrorDescription = nsError.localizedDescription 212 let userInfoSummary = nsError.userInfo 213 .map { "\($0.key)=\($0.value)" } 214 .joined(separator: " | ") 215 let message = "\(phase) failed: domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription) | userInfo: \(userInfoSummary)" 216 append(level: "error", message) 217 } 218 219 func note(_ message: String) { 220 append(level: "info", message) 221 } 222 223 func updateSnapshot(_ snapshot: SyncEngine.DiagnosticSnapshot) { 224 self.snapshot = snapshot 225 } 226 227 func clearLastError() { 228 lastErrorPhase = nil 229 lastErrorDomain = nil 230 lastErrorCode = nil 231 lastErrorDescription = nil 232 } 233 234 func run(_ phase: String, _ body: @Sendable () async throws -> Void) async { 235 recordStart(phase) 236 do { 237 try await body() 238 recordSuccess(phase) 239 } catch { 240 recordError(phase, error) 241 } 242 } 243 244 private func append(level: String, _ message: String) { 245 entries.append(SyncDiagnosticEntry(timestamp: Date(), level: level, message: message)) 246 if entries.count > maxEntries { 247 entries.removeFirst(entries.count - maxEntries) 248 } 249 } 250 }