commit a27fd1c70b3ed73dd4b7601e4ce033636edcb874
parent 5bbbfa443938d15600a3e7ce670fa8b773396c63
Author: Michael Camilleri <[email protected]>
Date: Mon, 1 Jun 2026 15:29:24 +0900
Add logging to replay downloads
Diffstat:
3 files changed, 46 insertions(+), 4 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -470,9 +470,18 @@ private struct PuzzleDisplayView: View {
let localDeviceID = RecordSerializer.localDeviceID
let otherDevices = store.contributingDevices(for: gameID)
.filter { $0.deviceID != localDeviceID }
+ let short = gameID.uuidString.prefix(8)
if otherDevices.isEmpty {
+ services.syncMonitor.note(
+ "replay[\(short)]: local-only path " +
+ "(no other contributing devices), localEntries=\(entries.count)"
+ )
return .ready(ReplayTimeline(merging: [entries]))
}
+ services.syncMonitor.note(
+ "replay[\(short)]: merged path, " +
+ "otherDevices=\(otherDevices.count), localEntries=\(entries.count)"
+ )
return await services.loadReplay(gameID: gameID)
}
)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -2587,17 +2587,44 @@ final class AppServices {
/// zone can't be reached. The fetch is a plain CKQuery, so it's safe to call
/// from the UI when the finish banner appears.
func loadReplay(gameID: UUID) async -> JournalReplayResult {
- // `try?` flattens both the thrown error and the method's own optional
- // (nil = zone unknown / access revoked) into a single `.unavailable`.
- guard let fetch = try? await syncEngine.fetchReplay(forGameID: gameID) else {
+ let short = gameID.uuidString.prefix(8)
+ // A nil return means zone unknown / access revoked; a throw means the
+ // on-demand CKQuery itself failed. Both flatten to `.unavailable`, but
+ // log which one (and the error) so the diagnostics stream can tell them
+ // apart — the replay fetch is otherwise invisible to the event log.
+ let fetch: JournalReplayFetch?
+ do {
+ fetch = try await syncEngine.fetchReplay(forGameID: gameID)
+ } catch {
+ let ns = error as NSError
+ syncMonitor.note(
+ "replay[\(short)]: fetch threw — domain=\(ns.domain) " +
+ "code=\(ns.code) \(ns.localizedDescription)"
+ )
+ return .unavailable
+ }
+ guard let fetch else {
+ syncMonitor.note("replay[\(short)]: fetch unavailable (zone unknown / access revoked)")
return .unavailable
}
let local = store.localReplaySource(gameID: gameID)
- return ReplayAssembler.assemble(
+ let result = ReplayAssembler.assemble(
fetch: fetch,
localKey: local?.key ?? JournalDeviceKey(authorID: "", deviceID: ""),
localEntries: local?.entries ?? []
)
+ let resultDesc: String
+ switch result {
+ case .ready(let timeline): resultDesc = "ready(steps=\(timeline.count))"
+ case .waiting(let missing): resultDesc = "waiting(missing=\(missing))"
+ case .unavailable: resultDesc = "unavailable"
+ }
+ syncMonitor.note(
+ "replay[\(short)]: merged — expected=\(fetch.expectedDevices.count), " +
+ "journals=\(fetch.journals.count), localEntries=\(local?.entries.count ?? 0) " +
+ "→ \(resultDesc)"
+ )
+ return result
}
/// Builds the `GameStore.onGameDeleted` callback. Extracted so tests can
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -873,6 +873,12 @@ extension SyncEngine {
)
}
}
+ await trace(
+ "fetchReplay \(gameID.uuidString.prefix(8)): scope=\(info.scope) " +
+ "movesRecords=\(movesRecords.count) expectedDevices=\(expected.count) " +
+ "journalRecords=\(journalRecords.count) " +
+ "journalEntryCounts=[\(journals.map { String($0.entries.count) }.joined(separator: ","))]"
+ )
return JournalReplayFetch(journals: journals, expectedDevices: expected)
}
}