crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }