commit e23dfeb33591059f139f4c566ebd497ebafde29f
parent aa5208b02e64fb1f8568a3e6b6b2da1191e872cd
Author: Michael Camilleri <[email protected]>
Date: Tue, 9 Jun 2026 11:07:02 +0900
Persist the diagnostics event log across launches
The in-app EventLog held breadcrumbs in memory under a one-hour time
window, so it was emptied on every termination and, even when alive,
forgot anything older than an hour. That window suits a live co-solve
but loses one important case: collecting a collaborator's log hours
after their session, such as when their device has slept or relaunched
overnight and the operational events that describe what the collaborator
did -- push(play)/push(pause) sends, sync, errors -- exist only in this
in-memory log, not the durable received-notification receipt buffer.
This commit backs the log with EventLogStore, an actor that owns a
single atomically-rewritten snapshot in Application Support.
loadPersisted hydrates the in-memory view on launch before live
breadcrumbs flow, merging by timestamp so a startup note that races
ahead is not lost; a debounced flush mirrors live appends back to disk
(one write a couple of seconds after a burst, not one per breadcrumb),
and a forced flush on backgrounding -- under a UIApplication assertion
alongside the moves flush -- keeps the tail the timer has not written
yet. A hydrated gate ensures no flush can clobber the file with a
pre-load snapshot.
With the log now durable, the retention window goes from one hour to
twenty-four: long enough to span an evening session plus a quiet night
before someone collects it, while staying cheap on an idle device (a
quiet night writes a handful of rows). The 30k-entry ceiling still
governs the pathological runaway case.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 97 insertions(+), 5 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -368,6 +368,12 @@ final class AppServices {
guard !started else { return }
started = true
+ // Hydrate the persisted diagnostics history before live breadcrumbs
+ // flow, so a log collected this morning still carries last night's
+ // session. Ordering against startup notes is by timestamp, so a note
+ // that races ahead of this isn't lost.
+ await eventLog.loadPersisted()
+
nytAuth.loadStoredSession()
driveMonitor.start()
@@ -781,6 +787,9 @@ final class AppServices {
ensureInBackground("moves-flush") { [weak self] in
await self?.movesUpdater.flush()
}
+ ensureInBackground("event-log-flush") { [weak self] in
+ await self?.eventLog.flush()
+ }
}
/// Completion fan-out, delivered through the push worker. Win sets
diff --git a/Crossmate/Services/DebuggingMonitors.swift b/Crossmate/Services/DebuggingMonitors.swift
@@ -174,16 +174,53 @@ final class PerformanceMonitor {
}
}
-struct EventLogEntry: Identifiable, Sendable {
- let id = UUID()
+struct EventLogEntry: Identifiable, Sendable, Codable {
+ var id = UUID()
let timestamp: Date
let level: String
let message: String
}
+/// Off-main durable backing for `EventLog`. Owns a single atomically-rewritten
+/// snapshot file in Application Support — chosen over Caches, which iOS can
+/// purge under storage pressure, exactly when a tester needs the log most.
+/// The file survives termination, so a collaborator can still share their
+/// diagnostics hours after the activity, long after their app was suspended.
+actor EventLogStore {
+ private let fileURL: URL
+
+ init(filename: String = "event-log.json") {
+ let dir = FileManager.default
+ .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ self.fileURL = dir.appendingPathComponent(filename)
+ }
+
+ func load() -> [EventLogEntry] {
+ guard let data = try? Data(contentsOf: fileURL),
+ let entries = try? JSONDecoder().decode([EventLogEntry].self, from: data)
+ else { return [] }
+ return entries
+ }
+
+ /// Rewrites the file from the already-pruned in-memory snapshot. Called
+ /// coalesced (debounced after a burst, and on backgrounding) rather than
+ /// per-append, so the O(n) encode stays rare even during an intense
+ /// ~5/sec co-solve.
+ func persist(_ entries: [EventLogEntry]) {
+ guard let data = try? JSONEncoder().encode(entries) else { return }
+ try? data.write(to: fileURL, options: .atomic)
+ }
+}
+
/// Generic in-app event log. Anything that wants to leave a breadcrumb the
/// user can read in the diagnostics screen calls `note(_:)`. SyncMonitor and
/// other producers compose this — they don't subclass it.
+///
+/// Backed by `EventLogStore` so the buffer outlives the process: `loadPersisted`
+/// hydrates the in-memory view on launch, and a debounced flush mirrors live
+/// breadcrumbs back to disk. That durability is what lets a quiet-overnight
+/// device still surface a collaborator's hours-old session the next morning.
@MainActor
@Observable
final class EventLog {
@@ -194,9 +231,10 @@ final class EventLog {
/// regardless of how chatty the session is. During an intense co-solve the
/// log churns at ~5 events/sec, so a count cap of a few thousand only holds
/// a couple of minutes and evicts the start of the session before anyone
- /// can grab it. An hour comfortably covers a full shared solve plus its
- /// opening minutes.
- private let retention: TimeInterval = 60 * 60
+ /// can grab it. A day comfortably spans a collaborator's evening session
+ /// plus a quiet night before someone collects the log in the morning, while
+ /// staying cheap on an idle device (a quiet night writes a handful of rows).
+ private let retention: TimeInterval = 60 * 60 * 24
/// Hard ceiling so a runaway producer (e.g. a tight error-retry loop) can't
/// grow the buffer without bound inside the retention window. Sized well
@@ -204,6 +242,29 @@ final class EventLog {
/// window stays the governing limit in practice; this only trips on a fault.
private let maxEntries = 30_000
+ private let store: EventLogStore
+
+ /// Until the on-disk history has been merged in, appends must not flush:
+ /// a flush before `loadPersisted` would clobber the file with a partial,
+ /// pre-hydration snapshot and lose the very overnight history we're after.
+ private var hydrated = false
+ private var flushTask: Task<Void, Never>?
+
+ init(store: EventLogStore = EventLogStore()) {
+ self.store = store
+ }
+
+ /// Merges the persisted history into the in-memory view. Call once, early
+ /// in launch. Any breadcrumbs noted before hydration are kept and ordered
+ /// in by timestamp, so nothing recorded during startup is lost.
+ func loadPersisted() async {
+ let persisted = await store.load()
+ entries = (persisted + entries).sorted { $0.timestamp < $1.timestamp }
+ prune(now: Date())
+ hydrated = true
+ scheduleFlush()
+ }
+
func note(_ message: String, level: String = "info") {
append(level: level, message)
}
@@ -212,6 +273,28 @@ final class EventLog {
let now = Date()
entries.append(EventLogEntry(timestamp: now, level: level, message: message))
prune(now: now)
+ scheduleFlush()
+ }
+
+ /// Coalesce writes: one persist a couple of seconds after the last append,
+ /// not one per breadcrumb. A fresh append cancels the pending write and
+ /// re-arms it, so a steady burst collapses into a single trailing flush.
+ private func scheduleFlush() {
+ guard hydrated else { return }
+ flushTask?.cancel()
+ flushTask = Task { [weak self] in
+ try? await Task.sleep(for: .seconds(2))
+ guard !Task.isCancelled, let self else { return }
+ await self.store.persist(self.entries)
+ }
+ }
+
+ /// Persist immediately, bypassing the debounce. Call on backgrounding so an
+ /// imminent suspend keeps the tail that the timer hasn't written yet.
+ func flush() async {
+ guard hydrated else { return }
+ flushTask?.cancel()
+ await store.persist(entries)
}
/// Drops entries older than the retention window, then enforces the count