diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..1ab1613b32 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,150 @@ +# Contributing to Trio + +Thank you for your interest in contributing to Trio. + +Trio is a community effort, and contributions of all kinds are welcome. This document outlines some guidelines, good practices, and expectations for contributing to the project, with the goal of making collaboration and review as smooth as possible. + +Whether you are helping other users, improving documentation, translating the app, testing builds, reviewing code, or contributing new features and fixes, your work matters. + +## Ways to contribute + +There are many ways to support the Trio community: + +- **Help others** by answering questions and guiding users in support communities. +- Improve the **documentation** by updating or expanding TrioDocs. +- Improve the **app** by contributing code, fixes, features, or tests. +- Help with **translation and localization** through Crowdin. +- Support **testing and feedback** by validating changes and reporting issues clearly. + +### Pay it forward + +If Trio has helped you manage your diabetes successfully, consider paying it forward by helping others. Answering questions in [Discord](https://discord.triodocs.org) or [Facebook](https://facebook.triodocs.org) groups can make a real difference for someone getting started. + +### Translate + +Trio is translated into multiple languages to make it easier to understand and use around the world. Translation is managed through [Crowdin](https://crowdin.triodocs.org/) and does not require programming experience. + +If your preferred language is missing, or you would like to improve an existing translation, please sign up as a translator on [Crowdin](https://crowdin.com/project/trio). + +### Develop + +Do you work with Swift? UI/UX? Testing? API optimization? Data storage? + +Trio is a collaborative project, and contributions of all kinds are welcome. Whether you are writing code, improving the user experience, testing builds, helping with documentation, or contributing in other ways, your help matters. + +## General principles + +- Start small. Smaller, focused contributions are easier to review, test, and merge. +- For larger changes or new features, open or reference an issue first so there is a clear place for discussion and progress tracking. +- Reach out early if you are planning to work on something substantial, especially if it may overlap with work already in progress. +- Keep discussions constructive, respectful, and focused on improving Trio for the community. +- Remember that Trio is part of a wider open source AID ecosystem. Collaboration and maintainability matter just as much as shipping features. + +## Development guidelines + +### Coding conventions + +- Use Xcode and follow the existing formatting and style used throughout the codebase. +- Keep indentation and formatting consistent in every file you change. +- Format your code before committing. +- Avoid unrelated formatting-only changes in files you are not otherwise modifying. +- Prefer clear, readable code over clever or overly compact solutions. +- Follow existing naming, file organization, and architectural patterns unless there is a good reason not to. + +### Strings and localization + +- Add new user-facing strings in the appropriate localization mechanism used by the app. +- Provide English source strings only unless the contribution is specifically about translations. +- Translation and localization for other languages should go through [Crowdin](https://crowdin.triodocs.org/). +- Translations from shared pump managers, CGM managers and services are handled by the [Loop lokalise project](https://loopkit.github.io/loopdocs/faqs/app-translation/#code-translation). + +### Logging + +Users share Trio's logs when they ask for help, so every line should make sense to someone who doesn't know the code. + +- Use `debug`, `info`, `warning`, `error`, or `fault`; never `print`, `debugPrint`, or `NSLog`. + - `debug`: detail for developers. + - `info`: something meaningful happened (loop ran, bolus delivered, pump connected, setting changed). + - `warning`: something failed and Trio recovered or will retry. + - `error`: something failed and the user may notice. + - `fault`: something that should never happen. +- Pick the category by what the line is about (`loop`, `pump`, `cgm`, `nightscout`, `storage`, …), not by folder. +- Write a short plain sentence: no emoji, no decorative prefixes, no "function called" traces, units with numbers (`BG 123 mg/dL`, `IOB 1.25 U`). +- Don't log whole objects or JSON, and never log secrets, tokens, names, or emails. +- A `catch` that swallows an error logs at `warning` or higher and passes it: `warning(.nightscout, "Upload of glucose failed", error: error)`. + +### Documentation + +- Update docstrings when your change affects setup, configuration, behavior, workflows, or troubleshooting. +- Keep documentation changes clear and practical. +- +1: Documentation contributions are just as valuable as code contributions. + +## Branches, commits, and pull requests + +### Getting started + +1. Fork the [Trio repository](https://github.com/nightscout/Trio) on GitHub. +1. Create a separate branch for each feature or fix with an [appropriate name](#branch-names). +1. Branch from the most recent appropriate development branch (typically `dev`). +1. Commit your changes to your fork. +1. When ready, open a pull request against the upstream repository (`nightscout/Trio`). + +### Before opening a pull request + +- Rebase or otherwise sync your branch with the latest target branch. +- Make sure your change is focused and does not include unrelated edits. +- Test your changes as thoroughly as you reasonably can. +- Update relevant documentation when needed. +- Double-check for debug code, commented-out code, accidental version changes, or temporary workarounds left behind. + +### Pull request guidance + +- Keep pull requests as small and focused as practical. +- Use a clear title and description. +- Explain **what** changed and **why**. +- Link the relevant issue when applicable. +- Mention any areas that need particular review attention. +- Be open to feedback and follow-up changes during review. +- Use AI tools, if at all, as a support for small, well-understood tasks rather than to generate large parts of a contribution +- Do not submit AI-heavy or "vibe-coded" pull requests; we welcome thoughtful use of tooling, but contributions need to be intentionally designed. + +## Naming conventions + +### Branch names + +Use short, descriptive branch names that make the purpose of the change obvious. For example: + +- `fix/watchstate-sync` +- `feature/onboarding-target-behavior` +- `refactor/therapy-editor` + +### Pull request titles + +Use concise, descriptive pull request titles. Good titles usually start with the type of change, for example: + +- `Fix watch state sync timing issue` +- `Add onboarding step for target behavior` +- `Update build documentation` +- `Refactor therapy editor validation` + +## Communication and coordination + +For new ideas, larger features, or work that may affect multiple parts of the app, **discuss it with the community first** — reach out to the contributor core on [Trio Discord](https://discord.triodocs.org/). This helps reduce duplicate work, avoid merge conflicts, and improve the final design. + +## Review expectations + +Please remember that Trio is maintained by contributors with limited time. Reviews may take time, and some pull requests may require iteration before they are ready to merge. + +To help keep reviews efficient: + +- Keep the scope narrow. +- Explain your reasoning clearly. +- Respond to review comments directly. +- Avoid force-pushing large unexplained rewrites during active review unless necessary. +- AI-assisted work is welcome for limited, well-understood tasks, but contributions should remain author-driven and must be code you fully understand, and can explain. + +We do not accept pull requests that are largely AI-generated or submitted without careful engineering judgment, testing, and alignment with Trio’s existing patterns. + +## Final note + +Trio exists because people choose to contribute their time, knowledge, and care to a shared effort. Thank you for helping improve the project and support the broader open source AID community. diff --git a/Config.xcconfig b/Config.xcconfig index b56059bf75..445870f8c6 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group // The developers set the version numbers, please leave them alone APP_VERSION = 1.0.2 -APP_DEV_VERSION = 1.0.2.26 +APP_DEV_VERSION = 1.0.2.30 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = autoISF 3.01 diff --git a/LiveActivity/LiveActivity.swift b/LiveActivity/LiveActivity.swift index bee5e6dda3..83850653ed 100644 --- a/LiveActivity/LiveActivity.swift +++ b/LiveActivity/LiveActivity.swift @@ -49,7 +49,6 @@ struct LiveActivity: Widget { .widgetURL(URL(string: "Trio://")) .keylineTint(glucoseColor) .contentMargins(.horizontal, 0, for: .minimal) - .contentMargins(.trailing, 0, for: .compactLeading) .contentMargins(.leading, 0, for: .compactTrailing) } diff --git a/LiveActivity/Views/LiveActivityView.swift b/LiveActivity/Views/LiveActivityView.swift index d33f633038..522b9409e5 100644 --- a/LiveActivity/Views/LiveActivityView.swift +++ b/LiveActivity/Views/LiveActivityView.swift @@ -264,7 +264,9 @@ struct LiveActivityCompactLeadingView: View { var glucoseColor: Color var body: some View { - LiveActivityBGAndTrendView(context: context, size: .compact, glucoseColor: glucoseColor).padding(.leading, 4) + LiveActivityBGAndTrendView(context: context, size: .compact, glucoseColor: glucoseColor) + .lineLimit(1) + .minimumScaleFactor(0.5) } } @@ -274,6 +276,8 @@ struct LiveActivityCompactTrailingView: View { var body: some View { LiveActivityGlucoseDeltaLabelView(context: context, glucoseColor: glucoseColor).padding(.trailing, 4) + .lineLimit(1) + .minimumScaleFactor(0.5) } } diff --git a/Model/CoreDataStack.swift b/Model/CoreDataStack.swift index dc5ecfe045..d74d80b9cc 100644 --- a/Model/CoreDataStack.swift +++ b/Model/CoreDataStack.swift @@ -128,10 +128,10 @@ class CoreDataStack: ObservableObject { do { try await fetchPersistentHistoryTransactionsAndChanges() } catch let error as NSError where error.code == NSPersistentHistoryTokenExpiredError { - debug(.coreData, "Persistent history token expired; clearing token and replaying available history.") + warning(.storage, "Persistent history token expired, replaying available history") await recoverFromExpiredHistoryToken() } catch { - debug(.coreData, "\(error)") + warning(.storage, "Fetching persistent history failed", error: error) } } @@ -141,7 +141,7 @@ class CoreDataStack: ObservableObject { do { try await fetchPersistentHistoryTransactionsAndChanges() } catch { - debug(.coreData, "Replay after token reset failed; jumping to current token. \(error)") + warning(.storage, "Replaying persistent history failed, jumping to current token", error: error) let viewContext = persistentContainer.viewContext let currentToken = persistentContainer.persistentStoreCoordinator .currentPersistentHistoryToken(fromStores: nil) @@ -200,12 +200,9 @@ class CoreDataStack: ObservableObject { let deleteHistoryTokensRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: date) do { try taskContext.execute(deleteHistoryTokensRequest) - debug(.coreData, "\(DebuggingIdentifiers.succeeded) Successfully deleted persistent history from before \(date)") + debug(.storage, "Deleted persistent history before \(date)") } catch { - debug( - .coreData, - "\(DebuggingIdentifiers.failed) Failed to delete persistent history from before \(date): \(error)" - ) + warning(.storage, "Deleting persistent history before \(date) failed", error: error) } } } @@ -229,7 +226,7 @@ class CoreDataStack: ObservableObject { Task { await self?.fetchPersistentHistory() } } - debug(.coreData, "Set up persistent store change notifications") + debug(.storage, "Set up persistent store change notifications") } /// Loads the persistent stores asynchronously. @@ -243,10 +240,10 @@ class CoreDataStack: ObservableObject { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in persistentContainer.loadPersistentStores { storeDescription, error in if let error = error { - warning(.coreData, "Failed to load persistent stores: \(error)") + warning(.storage, "Loading persistent stores failed", error: error) continuation.resume(throwing: error) } else { - debug(.coreData, "Successfully loaded persistent store: \(storeDescription.url?.absoluteString ?? "unknown")") + debug(.storage, "Loaded persistent store \(storeDescription.url?.lastPathComponent ?? "unknown")") continuation.resume(returning: ()) } } @@ -288,14 +285,14 @@ class CoreDataStack: ObservableObject { setupPersistentStoreChangeNotifications() - debug(.coreData, "Core Data stack initialized successfully") + info(.storage, "Core Data stack initialized") - } catch { - debug(.coreData, "Failed to initialize Core Data stack: \(error)") + } catch let initError { + warning(.storage, "Core Data stack initialization failed", error: initError) // If we still have retries left, try again after a delay if retryCount < maxRetries { - debug(.coreData, "Retrying initialization (\(retryCount + 1)/\(maxRetries))") + debug(.storage, "Retrying Core Data initialization (\(retryCount + 1)/\(maxRetries))") // Wait before retrying try await Task.sleep(for: .seconds(1)) @@ -304,8 +301,8 @@ class CoreDataStack: ObservableObject { try await initializeStack(retryCount: retryCount + 1) } else { // We've exhausted our retries - debug(.coreData, "Core Data initialization failed after \(maxRetries) attempts") - throw error + error(.storage, "Core Data initialization failed after \(maxRetries) attempts") + throw initError } } } @@ -318,7 +315,6 @@ extension CoreDataStack { /// - Tag: synchronousDelete func deleteObject(identifiedBy objectID: NSManagedObjectID) async { let viewContext = persistentContainer.viewContext - debug(.coreData, "Start deleting data from the store ...\(DebuggingIdentifiers.inProgress)") await viewContext.perform { do { @@ -327,9 +323,9 @@ extension CoreDataStack { guard viewContext.hasChanges else { return } try viewContext.save() - debug(.coreData, "Successfully deleted data. \(DebuggingIdentifiers.succeeded)") + debug(.storage, "Deleted object") } catch { - debug(.coreData, "Failed to delete data: \(error)") + warning(.storage, "Deleting object failed", error: error) } } } @@ -383,14 +379,13 @@ extension CoreDataStack { let batchDeleteResult = fetchResult as? NSBatchDeleteResult, let success = batchDeleteResult.result as? Bool, success else { - debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)") throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass) } } - debug(.coreData, "Successfully deleted data older than \(days) days. \(DebuggingIdentifiers.succeeded)") + debug(.storage, "Deleted \(objectType) older than \(days) days") } catch { - debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)") + warning(.storage, "Deleting \(objectType) older than \(days) days failed", error: error) throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass) } } @@ -449,17 +444,13 @@ extension CoreDataStack { let batchDeleteResult = fetchResult as? NSBatchDeleteResult, let success = batchDeleteResult.result as? Bool, success else { - debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)") throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass) } } - debug( - .coreData, - "Successfully deleted \(childType) data related to \(parentType) objects older than \(days) days. \(DebuggingIdentifiers.succeeded)" - ) + debug(.storage, "Deleted \(childType) of \(parentType) older than \(days) days") } catch { - debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)") + warning(.storage, "Deleting \(childType) of \(parentType) older than \(days) days failed", error: error) throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass) } } @@ -603,8 +594,8 @@ extension CoreDataStack { do { try context.save() - } catch { - debug(.coreData, "Error saving context \(DebuggingIdentifiers.failed): \(error)") + } catch let saveError { + error(.storage, "Saving view context failed", error: saveError) } } } @@ -620,15 +611,9 @@ extension NSManagedObjectContext { do { guard onContext.hasChanges else { return } try onContext.save() - debug( - .coreData, - "Saving to Core Data successful in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.succeeded)" - ) + debug(.storage, "Saved context in \(callingClass) \(callingFunction)") } catch let error as NSError { - debug( - .coreData, - "Saving to Core Data failed in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.failed) with error \(error), \(error.userInfo)" - ) + warning(.storage, "Saving context in \(callingClass) \(callingFunction) failed", error: error) throw error } } diff --git a/Model/Helper/JSONConverter.swift b/Model/Helper/JSONConverter.swift index 061791dd8c..2dd23bf520 100644 --- a/Model/Helper/JSONConverter.swift +++ b/Model/Helper/JSONConverter.swift @@ -13,7 +13,7 @@ class JSONConverter { return jsonString } } catch { - debugPrint("\(DebuggingIdentifiers.failed) could not convert object to JSON: \(error)") + warning(.algorithm, "Encoding \(T.self) as JSON failed", error: error) } return "could not convert object to JSON" diff --git a/Model/JSONImporter.swift b/Model/JSONImporter.swift index 68c4d2b55c..e4aa195875 100644 --- a/Model/JSONImporter.swift +++ b/Model/JSONImporter.swift @@ -786,83 +786,63 @@ extension JSONImporter { } func importGlucoseHistoryIfNeeded() async throws { - debug(.coreData, "Checking for glucose history JSON file...") - let url = openAPSFileURL(OpenAPS.Monitor.glucose) let suffix = "migrated.json" guard FileManager.default.fileExists(atPath: url.path) else { - debug(.coreData, "❌ No JSON file to import at \(url.path)") + debug(.storage, "No legacy glucose history to import") return } - debug(.coreData, "Glucose history JSON file found, proceeding with import of glucose history...") - try await importGlucoseHistory(url: url, now: Date()) - debug(.coreData, "Glucose history JSON file imported successfully, moving to \(suffix)") - try FileManager.default.moveItem( at: url, to: url.deletingPathExtension().appendingPathExtension(suffix) ) - debug(.coreData, "Import of glucose history completed successfully.") + info(.storage, "Imported legacy glucose history") } func importPumpHistoryIfNeeded() async throws { - debug(.coreData, "Checking for pump history JSON file...") - let url = openAPSFileURL(OpenAPS.Monitor.pumpHistory) let suffix = "migrated.json" guard FileManager.default.fileExists(atPath: url.path) else { - debug(.coreData, "❌ No JSON file to import at \(url.path)") + debug(.storage, "No legacy pump history to import") return } - debug(.coreData, "Pump history JSON file found, proceeding with import of glucose history...") - try await importPumpHistory(url: url, now: Date()) - debug(.coreData, "Pump history JSON file imported successfully, moving to \(suffix)") - try FileManager.default.moveItem( at: url, to: url.deletingPathExtension().appendingPathExtension(suffix) ) - debug(.coreData, "Import of pump history completed successfully.") + info(.storage, "Imported legacy pump history") } func importCarbHistoryIfNeeded() async throws { - debug(.coreData, "Checking for carb history JSON file...") - let url = openAPSFileURL(OpenAPS.Monitor.carbHistory) let suffix = "migrated.json" guard FileManager.default.fileExists(atPath: url.path) else { - debug(.coreData, "❌ No JSON file to import at \(url.path)") + debug(.storage, "No legacy carb history to import") return } - debug(.coreData, "Carb history JSON file found, proceeding with import of glucose history...") - try await importCarbHistory(url: url, now: Date()) - debug(.coreData, "Carb history JSON file imported successfully, moving to \(suffix)") - try FileManager.default.moveItem( at: url, to: url.deletingPathExtension().appendingPathExtension(suffix) ) - debug(.coreData, "Import of carb history completed successfully.") + info(.storage, "Imported legacy carb history") } func importDeterminationIfNeeded() async throws { - debug(.coreData, "Checking for determination JSON files...") - let enactedPath = OpenAPS.Enact.enacted // "enact/enacted.json" let suggestedPath = OpenAPS.Enact.suggested // "enact/suggested.json" let suffix = "migrated.json" @@ -873,22 +853,18 @@ extension JSONImporter { guard FileManager.default.fileExists(atPath: enactedURL.path), FileManager.default.fileExists(atPath: suggestedURL.path) else { - debug(.coreData, "❌ No JSON file to import at \(enactedURL.path) and/or \(suggestedURL.path)") + debug(.storage, "No legacy determination files to import") return } - debug(.coreData, "Determination JSON files found, proceeding with import...") - try await importOrefDetermination(enactedUrl: enactedURL, suggestedUrl: suggestedURL, now: Date()) - debug(.coreData, "Determination JSON file(s) imported successfully, moving to \(suffix)") - try FileManager.default.moveItem(at: enactedURL, to: enactedURL.deletingPathExtension().appendingPathExtension(suffix)) try FileManager.default.moveItem( at: suggestedURL, to: suggestedURL.deletingPathExtension().appendingPathExtension(suffix) ) - debug(.coreData, "Import of determination data completed successfully.") + info(.storage, "Imported legacy determinations") } } diff --git a/Trio Watch App Extension/TrioWatchApp.swift b/Trio Watch App Extension/TrioWatchApp.swift index fe8e7f96ce..36acac18ec 100644 --- a/Trio Watch App Extension/TrioWatchApp.swift +++ b/Trio Watch App Extension/TrioWatchApp.swift @@ -6,6 +6,7 @@ import UserNotifications init() { WatchNotificationHandler.shared.configure() + WatchLogger.shared.info(.lifecycle, "App launched") } var body: some Scene { @@ -14,9 +15,8 @@ import UserNotifications } .onChange(of: scenePhase) { _, newScenePhase in if newScenePhase == .background { - Task { - await WatchLogger.shared.flushPersistedLogs() - } + WatchLogger.shared.info(.lifecycle, "App moved to background") + WatchLogger.shared.didEnterBackground() } } } diff --git a/Trio Watch App Extension/WatchLogger.swift b/Trio Watch App Extension/WatchLogger.swift index ff6d87f13d..12d653fd45 100644 --- a/Trio Watch App Extension/WatchLogger.swift +++ b/Trio Watch App Extension/WatchLogger.swift @@ -1,150 +1,247 @@ import Foundation +import os import WatchConnectivity -actor WatchLogger { +final class WatchLogger: Sendable { static let shared = WatchLogger() - private var logs: [String] = [] - private let maxEntries = 500 - private let flushInterval: TimeInterval = 3 * 60 - private let flushSizeThreshold = 100 - private var lastFlush = Date() + fileprivate struct PendingDrafts { + var drafts: [WatchLogDraft] = [] + var flushScheduled = false + } - private let session = WCSession.default - private var timerTask: Task? + private static let flushDelay: Duration = .seconds(5) + private static let flushCount = 50 + private static let bufferLimit = 2000 + private static let shipInterval: Duration = .seconds(5 * 60) + + private let pending = OSAllocatedUnfairLock(initialState: PendingDrafts()) + private let shipper = WatchLogShipper() + private let throttle = OSAllocatedUnfairLock(initialState: [String: (state: String, date: Date)]()) + private let osLoggers: [WatchLogEntry.Category: os.Logger] = { + let subsystem = Bundle.main.bundleIdentifier ?? "Trio Watch" + return Dictionary(uniqueKeysWithValues: WatchLogEntry.Category.allCases.map { + ($0, os.Logger(subsystem: subsystem, category: $0.name)) + }) + }() private init() { - Task { - await startFlushTimer() + Task { [shipper, pending] in + while !Task.isCancelled { + try? await Task.sleep(for: Self.shipInterval) + await shipper.drain(pending) + await shipper.ship(onRequest: false) + } } } - private var dateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" - return formatter + func debug(_ category: WatchLogEntry.Category, _ message: String) { + log(.debug, category, message) } - private func startFlushTimer() async { - timerTask = Task { - while true { - try? await Task.sleep(nanoseconds: UInt64(flushInterval * 1_000_000_000)) - await flushIfNeeded(force: false) - } - } + func info(_ category: WatchLogEntry.Category, _ message: String) { + log(.info, category, message) } - func log( - _ message: String, - force: Bool = false, - function: String = #function, - file: String = #fileID, - line: Int = #line - ) async { - let shortFile = (file as NSString).lastPathComponent - let timestamp = dateFormatter.string(from: Date()) - let entry = "[\(timestamp)] [\(shortFile):\(line)] \(function) → \(message)" - - logs.append(entry) - if logs.count > maxEntries { - logs.removeFirst(logs.count - maxEntries) - } + func warning(_ category: WatchLogEntry.Category, _ message: String, error: Error? = nil) { + log(.warning, category, Self.message(message, error: error)) + } - print(entry) - await flushIfNeeded(force: force) + func error(_ category: WatchLogEntry.Category, _ message: String, error: Error? = nil) { + log(.error, category, Self.message(message, error: error)) } - func flushIfNeeded(force: Bool = false) async { + // At most one line per key and interval while the state stays the same + func shouldLog(_ key: String, state: String = "", every interval: TimeInterval = 30) -> Bool { let now = Date() - let shouldFlush = force || now.timeIntervalSince(lastFlush) >= flushInterval || logs.count >= flushSizeThreshold - - if shouldFlush { - await flushToPhone() + return throttle.withLock { last in + if let previous = last[key], previous.state == state, now.timeIntervalSince(previous.date) < interval { + return false + } + last[key] = (state, now) + return true } } - private func flushToPhone() async { - guard !logs.isEmpty else { - return + func didEnterBackground() { + Task { [shipper, pending] in + await shipper.drain(pending) + await shipper.ship(onRequest: false) } + } - let payload: [String: Any] = ["watchLogs": logs.joined(separator: "\n")] + // Requests and acks from the phone; returns false for unrelated messages + func handlePhoneMessage(_ message: [String: Any]) -> Bool { + if let ack = message[WatchLogTransfer.ack] as? NSNumber { + let installID = message[WatchLogTransfer.installID] as? String + Task { [shipper] in await shipper.acknowledge(ack.uint64Value, installID: installID) } + return true + } + if message[WatchLogTransfer.request] as? Bool == true { + Task { [shipper, pending] in + await shipper.drain(pending) + await shipper.ship(onRequest: true) + } + return true + } + return false + } - if session.activationState != .activated { - session.activate() + func transferFinished(_ transfer: WCSessionFileTransfer, error: Error?) { + guard let metadata = transfer.file.metadata, metadata[WatchLogTransfer.marker] != nil else { return } + let url = transfer.file.fileURL + let firstSeq = (metadata[WatchLogTransfer.firstSeq] as? NSNumber)?.uint64Value ?? 1 + if let error { + warning(.connectivity, "Log transfer to the phone failed, will retry", error: error) } + Task { [shipper] in await shipper.finished(url, firstSeq: firstSeq, failed: error != nil) } + } - if session.isReachable { - session.sendMessage(payload, replyHandler: nil) { _ in - Task { - await self.persistLogsLocally() - } + private func log(_ level: WatchLogEntry.Level, _ category: WatchLogEntry.Category, _ message: String) { + osLoggers[category]?.log(level: level.osLogType, "\(message, privacy: .public)") + let draft = WatchLogDraft(date: Date(), level: level, category: category, message: message) + let (flushNow, scheduleDelayed) = pending.withLock { state -> (Bool, Bool) in + state.drafts.append(draft) + if state.drafts.count > Self.bufferLimit { + state.drafts.removeFirst(state.drafts.count - Self.bufferLimit) + } + let scheduleDelayed = !state.flushScheduled + state.flushScheduled = true + return (state.drafts.count >= Self.flushCount, scheduleDelayed) + } + if flushNow { + Task { [shipper, pending] in await shipper.drain(pending) } + } else if scheduleDelayed { + Task { [shipper, pending] in + try? await Task.sleep(for: Self.flushDelay) + await shipper.drain(pending) } - } else { - await persistLogsLocally() } - - lastFlush = Date() - logs.removeAll() } - func persistLogsLocally() async { - let logDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - .appendingPathComponent("logs", isDirectory: true) + private static func message(_ message: String, error: Error?) -> String { + guard let error else { return message } + let nsError = error as NSError + return "\(message) (\(nsError.domain) \(nsError.code): \(nsError.localizedDescription))" + } +} - try? FileManager.default.createDirectory(at: logDir, withIntermediateDirectories: true) +private actor WatchLogShipper { + private static let staleTransfer: TimeInterval = 30 * 60 + private static let osLog = os.Logger(subsystem: Bundle.main.bundleIdentifier ?? "Trio Watch", category: "WatchLog") + + private var ring: WatchLogRing? + // Highest seq handed to a file transfer; falls back to the ack on failure or relaunch + private var sentSeq: UInt64 = 0 + + private let outbox = WatchLogRing.defaultDirectory.deletingLastPathComponent() + .appendingPathComponent("WatchLogOutbox", isDirectory: true) + + private func openRing() -> WatchLogRing { + if let ring { return ring } + let ring = WatchLogRing() + ring.prune() + self.ring = ring + sentSeq = ring.ackedSeq + clearOutbox() + return ring + } - let logFile = logDir.appendingPathComponent("watch_log.txt") - let previousLogFile = logDir.appendingPathComponent("watch_log_prev.txt") - let startOfDay = Calendar.current.startOfDay(for: Date()) + func drain(_ pending: OSAllocatedUnfairLock) { + let drafts = pending.withLock { state -> [WatchLogDraft] in + defer { + state.drafts.removeAll(keepingCapacity: true) + state.flushScheduled = false + } + return state.drafts + } + guard !drafts.isEmpty else { return } + let ring = openRing() + ring.append(drafts) + ring.prune() + } - if let attributes = try? FileManager.default.attributesOfItem(atPath: logFile.path), - let creationDate = attributes[.creationDate] as? Date, - creationDate < startOfDay - { - try? FileManager.default.removeItem(at: previousLogFile) - try? FileManager.default.moveItem(at: logFile, to: previousLogFile) - FileManager.default.createFile(atPath: logFile.path, contents: nil, attributes: [.creationDate: startOfDay]) + func ship(onRequest: Bool) { + let ring = openRing() + let session = WCSession.default + guard session.activationState == .activated, session.isCompanionAppInstalled else { return } + + let outstanding = session.outstandingFileTransfers.filter { $0.file.metadata?[WatchLogTransfer.marker] != nil } + let cutoff = Date().timeIntervalSince1970 - Self.staleTransfer + let stale = outstanding.filter { ($0.file.metadata?[WatchLogTransfer.created] as? TimeInterval ?? 0) < cutoff } + if !stale.isEmpty { + stale.forEach { $0.cancel() } + sentSeq = ring.ackedSeq + } else if !outstanding.isEmpty, !onRequest { + return } - let fullLog = logs.joined(separator: "\n") + "\n" - if let data = fullLog.data(using: .utf8) { - if let handle = try? FileHandle(forWritingTo: logFile) { - try? handle.seekToEnd() - handle.write(data) - try? handle.close() - } else { - try? data.write(to: logFile) - } + // A request resends everything unacknowledged so a lost earlier batch can't leave a gap + let from = onRequest ? ring.ackedSeq : max(ring.ackedSeq, sentSeq) + guard ring.lastSeq > from else { return } + let entries = ring.entries(after: from) + guard let first = entries.first, let last = entries.last else { return } + + let url = outbox.appendingPathComponent("\(first.seq)-\(last.seq).jsonl") + do { + try FileManager.default.createDirectory(at: outbox, withIntermediateDirectories: true) + try WatchLogEntry.jsonLines(entries).write(to: url, options: .atomic) + } catch { + Self.osLog.error("Writing watch log batch failed: \(String(describing: error), privacy: .public)") + return } + let metadata: [String: Any] = [ + WatchLogTransfer.marker: WatchLogTransfer.version, + WatchLogTransfer.firstSeq: NSNumber(value: first.seq), + WatchLogTransfer.lastSeq: NSNumber(value: last.seq), + WatchLogTransfer.watchBuild: Self.build, + WatchLogTransfer.installID: ring.installID, + WatchLogTransfer.created: Date().timeIntervalSince1970 + ] + session.transferFile(url, metadata: metadata) + sentSeq = last.seq + Self.osLog.debug("Queued watch log entries \(first.seq)-\(last.seq) for transfer") } - func flushPersistedLogs() async { - let logDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - .appendingPathComponent("logs", isDirectory: true) - let logFile = logDir.appendingPathComponent("watch_log.txt") - - guard let data = try? Data(contentsOf: logFile), - let logString = String(data: data, encoding: .utf8), - !logString.isEmpty - else { return } + func finished(_ url: URL, firstSeq: UInt64, failed: Bool) { + try? FileManager.default.removeItem(at: url) + if failed { + sentSeq = min(sentSeq, firstSeq - 1) + } + } - let payload: [String: Any] = ["watchLogs": logString] + func acknowledge(_ seq: UInt64, installID: String?) { + let ring = openRing() + guard installID == ring.installID else { return } + ring.acknowledge(seq) + sentSeq = max(sentSeq, ring.ackedSeq) + } - if session.activationState != .activated { - session.activate() + // Batches left over from an earlier launch are rebuilt from the ring + private func clearOutbox() { + let inFlight = Set(WCSession.default.outstandingFileTransfers.map(\.file.fileURL.lastPathComponent)) + let files = (try? FileManager.default.contentsOfDirectory(at: outbox, includingPropertiesForKeys: nil)) ?? [] + for file in files where !inFlight.contains(file.lastPathComponent) { + try? FileManager.default.removeItem(at: file) } + } - if session.isReachable { - session.sendMessage(payload, replyHandler: nil) { _ in - Task { - await self.persistLogsLocally() - } - } - try? FileManager.default.removeItem(at: logFile) - } else { - _ = session.transferUserInfo(payload) - try? FileManager.default.removeItem(at: logFile) + private static let build: String = { + let info = Bundle.main.infoDictionary + let version = info?["CFBundleShortVersionString"] as? String ?? "?" + let build = info?["CFBundleVersion"] as? String ?? "?" + return "\(version) (\(build))" + }() +} + +private extension WatchLogEntry.Level { + var osLogType: OSLogType { + switch self { + case .debug: return .debug + case .info: return .info + case .warning: return .default + case .error: return .error + case .fault: return .fault } } } diff --git a/Trio Watch App Extension/WatchState+Requests.swift b/Trio Watch App Extension/WatchState+Requests.swift index eefd5b5f17..6e44d83712 100644 --- a/Trio Watch App Extension/WatchState+Requests.swift +++ b/Trio Watch App Extension/WatchState+Requests.swift @@ -9,31 +9,22 @@ extension WatchState { /// - amount: The insulin amount to be delivered func sendBolusRequest(_ amount: Decimal) { guard let session = session, session.isReachable else { - Task { - await WatchLogger.shared.log("⌚️ Bolus request aborted: session unreachable") - } + WatchLogger.shared.warning(.bolus, "Bolus request not sent, phone unreachable") return } - Task { - await WatchLogger.shared.log("⌚️ Sending bolus request: \(amount)U") - } + WatchLogger.shared.info(.bolus, "Bolus of \(amount) U requested") let message: [String: Any] = [ WatchMessageKeys.bolus: amount ] session.sendMessage(message, replyHandler: nil) { error in - Task { - await WatchLogger.shared.log("Error sending bolus request: \(error)") - } + WatchLogger.shared.error(.bolus, "Sending bolus request failed", error: error) } // Display pending communication animation showCommsAnimation = true - Task { - await WatchLogger.shared.log("⌚️ showCommsAnimation = true") - } } /// Sends a carbohydrate entry request to the paired iPhone @@ -42,15 +33,11 @@ extension WatchState { /// - date: The timestamp for the carb entry (defaults to current time) func sendCarbsRequest(_ amount: Int, _ date: Date = Date()) { guard let session = session, session.isReachable else { - Task { - await WatchLogger.shared.log("⌚️ Carbs request aborted: session unreachable") - } + WatchLogger.shared.warning(.treatments, "Carbs request not sent, phone unreachable") return } - Task { - await WatchLogger.shared.log("⌚️ Sending carbs request: \(amount)g at \(date)") - } + WatchLogger.shared.info(.treatments, "Carbs of \(amount) g requested for \(date)") let message: [String: Any] = [ WatchMessageKeys.carbs: amount, @@ -58,162 +45,107 @@ extension WatchState { ] session.sendMessage(message, replyHandler: nil) { error in - Task { - await WatchLogger.shared.log("Error sending carbs request: \(error)") - await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!") - await WatchLogger.shared.persistLogsLocally() - } + WatchLogger.shared.error(.treatments, "Sending carbs request failed", error: error) } // Display pending communication animation showCommsAnimation = true - Task { - await WatchLogger.shared.log("⌚️ showCommsAnimation = true") - } } /// Sends a request to cancel the current override preset to the paired iPhone func sendCancelOverrideRequest() { guard let session = session, session.isReachable else { - Task { - await WatchLogger.shared.log("⌚️ Cancel override request aborted: session unreachable") - } + WatchLogger.shared.warning(.treatments, "Override cancel not sent, phone unreachable") return } - Task { - await WatchLogger.shared.log("⌚️ Sending cancel override request") - } + WatchLogger.shared.info(.treatments, "Override cancel requested") let message: [String: Any] = [ WatchMessageKeys.cancelOverride: true ] session.sendMessage(message, replyHandler: nil) { error in - Task { - await WatchLogger.shared.log("⌚️ Error sending cancel override request: \(error)") - await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!") - await WatchLogger.shared.persistLogsLocally() - } + WatchLogger.shared.error(.treatments, "Sending override cancel failed", error: error) } // Display pending communication animation showCommsAnimation = true - Task { - await WatchLogger.shared.log("⌚️ showCommsAnimation = true") - } } /// Sends a request to activate an override preset to the paired iPhone /// - Parameter presetName: The name of the override preset to activate func sendActivateOverrideRequest(presetName: String) { guard let session = session, session.isReachable else { - Task { - await WatchLogger.shared.log("⌚️ Activate override request aborted: session unreachable") - } + WatchLogger.shared.warning(.treatments, "Override request not sent, phone unreachable") return } - Task { - await WatchLogger.shared.log("⌚️ Sending activate override request for preset: \(presetName)") - } + WatchLogger.shared.info(.treatments, "Override preset \(presetName) requested") let message: [String: Any] = [ WatchMessageKeys.activateOverride: presetName ] session.sendMessage(message, replyHandler: nil) { error in - Task { - await WatchLogger.shared.log("⌚️ Error sending activate override request: \(error)") - await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!") - await WatchLogger.shared.persistLogsLocally() - } + WatchLogger.shared.error(.treatments, "Sending override request failed", error: error) } // Display pending communication animation showCommsAnimation = true - Task { - await WatchLogger.shared.log("⌚️ showCommsAnimation = true") - } } /// Sends a request to cancel the current temporary target to the paired iPhone func sendCancelTempTargetRequest() { guard let session = session, session.isReachable else { - Task { - await WatchLogger.shared.log("⌚️ Cancel temp target request aborted: session unreachable") - } + WatchLogger.shared.warning(.treatments, "Temp target cancel not sent, phone unreachable") return } - Task { - await WatchLogger.shared.log("⌚️ Sending cancel temp target request") - } + WatchLogger.shared.info(.treatments, "Temp target cancel requested") let message: [String: Any] = [ WatchMessageKeys.cancelTempTarget: true ] session.sendMessage(message, replyHandler: nil) { error in - Task { - await WatchLogger.shared.log("⌚️ Error sending cancel temp target request: \(error)") - await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!") - await WatchLogger.shared.persistLogsLocally() - } + WatchLogger.shared.error(.treatments, "Sending temp target cancel failed", error: error) } // Display pending communication animation showCommsAnimation = true - Task { - await WatchLogger.shared.log("⌚️ showCommsAnimation = true") - } } /// Sends a request to activate a temporary target preset to the paired iPhone /// - Parameter presetName: The name of the temporary target preset to activate func sendActivateTempTargetRequest(presetName: String) { guard let session = session, session.isReachable else { - Task { - await WatchLogger.shared.log("⌚️ Activate temp target request aborted: session unreachable") - } + WatchLogger.shared.warning(.treatments, "Temp target request not sent, phone unreachable") return } - Task { - await WatchLogger.shared.log("⌚️ Sending activate temp target request for preset: \(presetName)") - } + WatchLogger.shared.info(.treatments, "Temp target preset \(presetName) requested") let message: [String: Any] = [ WatchMessageKeys.activateTempTarget: presetName ] session.sendMessage(message, replyHandler: nil) { error in - Task { - await WatchLogger.shared.log("⌚️ Error sending activate temp target request: \(error)") - await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!") - await WatchLogger.shared.persistLogsLocally() - } + WatchLogger.shared.error(.treatments, "Sending temp target request failed", error: error) } // Display pending communication animation showCommsAnimation = true - Task { - await WatchLogger.shared.log("⌚️ showCommsAnimation = true") - } } /// Sends a request to calculate a bolus recommendation based on the current carbs amount func requestBolusRecommendation() { guard let session = session, session.isReachable else { - Task { - await WatchLogger.shared.log("⌚️ Bolus recommendation request aborted: session unreachable") - } + WatchLogger.shared.warning(.bolus, "Bolus recommendation not requested, phone unreachable") return } - Task { - await WatchLogger.shared.log("⌚️ Requesting bolus recommendation for carbs: \(carbsAmount)") - } + WatchLogger.shared.debug(.bolus, "Bolus recommendation requested for \(carbsAmount) g carbs") // Show a progress indicator until the phone replies. This keeps the user from // tapping the (0 U) "Log Carbs" button before the recommendation has landed. @@ -236,11 +168,7 @@ extension WatchState { ] session.sendMessage(message, replyHandler: nil) { error in - Task { - await WatchLogger.shared.log("Error requesting bolus recommendation: \(error)") - await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!") - await WatchLogger.shared.persistLogsLocally() - } + WatchLogger.shared.warning(.bolus, "Requesting bolus recommendation failed", error: error) DispatchQueue.main.async { self.showBolusCalculationProgress = false } @@ -249,38 +177,26 @@ extension WatchState { func requestWatchStateUpdate() { guard let session = session else { - Task { - await WatchLogger.shared.log("⌚️ No session available for state update") - } + WatchLogger.shared.warning(.connectivity, "No session for a state update") return } guard session.activationState == .activated else { - Task { - await WatchLogger.shared.log("⌚️ Session not activated. Activating...") - } + WatchLogger.shared.debug(.connectivity, "Session not activated, activating") session.activate() return } if session.isReachable { - Task { - await WatchLogger.shared.log("⌚️ Requesting WatchState update from iPhone") - } + WatchLogger.shared.debug(.data, "Requesting a state update from the phone") let message = [WatchMessageKeys.requestWatchUpdate: WatchMessageKeys.watchState] session.sendMessage(message, replyHandler: nil) { error in - Task { - await WatchLogger.shared.log("⌚️ Error requesting WatchState update: \(error)") - await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!") - await WatchLogger.shared.persistLogsLocally() - } + WatchLogger.shared.warning(.connectivity, "Requesting a state update failed", error: error) } } else { - Task { - await WatchLogger.shared.log("⌚️ Phone not reachable for WatchState update") - } + WatchLogger.shared.debug(.connectivity, "Phone unreachable, state update not requested") } } } diff --git a/Trio Watch App Extension/WatchState.swift b/Trio Watch App Extension/WatchState.swift index 805f528955..389efd87d9 100644 --- a/Trio Watch App Extension/WatchState.swift +++ b/Trio Watch App Extension/WatchState.swift @@ -100,27 +100,16 @@ import WatchConnectivity session.delegate = self session.activate() self.session = session - Task { - await WatchLogger.shared.log("⌚️ WCSession setup complete.") - } + WatchLogger.shared.debug(.connectivity, "Session set up") } else { - Task { - await WatchLogger.shared.log("⌚️ WCSession is not supported on this device") - } + WatchLogger.shared.error(.connectivity, "Watch connectivity not supported on this device") } } // MARK: – Handle Acknowledgement Messages FROM Phone func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) { - Task { - await WatchLogger.shared.log("Handling acknowledgment: \(message), success: \(success), isFinal: \(isFinal)") - } - if success { - Task { - await WatchLogger.shared.log("⌚️ Acknowledgment received: \(message)") - } acknowledgementStatus = .success acknowledgmentMessage = message @@ -129,10 +118,6 @@ import WatchConnectivity self.showCommsAnimation = false } } else { - Task { - await WatchLogger.shared.log("⌚️ Acknowledgment failed: \(message)") - } - // Hide progress animation DispatchQueue.main.async { self.showCommsAnimation = false @@ -149,9 +134,6 @@ import WatchConnectivity DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.showAcknowledgmentBanner = false self.showSyncingAnimation = false // Just ensure this is 100% set to false - Task { - await WatchLogger.shared.log("Cleared ack banner and syncing animation") - } } } } @@ -163,44 +145,33 @@ import WatchConnectivity func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { DispatchQueue.main.async { if let error = error { - Task { - await WatchLogger.shared.log("⌚️ Watch session activation failed: \(error)", force: true) - await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!") - await WatchLogger.shared.persistLogsLocally() - } + WatchLogger.shared.error(.connectivity, "Session activation failed", error: error) return } if activationState == .activated { - Task { - await WatchLogger.shared.log("⌚️ Watch session activated with state: \(activationState.rawValue)") - } + WatchLogger.shared.info(.connectivity, "Session activated, phone reachable: \(session.isReachable)") self.forceConditionalWatchStateUpdate() self.isReachable = session.isReachable - - Task { - await WatchLogger.shared.log("⌚️ Watch isReachable after activation: \(session.isReachable)") - } } } } /// Handles incoming messages from the paired iPhone when Phone is in the foreground func session(_: WCSession, didReceiveMessage message: [String: Any]) { - Task { - await WatchLogger.shared.log("⌚️ Watch received data: \(message)") - } + if WatchLogger.shared.handlePhoneMessage(message) { return } // Ack at top level — no `watchState` wrapper, no staleness check. if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool, let ackMessage = message[WatchMessageKeys.message] as? String, let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String { - Task { - await WatchLogger.shared - .log("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged), ackCode: \(ackCodeRaw)") + if acknowledged { + WatchLogger.shared.info(.treatments, "Phone confirmed request (\(ackCodeRaw)): \(ackMessage)") + } else { + WatchLogger.shared.warning(.treatments, "Phone rejected request (\(ackCodeRaw)): \(ackMessage)") } DispatchQueue.main.async { self.showSyncingAnimation = false @@ -211,9 +182,7 @@ import WatchConnectivity // Recommended bolus is also not part of the WatchState message. if let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber { - Task { - await WatchLogger.shared.log("⌚️ Received recommended bolus: \(recommendedBolus)") - } + WatchLogger.shared.debug(.bolus, "Bolus recommendation of \(recommendedBolus) U received") DispatchQueue.main.async { self.recommendedBolus = recommendedBolus.decimalValue self.showBolusCalculationProgress = false @@ -225,9 +194,14 @@ import WatchConnectivity } func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + if WatchLogger.shared.handlePhoneMessage(userInfo) { return } handleIncomingWatchStatePayload(userInfo) } + func session(_: WCSession, didFinish fileTransfer: WCSessionFileTransfer, error: (any Error)?) { + WatchLogger.shared.transferFinished(fileTransfer, error: error) + } + /// Shared path for watch-state payloads from either delegate method. /// Enforces the freshness contract in one place so the two delivery paths /// can't drift. @@ -235,7 +209,7 @@ import WatchConnectivity guard let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any], let timestamp = payload[WatchMessageKeys.date] as? TimeInterval else { - Task { await WatchLogger.shared.log("⌚️ Faulty watch state payload — skipping", force: true) } + WatchLogger.shared.warning(.data, "Watch state payload malformed, skipped") DispatchQueue.main.async { self.showSyncingAnimation = false } return } @@ -245,7 +219,9 @@ import WatchConnectivity // the watch app wakes after long disuse; without it, every payload // schedules merge + UI work. guard date >= Date().addingTimeInterval(-Self.maxAcceptableMessageAgeInMinutes) else { - Task { await WatchLogger.shared.log("⌚️ Skipping stale watch state (\(date))") } + if WatchLogger.shared.shouldLog("staleState") { + WatchLogger.shared.debug(.data, "Stale watch state from \(date) skipped") + } DispatchQueue.main.async { self.showSyncingAnimation = false } return } @@ -253,7 +229,9 @@ import WatchConnectivity // Monotonicity dedup. let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk() guard date > lastProcessed else { - Task { await WatchLogger.shared.log("⌚️ Skipping duplicate watch state (\(date))") } + if WatchLogger.shared.shouldLog("duplicateState") { + WatchLogger.shared.debug(.data, "Duplicate watch state from \(date) skipped") + } return } @@ -265,11 +243,7 @@ import WatchConnectivity func session(_: WCSession, didFinish _: WCSessionUserInfoTransfer, error: (any Error)?) { if let error = error { - Task { - await WatchLogger.shared.log("⌚️ transferUserInfo failed with error: \(error)") - await WatchLogger.shared.log("⌚️ Saving logs to disk as fallback!") - await WatchLogger.shared.persistLogsLocally() - } + WatchLogger.shared.warning(.connectivity, "User info transfer to the phone failed", error: error) } } @@ -277,8 +251,8 @@ import WatchConnectivity /// Updates the local reachability status func sessionReachabilityDidChange(_ session: WCSession) { DispatchQueue.main.async { - Task { - await WatchLogger.shared.log("⌚️ Watch reachability changed: \(session.isReachable)") + if WatchLogger.shared.shouldLog("reachability", state: "\(session.isReachable)") { + WatchLogger.shared.debug(.connectivity, "Phone reachable: \(session.isReachable)") } if session.isReachable { @@ -304,9 +278,7 @@ import WatchConnectivity /// it will show a syncing animation and request a new watch state update from the iPhone app. private func forceConditionalWatchStateUpdate() { guard let lastUpdateTimestamp = lastWatchStateUpdate else { - Task { - await WatchLogger.shared.log("Forcing initial WatchState update") - } + WatchLogger.shared.debug(.data, "No watch state yet, requesting one") // If there's no recorded timestamp, we must force a fresh update immediately. showSyncingAnimation = true @@ -316,12 +288,10 @@ import WatchConnectivity let now = Date().timeIntervalSince1970 let secondsSinceUpdate = now - lastUpdateTimestamp - Task { - await WatchLogger.shared.log("Time since last update: \(secondsSinceUpdate) seconds") - } // If more than 15 seconds have elapsed since the last update, force an(other) update. if secondsSinceUpdate > 15 { + WatchLogger.shared.debug(.data, "Watch state \(Int(secondsSinceUpdate)) s old, requesting update") showSyncingAnimation = true requestWatchStateUpdate() return @@ -341,10 +311,6 @@ import WatchConnectivity self.showSyncingAnimation = false } - Task { - await WatchLogger.shared.log("⌚️ Received acknowledgment: \(ackMessage), success: \(acknowledged)") - } - switch ackCode { case .savingCarbs: self.isMealBolusCombo = true @@ -378,9 +344,7 @@ import WatchConnectivity let lastTimestamp = lastWatchStateUpdate, incomingTimestamp <= lastTimestamp { - Task { - await WatchLogger.shared.log("Skipping UI update — outdated WatchState (\(incomingTimestamp))") - } + WatchLogger.shared.debug(.data, "Outdated watch state skipped") return } @@ -389,10 +353,6 @@ import WatchConnectivity self.showSyncingAnimation = true } - Task { - await WatchLogger.shared.log("Merging new WatchState data with keys: \(newData.keys.joined(separator: ", "))") - } - // 2) Merge data into our pendingData pendingData.merge(newData) { _, newVal in newVal } @@ -401,9 +361,6 @@ import WatchConnectivity // 4) Create and schedule a new finalization let workItem = DispatchWorkItem { [self] in - Task { - await WatchLogger.shared.log("⏳ Debounced update fired") - } self.finalizePendingData() } finalizeWorkItem = workItem @@ -413,10 +370,6 @@ import WatchConnectivity /// Applies all pending data to the watch state in one shot private func finalizePendingData() { guard !pendingData.isEmpty else { - Task { - await WatchLogger.shared.log("⚠️ finalizePendingData called with empty data") - } - // If we have no actual data, just end syncing DispatchQueue.main.async { self.showSyncingAnimation = false @@ -424,10 +377,6 @@ import WatchConnectivity return } - Task { - await WatchLogger.shared.log("⌚️ Finalizing pending data") - } - // Actually set your main UI properties here processRawDataForWatchState(pendingData) @@ -439,22 +388,13 @@ import WatchConnectivity self.showSyncingAnimation = false } - Task { - await WatchLogger.shared.log("✅ Watch UI update complete") - } + WatchLogger.shared.debug(.data, "Watch state applied") } /// Updates the UI properties private func processRawDataForWatchState(_ message: [String: Any]) { - Task { - await WatchLogger.shared.log("Processing raw WatchState data with keys: \(message.keys.joined(separator: ", "))") - } - if let timestamp = message[WatchMessageKeys.date] as? TimeInterval { lastWatchStateUpdate = timestamp - Task { - await WatchLogger.shared.log("Updated lastWatchStateUpdate: \(timestamp)") - } } if let currentGlucose = message[WatchMessageKeys.currentGlucose] as? String { diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 87db77f797..ba4b44bd7d 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -179,15 +179,10 @@ 38E87403274F78C000975559 /* libswiftCoreNFC.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 38E87402274F78C000975559 /* libswiftCoreNFC.tbd */; settings = {ATTRIBUTES = (Weak, ); }; }; 38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E87407274F9AD000975559 /* UserNotificationsManager.swift */; }; 38E989DD25F5021400C0CED0 /* PumpStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E989DC25F5021400C0CED0 /* PumpStatus.swift */; }; - 38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1B25F52C9300C0CED0 /* Signpost.swift */; }; 38E98A2425F52C9300C0CED0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1C25F52C9300C0CED0 /* Logger.swift */; }; - 38E98A2525F52C9300C0CED0 /* IssueReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1E25F52C9300C0CED0 /* IssueReporter.swift */; }; - 38E98A2725F52C9300C0CED0 /* CollectionIssueReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A2025F52C9300C0CED0 /* CollectionIssueReporter.swift */; }; - 38E98A2925F52C9300C0CED0 /* Error+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A2225F52C9300C0CED0 /* Error+Extensions.swift */; }; 38E98A2D25F52DC400C0CED0 /* NSLocking+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */; }; 38E98A3025F52FF700C0CED0 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A2F25F52FF700C0CED0 /* Config.swift */; }; 38E98A3725F5509500C0CED0 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A3625F5509500C0CED0 /* String+Extensions.swift */; }; - 38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */; }; 38EA0600262091870064E39B /* BolusProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */; }; 38F37828261260DC009DB701 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F37827261260DC009DB701 /* Color+Extensions.swift */; }; 38F3B2EF25ED8E2A005C48AA /* TempTargetsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */; }; @@ -396,7 +391,6 @@ 49F299352D2D82EA00E4C00D /* B30SettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F299312D2D82EA00E4C00D /* B30SettingsProvider.swift */; }; 49F299362D2D82EA00E4C00D /* B30SettingsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F299322D2D82EA00E4C00D /* B30SettingsStateModel.swift */; }; 49F299372D2D82EA00E4C00D /* B30SettingsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F2992E2D2D82EA00E4C00D /* B30SettingsRootView.swift */; }; - 49F4A34D2E044D7E008A93CC /* ShareLogsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F4A34C2E044D66008A93CC /* ShareLogsTests.swift */; }; 4EF7C2656FA0BC2AEA9B8764 /* ProfileSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = E762E5BC01E1CF1163C922EA /* ProfileSchedule.swift */; }; 5075C1608E6249A51495C422 /* TargetsEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */; }; 53F2382465BF74DB1A967C8B /* PumpConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8630D58BDAD6D9C650B9B39 /* PumpConfigProvider.swift */; }; @@ -1183,6 +1177,39 @@ CA0924000000000000000002 /* GRICalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0924000000000000000001 /* GRICalculator.swift */; }; CA0924000000000000000004 /* GRIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0924000000000000000003 /* GRIView.swift */; }; CA0924000000000000000006 /* GRICalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0924000000000000000005 /* GRICalculatorTests.swift */; }; + DE6F63D93662C12AD18A3802 /* LogRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F0205BEC3117B5D00022C30 /* LogRecord.swift */; }; + 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */; }; + 91FC2FB975FB723C55744FB1 /* LogSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB5F206A024F911C4E145D5 /* LogSink.swift */; }; + 931AD2C542093B9D1A342065 /* InMemoryLogSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C171A8FC35B730F1FFF25F /* InMemoryLogSink.swift */; }; + 27DE1FE3427339F87A4D1892 /* LaunchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24AF0AA0328EA5E4016BD4D6 /* LaunchID.swift */; }; + E0F5C5534791F6BB9A237891 /* TrioLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69EF64E9534E4822199EA279 /* TrioLogger.swift */; }; + 10C13662926D99DC9EDB0990 /* DeviceLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 260A8D718D2B6656B925FD08 /* DeviceLogStore.swift */; }; + 96310848E7A3468607C340E9 /* LogExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DB8B37A782E6D75420DB2FC /* LogExport.swift */; }; + B0D5791570373A23CD562E97 /* LogFileHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FBFFB8EF42D847AFFDA6C05 /* LogFileHeader.swift */; }; + BF0FA8AAE98B4080B961F69F /* LogFileWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 366F24AC292DBC7BC55CF196 /* LogFileWriter.swift */; }; + 4ABD323927D718C0177C26B5 /* OSLogCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */; }; + A8354E8A31A4F482D0B649F1 /* Redactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0396B90CF23D28D2FDCD78 /* Redactor.swift */; }; + 536B4DB89FCFE9188F4DFE5D /* SensitiveValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCB6F0602E069F5B4F83EDC /* SensitiveValues.swift */; }; + 4E3B6212E43A3E90DD735B30 /* AppLog.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */; }; + F0D6F2237EB1FB4862D0D4D7 /* AppLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39062D177D83C69CAFC9B2AC /* AppLogStore.swift */; }; + FF42480CF46A31D00C817D8C /* WatchLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D79872B74ADDBDD11682D0 /* WatchLogEntry.swift */; }; + E8C6B60FFBA9D89DE3BCCA99 /* WatchLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D79872B74ADDBDD11682D0 /* WatchLogEntry.swift */; }; + 6DB56C9F3F74834C536E9199 /* WatchLogIngest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 921084A4E9558519903842AE /* WatchLogIngest.swift */; }; + 8B735B34F861E3AE08F5C9BE /* WatchLogRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75C18518C0B49FB16C05F355 /* WatchLogRing.swift */; }; + 92DE66B4776804F0DD8A2925 /* WatchLogRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75C18518C0B49FB16C05F355 /* WatchLogRing.swift */; }; + 659C83549641F3FCC56F30AB /* ReportProblemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7373EC0CC3C059178CB967B4 /* ReportProblemView.swift */; }; + F6854A961CB1CF1105757936 /* LogShareRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EB8FEFF04CF8327EE83D84 /* LogShareRow.swift */; }; + 91AE5F35FC765DFE4C1A2589 /* AppLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */; }; + 2D5DFD6C90037128EA3DF5A9 /* DeviceLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161D44BBD9BB82CE0AF6B4EC /* DeviceLogStoreTests.swift */; }; + 38B4E1780D264CEBEB5DBCB3 /* LogExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF80D3953C10DCDF5D80C27 /* LogExportTests.swift */; }; + 4C0A9E5D31B7F26E8D1A0C42 /* LoggingLongRunStressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3B19D06A4C85F2E91D3B57 /* LoggingLongRunStressTests.swift */; }; + F07C663CC423264E609D706C /* LoggingRobustnessStressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258BD00B3EAADF1A62EFC884 /* LoggingRobustnessStressTests.swift */; }; + 3F30764ED6567CB6B1DF68C1 /* LoggingStressFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510C461CD60549BDF7E57BE9 /* LoggingStressFixtures.swift */; }; + EAE1104EF2313F9A0041C746 /* LoggingVolumeStressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A4F4087B27F7D5819A37272 /* LoggingVolumeStressTests.swift */; }; + 2C9535979A6E92EE0EC83255 /* RedactorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 704720C410E0C4F7D10B184B /* RedactorTests.swift */; }; + BD14CA4C88D904242178280A /* TrioLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7BC1040D2F0BD84E0718784 /* TrioLoggerTests.swift */; }; + FC83BDFB7C330BFD6A4DECFC /* WatchLogIngestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */; }; + 03DE042795C2AFA00C2CCE9E /* WatchLogRingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA5E2B256AC7F8447B1F1C3 /* WatchLogRingTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1480,15 +1507,10 @@ 38E87402274F78C000975559 /* libswiftCoreNFC.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libswiftCoreNFC.tbd; path = usr/lib/swift/libswiftCoreNFC.tbd; sourceTree = SDKROOT; }; 38E87407274F9AD000975559 /* UserNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationsManager.swift; sourceTree = ""; }; 38E989DC25F5021400C0CED0 /* PumpStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatus.swift; sourceTree = ""; }; - 38E98A1B25F52C9300C0CED0 /* Signpost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Signpost.swift; sourceTree = ""; }; 38E98A1C25F52C9300C0CED0 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; - 38E98A1E25F52C9300C0CED0 /* IssueReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssueReporter.swift; sourceTree = ""; }; - 38E98A2025F52C9300C0CED0 /* CollectionIssueReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionIssueReporter.swift; sourceTree = ""; }; - 38E98A2225F52C9300C0CED0 /* Error+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Error+Extensions.swift"; sourceTree = ""; }; 38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSLocking+Extensions.swift"; sourceTree = ""; }; 38E98A2F25F52FF700C0CED0 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; 38E98A3625F5509500C0CED0 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; - 38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleLogReporter.swift; sourceTree = ""; }; 38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressViewStyle.swift; sourceTree = ""; }; 38F37827261260DC009DB701 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; 38F3783A2613555C009DB701 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; @@ -1679,7 +1701,6 @@ 49F299302D2D82EA00E4C00D /* B30SettingsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = B30SettingsDataFlow.swift; sourceTree = ""; }; 49F299312D2D82EA00E4C00D /* B30SettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = B30SettingsProvider.swift; sourceTree = ""; }; 49F299322D2D82EA00E4C00D /* B30SettingsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = B30SettingsStateModel.swift; sourceTree = ""; }; - 49F4A34C2E044D66008A93CC /* ShareLogsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLogsTests.swift; sourceTree = ""; }; 4B8F3B0E159844FD8746DFC0 /* TempTargetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetData.swift; sourceTree = ""; }; 4DD795BA46B193644D48138C /* TargetsEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorRootView.swift; sourceTree = ""; }; 505E09DC17A0C3D0AF4B66FE /* ISFEditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorStateModel.swift; sourceTree = ""; }; @@ -2458,6 +2479,37 @@ CA0924000000000000000001 /* GRICalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRICalculator.swift; sourceTree = ""; }; CA0924000000000000000003 /* GRIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRIView.swift; sourceTree = ""; }; CA0924000000000000000005 /* GRICalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRICalculatorTests.swift; sourceTree = ""; }; + 0F0205BEC3117B5D00022C30 /* LogRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRecord.swift; sourceTree = ""; }; + 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRetentionPolicy.swift; sourceTree = ""; }; + ABB5F206A024F911C4E145D5 /* LogSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogSink.swift; sourceTree = ""; }; + E9C171A8FC35B730F1FFF25F /* InMemoryLogSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryLogSink.swift; sourceTree = ""; }; + 24AF0AA0328EA5E4016BD4D6 /* LaunchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchID.swift; sourceTree = ""; }; + 69EF64E9534E4822199EA279 /* TrioLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioLogger.swift; sourceTree = ""; }; + 260A8D718D2B6656B925FD08 /* DeviceLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogStore.swift; sourceTree = ""; }; + 7DB8B37A782E6D75420DB2FC /* LogExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExport.swift; sourceTree = ""; }; + 7FBFFB8EF42D847AFFDA6C05 /* LogFileHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFileHeader.swift; sourceTree = ""; }; + 366F24AC292DBC7BC55CF196 /* LogFileWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFileWriter.swift; sourceTree = ""; }; + 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCapture.swift; sourceTree = ""; }; + 3B0396B90CF23D28D2FDCD78 /* Redactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Redactor.swift; sourceTree = ""; }; + 0CCB6F0602E069F5B4F83EDC /* SensitiveValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensitiveValues.swift; sourceTree = ""; }; + 6AB077E2E3EC75CCBDEFF8B5 /* AppLog.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AppLog.xcdatamodel; sourceTree = ""; }; + 39062D177D83C69CAFC9B2AC /* AppLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogStore.swift; sourceTree = ""; }; + F5D79872B74ADDBDD11682D0 /* WatchLogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogEntry.swift; sourceTree = ""; }; + 921084A4E9558519903842AE /* WatchLogIngest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogIngest.swift; sourceTree = ""; }; + 75C18518C0B49FB16C05F355 /* WatchLogRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogRing.swift; sourceTree = ""; }; + 7373EC0CC3C059178CB967B4 /* ReportProblemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProblemView.swift; sourceTree = ""; }; + F7EB8FEFF04CF8327EE83D84 /* LogShareRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogShareRow.swift; sourceTree = ""; }; + 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogStoreTests.swift; sourceTree = ""; }; + 161D44BBD9BB82CE0AF6B4EC /* DeviceLogStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogStoreTests.swift; sourceTree = ""; }; + CAF80D3953C10DCDF5D80C27 /* LogExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExportTests.swift; sourceTree = ""; }; + 7E3B19D06A4C85F2E91D3B57 /* LoggingLongRunStressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingLongRunStressTests.swift; sourceTree = ""; }; + 258BD00B3EAADF1A62EFC884 /* LoggingRobustnessStressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingRobustnessStressTests.swift; sourceTree = ""; }; + 510C461CD60549BDF7E57BE9 /* LoggingStressFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingStressFixtures.swift; sourceTree = ""; }; + 5A4F4087B27F7D5819A37272 /* LoggingVolumeStressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingVolumeStressTests.swift; sourceTree = ""; }; + 704720C410E0C4F7D10B184B /* RedactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactorTests.swift; sourceTree = ""; }; + C7BC1040D2F0BD84E0718784 /* TrioLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioLoggerTests.swift; sourceTree = ""; }; + 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogIngestTests.swift; sourceTree = ""; }; + FEA5E2B256AC7F8447B1F1C3 /* WatchLogRingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogRingTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -3312,6 +3364,7 @@ 7A11AC0DE000000000000170 /* GlassActionSheet.swift */, 7A11AC0DE000000000000014 /* ManualGlucoseEntryView.swift */, DD500A712807000000000100 /* SnoozeAlertsSheetView.swift */, + F7EB8FEFF04CF8327EE83D84 /* LogShareRow.swift */, ); path = Views; sourceTree = ""; @@ -3619,24 +3672,17 @@ 38E98A1A25F52C9300C0CED0 /* Logger */ = { isa = PBXGroup; children = ( - 38E98A1B25F52C9300C0CED0 /* Signpost.swift */, 38E98A1C25F52C9300C0CED0 /* Logger.swift */, - 38E98A1D25F52C9300C0CED0 /* IssueReporter */, - 38E98A2225F52C9300C0CED0 /* Error+Extensions.swift */, + 79A6FC1583EE7761756C86BD /* Contracts */, + 9F14EBDAAE691A7D9C7913DE /* Core */, + B57248ACB19D6554EBCAA15E /* DeviceLog */, + 9EFA1E4C7EB7203F0D39B585 /* Export */, + 5BA8CACAF5028913393A78CB /* Store */, + AB59F766D97F5B125B0BEFDD /* Watch */, ); path = Logger; sourceTree = ""; }; - 38E98A1D25F52C9300C0CED0 /* IssueReporter */ = { - isa = PBXGroup; - children = ( - 38E98A1E25F52C9300C0CED0 /* IssueReporter.swift */, - 38E98A2025F52C9300C0CED0 /* CollectionIssueReporter.swift */, - 38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */, - ); - path = IssueReporter; - sourceTree = ""; - }; 38E98A3225F5300800C0CED0 /* Config */ = { isa = PBXGroup; children = ( @@ -3704,7 +3750,6 @@ 98546920BB7247B0AC73449A /* ContainerResolutionTests.swift */, 7AF74BBFE9F7B8CA9187C4DB /* RemoteMealMutationPayloadTests.swift */, 3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */, - 49F4A34B2E044D52008A93CC /* ShareLogsTests */, B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */, B3919BBB515547118D684CF2 /* AlgorithmSuggestedBolusTests.swift */, B3919BBB515547118D684CE2 /* DosingModeClampTests.swift */, @@ -3718,6 +3763,7 @@ BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */, CD1235A000000000000000F1 /* TidepoolUploadSerializerTests.swift */, CE1425A000000000000000A1 /* CarbsEntryLoopKitTests.swift */, + A5D6B6D83F32CE8BBDDB4E5B /* Logging */, ); path = TrioTests; sourceTree = ""; @@ -4021,14 +4067,6 @@ path = B30Settings; sourceTree = ""; }; - 49F4A34B2E044D52008A93CC /* ShareLogsTests */ = { - isa = PBXGroup; - children = ( - 49F4A34C2E044D66008A93CC /* ShareLogsTests.swift */, - ); - path = ShareLogsTests; - sourceTree = ""; - }; 4F4AE4D901E8BA872B207D7F /* View */ = { isa = PBXGroup; children = ( @@ -4938,6 +4976,7 @@ DD1745182C543B5700211FAC /* NotificationsView.swift */, DD17451C2C543C5F00211FAC /* ServicesView.swift */, DD1745362C55B74200211FAC /* AlgorithmSettings.swift */, + 7373EC0CC3C059178CB967B4 /* ReportProblemView.swift */, ); path = Subviews; sourceTree = ""; @@ -5557,6 +5596,84 @@ path = QuickPickTreatments; sourceTree = ""; }; + 79A6FC1583EE7761756C86BD /* Contracts */ = { + isa = PBXGroup; + children = ( + 0F0205BEC3117B5D00022C30 /* LogRecord.swift */, + 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */, + ABB5F206A024F911C4E145D5 /* LogSink.swift */, + ); + path = Contracts; + sourceTree = ""; + }; + 9F14EBDAAE691A7D9C7913DE /* Core */ = { + isa = PBXGroup; + children = ( + E9C171A8FC35B730F1FFF25F /* InMemoryLogSink.swift */, + 24AF0AA0328EA5E4016BD4D6 /* LaunchID.swift */, + 69EF64E9534E4822199EA279 /* TrioLogger.swift */, + ); + path = Core; + sourceTree = ""; + }; + B57248ACB19D6554EBCAA15E /* DeviceLog */ = { + isa = PBXGroup; + children = ( + 260A8D718D2B6656B925FD08 /* DeviceLogStore.swift */, + ); + path = DeviceLog; + sourceTree = ""; + }; + 9EFA1E4C7EB7203F0D39B585 /* Export */ = { + isa = PBXGroup; + children = ( + 7DB8B37A782E6D75420DB2FC /* LogExport.swift */, + 7FBFFB8EF42D847AFFDA6C05 /* LogFileHeader.swift */, + 366F24AC292DBC7BC55CF196 /* LogFileWriter.swift */, + 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */, + 3B0396B90CF23D28D2FDCD78 /* Redactor.swift */, + 0CCB6F0602E069F5B4F83EDC /* SensitiveValues.swift */, + ); + path = Export; + sourceTree = ""; + }; + 5BA8CACAF5028913393A78CB /* Store */ = { + isa = PBXGroup; + children = ( + 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */, + 39062D177D83C69CAFC9B2AC /* AppLogStore.swift */, + ); + path = Store; + sourceTree = ""; + }; + AB59F766D97F5B125B0BEFDD /* Watch */ = { + isa = PBXGroup; + children = ( + F5D79872B74ADDBDD11682D0 /* WatchLogEntry.swift */, + 921084A4E9558519903842AE /* WatchLogIngest.swift */, + 75C18518C0B49FB16C05F355 /* WatchLogRing.swift */, + ); + path = Watch; + sourceTree = ""; + }; + A5D6B6D83F32CE8BBDDB4E5B /* Logging */ = { + isa = PBXGroup; + children = ( + 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */, + 161D44BBD9BB82CE0AF6B4EC /* DeviceLogStoreTests.swift */, + CAF80D3953C10DCDF5D80C27 /* LogExportTests.swift */, + 7E3B19D06A4C85F2E91D3B57 /* LoggingLongRunStressTests.swift */, + 258BD00B3EAADF1A62EFC884 /* LoggingRobustnessStressTests.swift */, + 510C461CD60549BDF7E57BE9 /* LoggingStressFixtures.swift */, + 5A4F4087B27F7D5819A37272 /* LoggingVolumeStressTests.swift */, + 704720C410E0C4F7D10B184B /* RedactorTests.swift */, + C7BC1040D2F0BD84E0718784 /* TrioLoggerTests.swift */, + 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */, + FEA5E2B256AC7F8447B1F1C3 /* WatchLogRingTests.swift */, + ); + path = Logging; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -6223,7 +6340,6 @@ 65070A332BFDCB83006F213F /* TidepoolStartView.swift in Sources */, 49CCD2142CAE5E99002A9476 /* BulletList.swift in Sources */, 190EBCC629FF138000BA767D /* UserInterfaceSettingsProvider.swift in Sources */, - 38E98A2725F52C9300C0CED0 /* CollectionIssueReporter.swift in Sources */, E00EEC0427368630002FF094 /* SecurityAssembly.swift in Sources */, 3811DEE825CA063400A708ED /* Injected.swift in Sources */, DD1745152C54388A00211FAC /* TherapySettingsView.swift in Sources */, @@ -6328,8 +6444,6 @@ DB0BB002000000000000000C /* ReleaseNotes.swift in Sources */, DB0BB002000000000000000E /* ReleaseNotesService.swift in Sources */, DB0BB0020000000000000010 /* ReleaseNotesSheetView.swift in Sources */, - 38E98A2925F52C9300C0CED0 /* Error+Extensions.swift in Sources */, - 38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */, 3811DE6125C9D4D500A708ED /* ViewModifiers.swift in Sources */, 3811DEAC25C9D88300A708ED /* NightscoutManager.swift in Sources */, BD793CB22CE8033500D669AC /* TempTargetRunStored.swift in Sources */, @@ -6451,7 +6565,6 @@ BDFD165A2AE40438007F0DDA /* TreatmentsRootView.swift in Sources */, BD54A9732D281ABC00F9C1EE /* TempTargetPresetWatch.swift in Sources */, DD3078682D42F5CE00DE0490 /* WatchGlucoseObject.swift in Sources */, - 38E98A2525F52C9300C0CED0 /* IssueReporter.swift in Sources */, 3BCE75B52D4B391F009E9453 /* Decimal+rounding.swift in Sources */, DD1745522C55CA5D00211FAC /* UnitsLimitsSettingsStateModel.swift in Sources */, DD2CC85C2D25DA1000445446 /* GlucoseTargetsView.swift in Sources */, @@ -6628,7 +6741,6 @@ 38E4453D274E411700EC9A94 /* Disk+Errors.swift in Sources */, C29835B02E2AA3F30068C5BB /* NightscoutUploadStepView.swift in Sources */, 58D08B3A2C8DFECD00AA37D3 /* TempTargets.swift in Sources */, - 38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */, 3B5CD1ED2D4912A600CE213C /* JSONBridge.swift in Sources */, CE7CA3542A064973004BE681 /* TempPresetsIntentRequest.swift in Sources */, 58A3D5442C96DE11003F90FC /* TempTargetStored+Helper.swift in Sources */, @@ -6856,6 +6968,26 @@ CE51D92F6BA6C58B03D3C0D9 /* ProfileSchedulerRootView.swift in Sources */, 324C9DA0BCAF56E5E21A283D /* AddScheduleView.swift in Sources */, 5B99A2A5E427538F74CAC79A /* ProfileScheduleFirer.swift in Sources */, + DE6F63D93662C12AD18A3802 /* LogRecord.swift in Sources */, + 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */, + 91FC2FB975FB723C55744FB1 /* LogSink.swift in Sources */, + 931AD2C542093B9D1A342065 /* InMemoryLogSink.swift in Sources */, + 27DE1FE3427339F87A4D1892 /* LaunchID.swift in Sources */, + E0F5C5534791F6BB9A237891 /* TrioLogger.swift in Sources */, + 10C13662926D99DC9EDB0990 /* DeviceLogStore.swift in Sources */, + 96310848E7A3468607C340E9 /* LogExport.swift in Sources */, + B0D5791570373A23CD562E97 /* LogFileHeader.swift in Sources */, + BF0FA8AAE98B4080B961F69F /* LogFileWriter.swift in Sources */, + 4ABD323927D718C0177C26B5 /* OSLogCapture.swift in Sources */, + A8354E8A31A4F482D0B649F1 /* Redactor.swift in Sources */, + 536B4DB89FCFE9188F4DFE5D /* SensitiveValues.swift in Sources */, + 4E3B6212E43A3E90DD735B30 /* AppLog.xcdatamodeld in Sources */, + F0D6F2237EB1FB4862D0D4D7 /* AppLogStore.swift in Sources */, + E8C6B60FFBA9D89DE3BCCA99 /* WatchLogEntry.swift in Sources */, + 6DB56C9F3F74834C536E9199 /* WatchLogIngest.swift in Sources */, + 92DE66B4776804F0DD8A2925 /* WatchLogRing.swift in Sources */, + 659C83549641F3FCC56F30AB /* ReportProblemView.swift in Sources */, + F6854A961CB1CF1105757936 /* LogShareRow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6915,7 +7047,6 @@ DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */, 6E3CFEA6BBC44435AAA5A5FE /* InMemoryFileStorage.swift in Sources */, BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */, - 49F4A34D2E044D7E008A93CC /* ShareLogsTests.swift in Sources */, 3BAAE60C2DE7766C0049589B /* DynamicISFEnableTests.swift in Sources */, 3BAC929B2E55FF5300B853DA /* DetermineBasalEnableSmbTests.swift in Sources */, CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */, @@ -6986,6 +7117,17 @@ BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */, CD1235B000000000000000F2 /* TidepoolUploadSerializerTests.swift in Sources */, CE1425B000000000000000A2 /* CarbsEntryLoopKitTests.swift in Sources */, + 91AE5F35FC765DFE4C1A2589 /* AppLogStoreTests.swift in Sources */, + 2D5DFD6C90037128EA3DF5A9 /* DeviceLogStoreTests.swift in Sources */, + 38B4E1780D264CEBEB5DBCB3 /* LogExportTests.swift in Sources */, + 4C0A9E5D31B7F26E8D1A0C42 /* LoggingLongRunStressTests.swift in Sources */, + F07C663CC423264E609D706C /* LoggingRobustnessStressTests.swift in Sources */, + 3F30764ED6567CB6B1DF68C1 /* LoggingStressFixtures.swift in Sources */, + EAE1104EF2313F9A0041C746 /* LoggingVolumeStressTests.swift in Sources */, + 2C9535979A6E92EE0EC83255 /* RedactorTests.swift in Sources */, + BD14CA4C88D904242178280A /* TrioLoggerTests.swift in Sources */, + FC83BDFB7C330BFD6A4DECFC /* WatchLogIngestTests.swift in Sources */, + 03DE042795C2AFA00C2CCE9E /* WatchLogRingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -7039,6 +7181,8 @@ 49C27EC92E1BBBCA00FC65DF /* TaiStyle.swift in Sources */, DD09D5C92D29F3D0000D82C9 /* AcknowledgementPendingView.swift in Sources */, DDFF204A2DB29EF500AB8A96 /* WatchLogger.swift in Sources */, + FF42480CF46A31D00C817D8C /* WatchLogEntry.swift in Sources */, + 8B735B34F861E3AE08F5C9BE /* WatchLogRing.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -7846,6 +7990,16 @@ sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; + 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 6AB077E2E3EC75CCBDEFF8B5 /* AppLog.xcdatamodel */, + ); + currentVersion = 6AB077E2E3EC75CCBDEFF8B5 /* AppLog.xcdatamodel */; + path = AppLog.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; /* End XCVersionGroup section */ }; rootObject = 388E595025AD948C0019842D /* Project object */; diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index df0ded3e84..544778ed2e 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -223,10 +223,7 @@ final class BaseAPSManager: APSManager, Injectable { do { try await openAPS.createProfiles(for: self.settingsManager.settings.dosingMode) } catch { - debug( - .apsManager, - "\(DebuggingIdentifiers.failed) Error creating profiles: \(error)" - ) + warning(.algorithm, "Creating profiles after unit conversion failed", error: error) } } } @@ -315,7 +312,7 @@ final class BaseAPSManager: APSManager, Injectable { // activation sheet, closing the sheet (reported by tester during // O5 pairing). guard self.pumpManager != nil else { - debug(.apsManager, "No pump manager — skipping loop attempt") + debug(.loop, "Loop skipped: no pump configured") return } @@ -326,7 +323,7 @@ final class BaseAPSManager: APSManager, Injectable { lastLoopDate: lastLoopDate, lastLoopStartDate: lastLoopStartDate ) else { - debug(.apsManager, "Loop skipped (already running or too soon)") + debug(.loop, "Loop skipped: already running or too soon") return } @@ -343,6 +340,7 @@ final class BaseAPSManager: APSManager, Injectable { } isLooping.send(true) + info(.loop, "Loop started, mode \(settings.dosingMode.rawValue)\(userInitiated ? ", requested by user" : "")") let loopStartDate = Date() lastLoopStartDate = loopStartDate @@ -367,7 +365,6 @@ final class BaseAPSManager: APSManager, Injectable { loopStatRecord.duration = roundDouble((endDate - loopStatRecord.start).timeInterval / 60, 2) loopStatRecord.loopStatus = error.localizedDescription await finalizeLoop(error: error, loopStatRecord: loopStatRecord) - debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to complete Loop: \(error)") } // End the background task @@ -413,7 +410,7 @@ final class BaseAPSManager: APSManager, Injectable { return nil } } catch { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch the last loop with error: \(error)") + warning(.storage, "Fetching the last loop for its interval failed", error: error) return nil } } @@ -425,8 +422,8 @@ final class BaseAPSManager: APSManager, Injectable { let convertedVolume = (Decimal(volume) / settings.insulinConcentration).precisionRounded() debug( - .apsManager, - "Concentration: Bolus \(roundedVolume) U adjusted to U\(Int(settings.insulinConcentration * 100))-Volume of \(convertedVolume)" + .pump, + "Bolus \(roundedVolume) U adjusted to U\(Int(settings.insulinConcentration * 100)) volume \(convertedVolume) U" ) return convertedVolume.nearestDouble @@ -439,8 +436,8 @@ final class BaseAPSManager: APSManager, Injectable { let convertedRate = (Decimal(rate) / settings.insulinConcentration).precisionRounded() debug( - .apsManager, - "Concentration: Rate \(roundedRate) IU/hr adjusted to U\(Int(settings.insulinConcentration * 100))-Rate of \(convertedRate)" + .pump, + "Basal rate \(roundedRate) U/h adjusted to U\(Int(settings.insulinConcentration * 100)) rate \(convertedRate) U/h" ) return convertedRate.nearestDouble @@ -456,8 +453,8 @@ final class BaseAPSManager: APSManager, Injectable { ) debug( - .apsManager, - "Concentration: Pumped rate volume \(rate.precisionRounded()) U\(Int(settings.insulinConcentration * 100))/hr, adjusted to U100 rate of \(u100Rate) IU/hr" + .pump, + "Pumped U\(Int(settings.insulinConcentration * 100)) rate \(rate.precisionRounded()) U/h adjusted to U100 rate \(u100Rate) U/h" ) return u100Rate @@ -469,10 +466,10 @@ final class BaseAPSManager: APSManager, Injectable { isLooping.send(false) if let error = error { - warning(.apsManager, "Loop failed with error: \(error)") + warning(.loop, "Loop failed", error: error) processError(error) } else { - debug(.apsManager, "Loop succeeded") + info(.loop, "Loop finished in \(String(format: "%.1f", Date().timeIntervalSince(loopStatRecord.start))) s") lastLoopDate = Date() lastError.send(nil) transientCategoryFirstSeen.removeAll() @@ -539,8 +536,6 @@ final class BaseAPSManager: APSManager, Injectable { } func determineBasal() async throws { - debug(.apsManager, "Start determine basal") - try await calculateAndStoreTDD() var invalidGlucoseError: String? @@ -560,12 +555,12 @@ final class BaseAPSManager: APSManager, Injectable { fetchLimit: 6 ) } catch { - debug(.apsManager, "Failed to fetch glucose for validation: \(error)") + warning(.loop, "Fetching glucose for validation failed", error: error) return false } guard glucose.count > 2 else { - debug(.apsManager, "Not enough glucose data") + debug(.loop, "Glucose check failed: \(glucose.count) readings in the last hour, need 3") invalidGlucoseError = String( localized: "Not enough glucose data. You need at least three glucose readings in the last six hours to run the algorithm." @@ -575,12 +570,13 @@ final class BaseAPSManager: APSManager, Injectable { let dateOfLastGlucose = glucose.first?.date guard dateOfLastGlucose ?? Date() >= Date().addingTimeInterval(-12.minutes.timeInterval) else { - debug(.apsManager, "Glucose data is stale") + debug(.loop, "Glucose check failed: latest reading older than 12 min") invalidGlucoseError = String(localized: "Glucose data is stale. The most recent glucose reading is from more than 12 minutes ago.") return false } + info(.loop, "Glucose ready: BG \(glucose.first?.glucose ?? 0) mg/dL, \(glucose.count) recent readings") return true } @@ -607,6 +603,10 @@ final class BaseAPSManager: APSManager, Injectable { } if let determination = determination { + info( + .loop, + "Determination: eventual BG \(determination.eventualBG.map { "\($0) mg/dL" } ?? "none"), temp basal \(determination.rate.map { "\($0) U/h for \(determination.duration ?? 0) min" } ?? "none"), SMB \(determination.units ?? 0) U" + ) // Capture weak self in closure await MainActor.run { [weak self] in guard let self else { return } @@ -633,7 +633,7 @@ final class BaseAPSManager: APSManager, Injectable { func determineBasalSync() async throws { await loopGuard.waitForLoop() guard await loopGuard.tryStartStandaloneDetermination() else { - debug(.apsManager, "Standalone determination skipped: one is already running") + debug(.loop, "Standalone determination skipped: one is already running") return } do { @@ -665,9 +665,7 @@ final class BaseAPSManager: APSManager, Injectable { simulation: true ) } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error occurred in simulateDetermineBasal: \(error)" - ) + warning(.algorithm, "Simulated determination failed", error: error) return nil } } @@ -697,6 +695,7 @@ final class BaseAPSManager: APSManager, Injectable { } if let error = verifyStatus() { + warning(.bolus, "Bolus of \(amount) U not enacted", error: error) processError(error) // Capture broadcaster and queue before async context let broadcaster = self.broadcaster @@ -718,31 +717,28 @@ final class BaseAPSManager: APSManager, Injectable { units: adjustPumpedVolumeToConcentration(amount).deliverable ) - debug(.apsManager, "Enact bolus \(roundedAmount), manual \(!isSMB)") + debug(.bolus, "Enacting bolus \(roundedAmount) U, \(isSMB ? "automatic" : "manual")") do { try await pump.enactBolus(units: roundedAmount, automatic: isSMB) - debug(.apsManager, "Bolus succeeded") + info(.bolus, "Bolus enacted: \(roundedAmount) U, \(isSMB ? "automatic" : "manual")") bolusProgress.send(0) callback?(true, String(localized: "Bolus enacted successfully.", comment: "Success message for enacting a bolus")) if !isSMB { do { try await determineBasalSync() } catch { - warning( - .apsManager, - "determineBasalSync after manual bolus failed: \(error.localizedDescription)" - ) + warning(.loop, "Determination after manual bolus failed", error: error) } } - } catch { - warning(.apsManager, "Bolus failed with error: \(error)") - lastError.send(APSError.pumpError(error)) + } catch let bolusError { + error(.bolus, "Bolus of \(roundedAmount) U failed", error: bolusError) + lastError.send(APSError.pumpError(bolusError)) issueAlertForCategory( .bolusFailed, title: String(localized: "Bolus failed"), body: String(localized: "Check pump history before repeating.") - + "\n\n\(error.localizedDescription)" + + "\n\n\(bolusError.localizedDescription)" ) if !isSMB { let broadcaster = self.broadcaster @@ -754,7 +750,7 @@ final class BaseAPSManager: APSManager, Injectable { } callback?( false, - String(localized: "Error! Bolus failed with error: \(error.localizedDescription)") + String(localized: "Error! Bolus failed with error: \(bolusError.localizedDescription)") ) } } @@ -778,36 +774,36 @@ final class BaseAPSManager: APSManager, Injectable { let adjustedRate = adjustPumpedRateToConcentration(safeRate).deliverable let roundedRate = pump.roundToSupportedBasalRate(unitsPerHour: adjustedRate) - debug(.apsManager, "Enact manual temp basal \(roundedRate) - \(duration)") + info(.pump, "Manual temp basal \(roundedRate) U/h for \(Int(duration / 60)) min requested") do { try await pump.enactTempBasal(unitsPerHour: roundedRate, for: duration) - debug(.apsManager, "Manual temp basal succeeded") + info(.pump, "Manual temp basal enacted") } catch { - debug(.apsManager, "Manual temp basal failed with error: \(error)") + warning(.pump, "Manual temp basal failed", error: error) processError(APSError.pumpError(error)) } } func cancelBolus(_ callback: ((Bool, String) -> Void)?) async { guard let pump = pumpManager, pump.status.pumpStatus.bolusing else { return } - debug(.apsManager, "Cancel bolus") + debug(.bolus, "Cancelling bolus") do { _ = try await pump.cancelBolus() - debug(.apsManager, "Bolus cancelled") + info(.bolus, "Bolus cancelled") callback?(true, String(localized: "Bolus cancelled successfully.", comment: "Success message for canceling a bolus")) - } catch { - debug(.apsManager, "Bolus cancellation failed with error: \(error)") - lastError.send(APSError.pumpError(error)) + } catch let cancelError { + error(.bolus, "Bolus cancellation failed", error: cancelError) + lastError.send(APSError.pumpError(cancelError)) issueAlertForCategory( .bolusFailed, title: String(localized: "Bolus cancellation failed"), - body: String(localized: "Try again.") + "\n\n\(error.localizedDescription)" + body: String(localized: "Try again.") + "\n\n\(cancelError.localizedDescription)" ) callback?( false, String( - localized: "Error! Bolus cancellation failed with error: \(error.localizedDescription)", + localized: "Error! Bolus cancellation failed with error: \(cancelError.localizedDescription)", comment: "Error message for canceling a bolus" ) ) @@ -841,8 +837,8 @@ final class BaseAPSManager: APSManager, Injectable { let duration = max(0, Int(tempBasal.duration) - delta) let rate = tempBasal.rate as? Decimal ?? 0 debug( - .apsManager, - "Concentration: last fetched TB rate \(rate) IU/hr started \(Formatter.timeFormatter.string(from: eventTimestamp)) for another \(duration) minutes from PumpHistory" + .pump, + "Last temp basal from pump history \(rate) U/h, started \(Formatter.timeFormatter.string(from: eventTimestamp)), \(duration) min left" ) return TempBasal(duration: duration, rate: rate, temp: .absolute, timestamp: date) } @@ -855,7 +851,7 @@ final class BaseAPSManager: APSManager, Injectable { case let .tempBasal(dose): let rate = adjustPumpedRateToU100(Decimal(dose.unitsPerHour)) let durationMin = max(0, Int((dose.endDate.timeIntervalSince1970 - date.timeIntervalSince1970) / 60)) - debug(.apsManager, "Concentration: last fetched TB rate \(rate) IU/hr for \(durationMin)m from PumpManager") + debug(.pump, "Last temp basal from pump manager \(rate) U/h, \(durationMin) min left") return TempBasal(duration: durationMin, rate: rate, temp: .absolute, timestamp: date) default: return fetchedTempBasal @@ -874,7 +870,7 @@ final class BaseAPSManager: APSManager, Injectable { } if pump.status.pumpStatus.suspended { - debug(.apsManager, "Skipping enactDetermination because pump is suspended") + info(.loop, "Determination not enacted: pump suspended") return } @@ -892,6 +888,7 @@ final class BaseAPSManager: APSManager, Injectable { unitsPerHour: adjustPumpedRateToConcentration(requestedRate).deliverable ) : requestedRate try await performBasal(pump: pump, rate: NSDecimalNumber(value: adjustedRate), duration: duration) + info(.loop, "Temp basal enacted: \(rate) U/h for \(Int(duration / 60)) min") } // only perform a bolus if smbToDeliver is > 0 @@ -902,6 +899,7 @@ final class BaseAPSManager: APSManager, Injectable { ) let finalSMBAmount = NSDecimalNumber(value: adjustedSMBAmount) // Convert to NSDecimalNumber try await performBolus(pump: pump, smbToDeliver: finalSMBAmount) + info(.loop, "SMB enacted: \(smb) U") } } @@ -930,7 +928,7 @@ final class BaseAPSManager: APSManager, Injectable { // again here: the Decimal -> Double hop can land a hair low and cost a whole increment let unitsPerHour = rate.decimalValue.nearestDouble if pump.roundToSupportedBasalRate(unitsPerHour: unitsPerHour) != unitsPerHour { - debug(.apsManager, "Temp basal \(unitsPerHour) U/hr is not on the pump's rate table") + debug(.pump, "Temp basal \(unitsPerHour) U/h is not on the pump's rate table") } try await pump.enactTempBasal(unitsPerHour: unitsPerHour, for: duration) } @@ -945,7 +943,7 @@ final class BaseAPSManager: APSManager, Injectable { guard let determinationID = try await determinationStorage .fetchLastDeterminationObjectID(predicate: NSPredicate.predicateFor30MinAgoForDetermination).first else { - debug(.apsManager, "No determination found to report enacted status") + debug(.loop, "No recent determination to mark as enacted") return } @@ -955,7 +953,7 @@ final class BaseAPSManager: APSManager, Injectable { guard let determinationUpdated = try context .existingObject(with: determinationID) as? OrefDetermination else { - debug(.apsManager, "Could not find determination object in context") + debug(.loop, "Determination to mark as enacted no longer exists") return } @@ -965,13 +963,10 @@ final class BaseAPSManager: APSManager, Injectable { guard context.hasChanges else { return } try context.save() - debug(.apsManager, "Determination enacted. Enacted: \(wasEnacted)") + debug(.loop, "Determination marked as \(wasEnacted ? "enacted" : "not enacted")") } } catch { - debug( - .apsManager, - "\(DebuggingIdentifiers.failed) Error reporting enacted status: \(error)" - ) + warning(.storage, "Saving the enacted status failed", error: error) } } @@ -1164,7 +1159,7 @@ final class BaseAPSManager: APSManager, Injectable { do { return try context.fetch(requestStats).first?.lastrun } catch { - print(error.localizedDescription) + warning(.storage, "Fetching the last statistics upload date failed", error: error) return .distantPast } } @@ -1203,9 +1198,7 @@ final class BaseAPSManager: APSManager, Injectable { max_duration: oneDayLoops.max_duration ) } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to get Loop statistics for Statistics Upload" - ) + warning(.storage, "Fetching loop statistics failed", error: error) return LoopCycles( loops: 0, errors: 0, @@ -1375,10 +1368,7 @@ final class BaseAPSManager: APSManager, Injectable { return (oneDayGlucose, eA1cDisplayUnit, numberOfDays, TimeInRange, avg, hbs, variance) } } catch { - debug( - .apsManager, - "\(DebuggingIdentifiers.failed) Error fetching glucose for stats: \(error)" - ) + warning(.storage, "Fetching glucose for statistics failed", error: error) return nil } } @@ -1398,7 +1388,7 @@ final class BaseAPSManager: APSManager, Injectable { guard context.hasChanges else { return } try context.save() } catch { - print(error.localizedDescription) + warning(.storage, "Saving loop statistics failed", error: error) } } } @@ -1419,7 +1409,6 @@ final class BaseAPSManager: APSManager, Injectable { } private func processError(_ error: Error) { - warning(.apsManager, "\(error)") lastError.send(error) surfaceErrorIfNeeded(error) } @@ -1450,8 +1439,8 @@ final class BaseAPSManager: APSManager, Injectable { transientCategoryFirstSeen[key] = firstSeen transientCategoryCount[key] = count debug( - .apsManager, - "APSManager suppressed transient \(category) (count=\(count)/\(Self.transientCountThreshold), dwell=\(Int(dwellElapsed))s/\(Int(Self.transientDwellThreshold))s)" + .alerts, + "Alert for transient \(category) held back: \(count) of \(Self.transientCountThreshold) occurrences, \(Int(dwellElapsed)) of \(Int(Self.transientDwellThreshold)) s" ) } } @@ -1537,10 +1526,9 @@ private extension PumpManager { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in self.enactTempBasal(unitsPerHour: unitsPerHour, for: duration) { error in if let error = error { - debug(.apsManager, "Temp basal failed: \(unitsPerHour) for: \(duration)") + debug(.pump, "Temp basal command failed: \(unitsPerHour) U/h for \(Int(duration / 60)) min") continuation.resume(throwing: error) } else { - debug(.apsManager, "Temp basal succeeded: \(unitsPerHour) for: \(duration)") continuation.resume(returning: ()) } } @@ -1553,10 +1541,9 @@ private extension PumpManager { self.enactBolus(units: units, activationType: automaticValue) { error in if let error = error { - debug(.apsManager, "Bolus failed: \(units)") + debug(.pump, "Bolus command failed: \(units) U") continuation.resume(throwing: error) } else { - debug(.apsManager, "Bolus succeeded: \(units)") continuation.resume(returning: ()) } } @@ -1568,10 +1555,8 @@ private extension PumpManager { self.cancelBolus { result in switch result { case let .success(dose): - debug(.apsManager, "Cancel Bolus succeeded") continuation.resume(returning: dose) case let .failure(error): - debug(.apsManager, "Cancel Bolus failed") continuation.resume(throwing: APSError.pumpError(error)) } } @@ -1625,9 +1610,9 @@ extension BaseAPSManager: SettingsObserver { do { try await pump.enactTempBasal(unitsPerHour: 0, for: 0) - debug(.apsManager, "Cancelled temp basal for basal testing") + info(.pump, "Temp basal cancelled for basal testing") } catch { - debug(.apsManager, "Failed to cancel temp basal for basal testing: \(error)") + warning(.pump, "Cancelling temp basal for basal testing failed", error: error) processError(APSError.pumpError(error)) } } @@ -1666,7 +1651,7 @@ extension BaseAPSManager: PumpManagerStatusObserver { guard context.hasChanges else { return } try context.save() } catch { - debug(.apsManager, "Failed to fetch or save battery: \(error)") + warning(.storage, "Saving pump battery status failed", error: error) } } // TODO: - remove this after ensuring that NS still gets the same infos from Core Data diff --git a/Trio/Sources/APS/CGM/AppGroupSource.swift b/Trio/Sources/APS/CGM/AppGroupSource.swift index 5bc555c440..1a03140805 100644 --- a/Trio/Sources/APS/CGM/AppGroupSource.swift +++ b/Trio/Sources/APS/CGM/AppGroupSource.swift @@ -53,7 +53,7 @@ struct AppGroupSource: GlucoseSource { } HeartBeatManager.shared.checkCGMBluetoothTransmitter(sharedUserDefaults: sharedDefaults, heartbeat: heartbeat) - debug(.deviceManager, "APPGROUP : START FETCH LAST BG ") + debug(.cgm, "Reading glucose shared through the app group") let decoded = try? JSONSerialization.jsonObject(with: sharedData, options: []) // Two shapes accepted: diff --git a/Trio/Sources/APS/CGM/BluetoothTransmitter.swift b/Trio/Sources/APS/CGM/BluetoothTransmitter.swift index 99a2dc63c4..805b1f89a9 100644 --- a/Trio/Sources/APS/CGM/BluetoothTransmitter.swift +++ b/Trio/Sources/APS/CGM/BluetoothTransmitter.swift @@ -51,7 +51,7 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele super.init() - debug(.deviceManager, "in initialize, creating centralManager for peripheral with address \(deviceAddress)") + debug(.cgm, "Creating Bluetooth central for heartbeat transmitter") centralManager = CBCentralManager( delegate: self, @@ -69,8 +69,6 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele // MARK: - De-initialization deinit { - debug(.deviceManager, "deinit called") - // disconnect the device disconnect() } @@ -87,12 +85,7 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele /// disconnect the device func disconnect() { if let peripheral = peripheral { - var name = "unknown" - if let peripheralName = peripheral.name { - name = peripheralName - } - - debug(.deviceManager, "disconnecting from peripheral with name \(name)") + info(.cgm, "Disconnecting heartbeat transmitter") centralManager!.cancelPeripheralConnection(peripheral) } @@ -100,7 +93,7 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele /// stops scanning func stopScanning() { - debug(.deviceManager, "in stopScanning") + debug(.cgm, "Stopped scanning for heartbeat transmitter") centralManager!.stopScan() } @@ -108,17 +101,11 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele /// calls setNotifyValue for characteristic with value enabled func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic) { if let peripheral = peripheral { - debug( - .deviceManager, - "setNotifyValue, for peripheral with name \(peripheral.name ?? "'unknown'"), setting notify for characteristic \(characteristic.uuid.uuidString), to \(enabled.description)" - ) + debug(.cgm, "Heartbeat transmitter notify \(enabled) for characteristic \(characteristic.uuid.uuidString)") peripheral.setNotifyValue(enabled, for: characteristic) } else { - debug( - .deviceManager, - "setNotifyValue, for peripheral with name \(peripheral?.name ?? "'unknown'"), failed to set notify for characteristic \(characteristic.uuid.uuidString), to \(enabled.description)" - ) + debug(.cgm, "Heartbeat transmitter notify not set: no peripheral") } } @@ -127,12 +114,12 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele /// start bluetooth scanning for device fileprivate func startScanning() { if centralManager!.state == .poweredOn { - debug(.deviceManager, "in startScanning") + debug(.cgm, "Scanning for heartbeat transmitter") centralManager!.scanForPeripherals(withServices: nil, options: nil) } else { - debug(.deviceManager, "in startScanning. Not started, state is not poweredOn") + debug(.cgm, "Heartbeat transmitter scan not started: Bluetooth is not powered on") } } @@ -145,12 +132,12 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele peripheral.delegate = self if peripheral.state == .disconnected { - debug(.deviceManager, " trying to connect") + debug(.cgm, "Connecting to heartbeat transmitter") centralManager!.connect(peripheral, options: nil) } else { - debug(.deviceManager, " calling centralManager(newCentralManager, didConnect: peripheral") + debug(.cgm, "Heartbeat transmitter already connected") centralManager(centralManager!, didConnect: peripheral) } @@ -160,18 +147,16 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele /// /// the result of the attempt to try to find such device, is returned fileprivate func retrievePeripherals(_ central: CBCentralManager) -> Bool { - debug(.deviceManager, "in retrievePeripherals, deviceaddress is \(deviceAddress)") + debug(.cgm, "Looking up known heartbeat transmitter") if let uuid = UUID(uuidString: deviceAddress) { - debug(.deviceManager, " uuid is not nil") - let peripheralArr = central.retrievePeripherals(withIdentifiers: [uuid]) if !peripheralArr.isEmpty { peripheral = peripheralArr[0] if let peripheral = peripheral { - debug(.deviceManager, " trying to connect") + debug(.cgm, "Known heartbeat transmitter found, connecting") peripheral.delegate = self @@ -180,14 +165,14 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele return true } else { - debug(.deviceManager, " peripheral is nil") + debug(.cgm, "Known heartbeat transmitter missing") } } else { - debug(.deviceManager, " uuid is not nil, but central.retrievePeripherals returns 0 peripherals") + debug(.cgm, "Known heartbeat transmitter not available, will scan") } } else { - debug(.deviceManager, " uuid is nil") + debug(.cgm, "No stored heartbeat transmitter ID, will scan") } return false @@ -201,50 +186,28 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele advertisementData _: [String: Any], rssi _: NSNumber ) { - // devicename needed unwrapped for logging - var deviceName = "unknown" - if let temp = peripheral.name { - deviceName = temp - } - - debug(.deviceManager, "Did discover peripheral with name: \(deviceName)") - // check if stored address not nil, in which case we already connected before and we expect a full match with the already known device name if peripheral.identifier.uuidString == deviceAddress { - debug(.deviceManager, " stored address matches peripheral address, will try to connect") + debug(.cgm, "Discovered heartbeat transmitter, connecting") stopScanAndconnect(to: peripheral) - - } else { - debug(.deviceManager, " stored address does not match peripheral address, ignoring this device") } } func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) { - debug(.deviceManager, "connected to peripheral with name \(peripheral.name ?? "'unknown'")") + info(.cgm, "Heartbeat transmitter connected") peripheral.discoverServices(servicesCBUUIDs) } func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - if let error = error { - debug( - .deviceManager, - "failed to connect, for peripheral with name \(peripheral.name ?? "'unknown'"), with error: \(error), will try again" - ) - - } else { - debug(.deviceManager, "failed to connect, for peripheral with name \(peripheral.name ?? "'unknown'"), will try again") - } + warning(.cgm, "Heartbeat transmitter connection failed, retrying", error: error) centralManager!.connect(peripheral, options: nil) } func centralManagerDidUpdateState(_ central: CBCentralManager) { - debug( - .deviceManager, - "in centralManagerDidUpdateState, for peripheral with name \(peripheral?.name ?? "'unknown'"), new state is \(central.state.rawValue)" - ) + debug(.cgm, "Bluetooth state for heartbeat transmitter: \(central.state.rawValue)") /// in case status changed to powered on and if device address known then try to retrieveperipherals if central.state == .poweredOn { @@ -253,41 +216,38 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele } } - func centralManager(_: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - debug(.deviceManager, " didDisconnect peripheral with name \(peripheral.name ?? "'unknown'")") + func centralManager(_: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error: Error?) { + info(.cgm, "Heartbeat transmitter disconnected") // call heartbeat, useful for Dexcom transmitters, after a disconnect, then there's probably a new reading available heartbeat() if let error = error { - debug(.deviceManager, " error: \(error)") + warning(.cgm, "Heartbeat transmitter disconnect reported an error", error: error) } // if self.peripheral == nil, then a manual disconnect or something like that has occured, no need to reconnect // otherwise disconnect occurred because of other (like out of range), so let's try to reconnect - if let ownPeripheral = self.peripheral { - debug(.deviceManager, " Will try to reconnect") + if let ownPeripheral = peripheral { + debug(.cgm, "Reconnecting heartbeat transmitter") centralManager!.connect(ownPeripheral, options: nil) } else { - debug(.deviceManager, " peripheral is nil, will not try to reconnect") + debug(.cgm, "Not reconnecting heartbeat transmitter: disconnected on purpose") } } func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - debug(.deviceManager, "didDiscoverServices for peripheral with name \(peripheral.name ?? "'unknown'")") + debug(.cgm, "Heartbeat transmitter services discovered") if let error = error { - debug(.deviceManager, " didDiscoverServices error: \(error)") + warning(.cgm, "Heartbeat transmitter service discovery failed", error: error) } if let services = peripheral.services { for service in services { - debug( - .deviceManager, - " Call discovercharacteristics for service with uuid \(String(describing: service.uuid))" - ) + debug(.cgm, "Discovering characteristics for service \(service.uuid.uuidString)") peripheral.discoverCharacteristics(nil, for: service) } } else { @@ -296,21 +256,16 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - debug( - .deviceManager, - "didDiscoverCharacteristicsFor for peripheral with name \(peripheral.name ?? "'unknown'"), for service with uuid \(String(describing: service.uuid))" - ) + debug(.cgm, "Characteristics discovered for service \(service.uuid.uuidString)") if let error = error { - debug(.deviceManager, " didDiscoverCharacteristicsFor error: \(error)") + warning(.cgm, "Heartbeat transmitter characteristic discovery failed", error: error) } if let characteristics = service.characteristics { for characteristic in characteristics { - debug(.deviceManager, " characteristic: \(String(describing: characteristic.uuid))") - if characteristic.uuid == CBUUID(string: CBUUID_ReceiveCharacteristic) { - debug(.deviceManager, " found receiveCharacteristic") + debug(.cgm, "Heartbeat transmitter receive characteristic found") receiveCharacteristic = characteristic @@ -319,21 +274,22 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele } } else { - debug(.deviceManager, " Did discover characteristics, but no characteristics listed. There must be some error.") + warning(.cgm, "Heartbeat transmitter service has no characteristics") } } - func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + func peripheral(_: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { if let error = error { - debug( - .deviceManager, - "didUpdateNotificationStateFor for peripheral with name \(peripheral.name ?? "'unkonwn'"), characteristic \(String(describing: characteristic.uuid)), error = \(error)" + warning( + .cgm, + "Heartbeat transmitter notify update failed for characteristic \(characteristic.uuid.uuidString)", + error: error ) } } - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor _: CBCharacteristic, error _: Error?) { - debug(.deviceManager, "didUpdateValueFor for peripheral with name \(peripheral.name ?? "'unknown'")") + func peripheral(_: CBPeripheral, didUpdateValueFor _: CBCharacteristic, error _: Error?) { + debug(.cgm, "Heartbeat transmitter sent data") // call heartbeat heartbeat() @@ -347,7 +303,7 @@ class BluetoothTransmitter: NSObject, CBCentralManagerDelegate, CBPeripheralDele // even if it's an empty function // trace is called here because it allows us to see in the issue reports if there was a restart after app crash or removed from memory - in all other cases (force closed by user) this function is not called - debug(.deviceManager, "in willRestoreState") + debug(.cgm, "Bluetooth state restored for heartbeat transmitter") } } diff --git a/Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift b/Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift index 031e82d4b6..cdbd1f0456 100644 --- a/Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift +++ b/Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift @@ -113,7 +113,7 @@ final class GlucoseSimulatorSource: GlucoseSource { try context.save() } } catch { - print("GlucoseSimulatorSource: clearRecentSimulatorReadings failed: \(error)") + warning(.cgm, "Clearing recent simulator readings failed", error: error) } } } diff --git a/Trio/Sources/APS/CGM/PluginSource.swift b/Trio/Sources/APS/CGM/PluginSource.swift index cce336ca40..f929e1f46c 100644 --- a/Trio/Sources/APS/CGM/PluginSource.swift +++ b/Trio/Sources/APS/CGM/PluginSource.swift @@ -13,6 +13,7 @@ import SyaiKit final class PluginSource: GlucoseSource { private let processQueue = DispatchQueue(label: "CGMPluginSource.processQueue") private let glucoseStorage: GlucoseStorage! + private let deviceLogStore: DeviceLogStore var glucoseManager: FetchGlucoseManager? private var fetchCancellable: AnyCancellable? @@ -25,8 +26,9 @@ final class PluginSource: GlucoseSource { var cgmHasValidSensorSession: Bool = false - init(glucoseStorage: GlucoseStorage, glucoseManager: FetchGlucoseManager) { + init(glucoseStorage: GlucoseStorage, glucoseManager: FetchGlucoseManager, deviceLogStore: DeviceLogStore) { self.glucoseStorage = glucoseStorage + self.deviceLogStore = deviceLogStore self.glucoseManager = glucoseManager cgmManager = glucoseManager.cgmManager @@ -96,13 +98,19 @@ final class PluginSource: GlucoseSource { extension PluginSource: CGMManagerDelegate { func deviceManager( - _: LoopKit.DeviceManager, + _ manager: LoopKit.DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, - type _: LoopKit.DeviceLogEntryType, + type: LoopKit.DeviceLogEntryType, message: String, - completion _: ((Error?) -> Void)? + completion: ((Error?) -> Void)? ) { - debug(.deviceManager, "device Manager for \(String(describing: deviceIdentifier)) : \(message)") + deviceLogStore.log( + managerIdentifier: manager.pluginIdentifier, + deviceIdentifier: deviceIdentifier, + type: type, + message: message, + completion: completion + ) } /// Forwards CGMManager-issued alerts into the unified `TrioAlertManager` @@ -153,7 +161,7 @@ extension PluginSource: CGMManagerDelegate { dispatchPrecondition(condition: .onQueue(self.processQueue)) - debug(.deviceManager, " CGM Manager with identifier \(manager.pluginIdentifier) wants deletion") + info(.cgm, "CGM \(manager.pluginIdentifier) requested removal") Task { await self.glucoseManager?.deleteGlucoseSource() } @@ -169,15 +177,13 @@ extension PluginSource: CGMManagerDelegate { switch self.readCGMResult(readingResult: readingResult) { case let .success(glucose): self.glucoseManager?.newGlucoseFromCgmManager(newGlucose: glucose) - case .failure: - debug(.deviceManager, "CGM PLUGIN - unable to read CGM result") + case let .failure(error): + debug(.cgm, "CGM reading not usable: \(error.localizedDescription)") } self.glucoseManager?.reportCGMSensorObservation(self.currentSensorObservation()) self.publishCGMStatus() - - debug(.deviceManager, "CGM PLUGIN - Direct return done") } } @@ -190,9 +196,10 @@ extension PluginSource: CGMManagerDelegate { // TODO: Events in APS ? // currently only display in log the date of the event events.forEach { event in - debug(.deviceManager, "events from CGM at \(event.date)") + debug(.cgm, "CGM event \(event.type) at \(event.date)") if event.type == .sensorStart { + info(.cgm, "Sensor started") self.glucoseManager?.removeCalibrations() self.glucoseManager?.startCGMSensorSession(startedAt: event.date) } @@ -213,10 +220,7 @@ extension PluginSource: CGMManagerDelegate { dispatchPrecondition(condition: .onQueue(self.processQueue)) guard let fetchGlucoseManager = self.glucoseManager else { - debug( - .deviceManager, - "Could not gracefully unwrap FetchGlucoseManager upon observing LoopKit's cgmManagerDidUpdateState" - ) + debug(.cgm, "CGM state update ignored: no glucose manager") return } // Adjust app-specific NS Upload setting value when CGM setting is changed @@ -238,13 +242,14 @@ extension PluginSource: CGMManagerDelegate { } func cgmManager(_: CGMManager, didUpdate status: CGMManagerStatus) { - debug(.deviceManager, "CGM Manager did update state to \(status)") + debug(.cgm, "CGM status: valid sensor session \(status.hasValidSensorSession)") processQueue.async { [weak self] in guard let self = self else { return } dispatchPrecondition(condition: .onQueue(self.processQueue)) if self.cgmHasValidSensorSession != status.hasValidSensorSession { + info(.cgm, status.hasValidSensorSession ? "Sensor session started" : "Sensor session ended") self.cgmHasValidSensorSession = status.hasValidSensorSession } } @@ -261,13 +266,8 @@ extension PluginSource: CGMManagerDelegate { } private func readCGMResult(readingResult: CGMReadingResult) -> Result<[BloodGlucose], Error> { -// debug(.deviceManager, "PLUGIN CGM - Process CGM Reading Result launched with \(readingResult)") - if glucoseManager?.glucoseSource == nil { - debug( - .deviceManager, - "No glucose source available." - ) + debug(.cgm, "CGM reading received with no glucose source set") } switch readingResult { @@ -319,10 +319,7 @@ extension PluginSource: CGMManagerDelegate { // (LoopKit), not "unsafe to dose"; drivers withhold sensor-reported faults. let bloodGlucose = values.compactMap { newGlucoseSample -> BloodGlucose? in if newGlucoseSample.isDisplayOnly { - debug( - .deviceManager, - "PLUGIN CGM - display-only reading at \(newGlucoseSample.date), storing it" - ) + debug(.cgm, "Display-only reading at \(newGlucoseSample.date), storing it") } let quantity = newGlucoseSample.quantity diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index de89f5aca3..49bdcf133b 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -47,6 +47,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { @Injected() private var settingsManager: SettingsManager! @Injected() private var bluetoothProvider: BluetoothStateManager! @Injected() private var trioAlertManager: TrioAlertManager! + @Injected() private var deviceLogStore: DeviceLogStore! // lazy to avoid circular dependency (NightscoutManager → PumpHistoryStorage) private var resolver: Resolver? // serializes event batches: a PM may report again before the prior store finished @@ -95,6 +96,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { pumpDisplayState.value = PumpDisplayState(name: pumpManager.localizedTitle, image: pumpManager.smallImage) pumpName.send(pumpManager.localizedTitle) + info(.pump, "Pump set: \(pumpManager.pluginIdentifier)") var modifiedPreferences = settingsManager.preferences modifiedPreferences.bolusIncrement = PumpIncrementResolver.resolve( @@ -109,8 +111,8 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences) settingsManager.preferences = modifiedPreferences debug( - .deviceManager, - "Concentration U\(Int(truncating: NSDecimalNumber(decimal: concentration * 100))), Bolus increment set to: \(settingsManager.preferences.bolusIncrement), supported Pump Increments = \(pumpManager.supportedBolusVolumes.prefix(1))" + .pump, + "Concentration U\(Int(truncating: NSDecimalNumber(decimal: concentration * 100))), bolus increment set to \(settingsManager.preferences.bolusIncrement) U, smallest pump increment \(pumpManager.supportedBolusVolumes.prefix(1))" ) // Ensure the pump manager's delivery limits always reflect the user's @@ -127,7 +129,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { processQueue.async { pumpManager.syncDeliveryLimits(limits: deliveryLimits) { result in if case let .failure(error) = result { - debug(.deviceManager, "syncDeliveryLimits on pump manager init failed: \(error)") + warning(.pump, "Syncing delivery limits to the pump failed", error: error) } } } @@ -169,6 +171,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { storeBatteryStatus(simulatorPump.status) } } else { + info(.pump, oldValue == nil ? "No pump configured" : "Pump removed") pumpDisplayState.value = nil pumpExpiresAtDate.send(nil) pumpActivatedAtDate.send(nil) @@ -183,8 +186,8 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { concentration: concentration ) debug( - .deviceManager, - "Concentration U\(Int(truncating: NSDecimalNumber(decimal: settingsManager.settings.insulinConcentration * 100))), Bolus increment reset to: \(modifiedPreferences.bolusIncrement)" + .pump, + "Concentration U\(Int(truncating: NSDecimalNumber(decimal: settingsManager.settings.insulinConcentration * 100))), bolus increment reset to \(modifiedPreferences.bolusIncrement) U" ) storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences) settingsManager.preferences = modifiedPreferences @@ -206,7 +209,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { try context.save() } catch { - debug(.deviceManager, "Failed to delete OpenAPS_Battery entries: \(error)") + warning(.storage, "Deleting pump battery entries failed", error: error) } } } @@ -287,12 +290,12 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { func heartbeat(date: Date) { guard pumpUpdateCancellable == nil else { - warning(.deviceManager, "Pump updating already in progress. Skip updating.") + debug(.pump, "Pump update skipped: one is already running") return } guard !loopInProgress else { - warning(.deviceManager, "Loop in progress. Skip updating.") + debug(.pump, "Pump update skipped: loop is running") return } @@ -306,15 +309,15 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { private func updatePumpData() { guard let pumpManager = pumpManager else { - debug(.deviceManager, "Pump is not set, skip updating") + debug(.pump, "Pump update skipped: no pump configured") updateUpdateFinished(false) return } - debug(.deviceManager, "Start updating the pump data") + debug(.pump, "Updating pump data") processQueue.safeSync { pumpManager.ensureCurrentPumpData { _ in - debug(.deviceManager, "Pump data updated.") + debug(.pump, "Pump data updated") self.updateUpdateFinished(true) } } @@ -324,7 +327,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { pumpUpdateCancellable = nil pumpUpdatePromise = nil if !recommendsLoop { - warning(.deviceManager, "Loop recommendation time out or got error. Trying to loop right now.") + debug(.loop, "Requesting loop without fresh pump data") } self.recommendsLoop.send() } @@ -366,11 +369,11 @@ extension BaseDeviceDataManager: PumpManagerDelegate { didRequestBasalRateScheduleChange _: LoopKit.BasalRateSchedule, completion _: @escaping (Error?) -> Void ) { - debug(.deviceManager, "pumpManagerBasalRateChange") + debug(.pump, "Pump requested a basal schedule change; not supported") } func pumpManagerPumpWasReplaced(_: PumpManager) { - debug(.deviceManager, "pumpManagerPumpWasReplaced") + info(.pump, "Pump replaced") } var detectedSystemTimeOffset: TimeInterval { @@ -379,7 +382,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { } func pumpManager(_: PumpManager, didAdjustPumpClockBy adjustment: TimeInterval) { - debug(.deviceManager, "didAdjustPumpClockBy \(adjustment)") + info(.pump, "Pump clock adjusted by \(Int(adjustment)) s") } func pumpManagerDidUpdateState(_ pumpManager: PumpManager) { @@ -392,7 +395,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { /// heartbeat with pump occurs some issues in the backgroundtask - so never used func pumpManagerBLEHeartbeatDidFire(_: PumpManager) { - debug(.deviceManager, "Pump Heartbeat: do nothing. Pump connection is OK") + debug(.pump, "Pump BLE heartbeat fired") } func pumpManagerMustProvideBLEHeartbeat(_: PumpManager) -> Bool { @@ -436,15 +439,25 @@ extension BaseDeviceDataManager: PumpManagerDelegate { guard context.hasChanges else { return } try context.save() } catch { - debug(.deviceManager, "Failed to fetch or save battery: \(error)") + warning(.storage, "Saving pump battery status failed", error: error) } } } func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { dispatchPrecondition(condition: .onQueue(processQueue)) - debug(.deviceManager, "New pump status Bolus: \(status.bolusState)") - debug(.deviceManager, "New pump status Basal: \(String(describing: status.basalDeliveryState))") + debug( + .pump, + "Pump status: bolusing \(status.bolusState != .noBolus), suspended \(status.basalDeliveryState?.isSuspended ?? false), battery \(status.pumpBatteryChargeRemaining.map { "\(Int($0 * 100))%" } ?? "n/a")" + ) + if let isSuspended = status.basalDeliveryState?.isSuspended, + isSuspended != oldStatus.basalDeliveryState?.isSuspended + { + info(.pump, isSuspended ? "Pump suspended" : "Pump resumed") + } + if status.deliveryIsUncertain, !oldStatus.deliveryIsUncertain { + warning(.pump, "Pump delivery is uncertain") + } // Before any of the pump-specific branches below, which return early. storeBatteryStatus(status) @@ -483,7 +496,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { // Check if manual temp basal is active let manualTempBasalActive = status.basalDeliveryState?.isManualTempBasal ?? false if manualTempBasalActive { - debug(.deviceManager, "manual temp basal") + debug(.pump, "Manual temp basal active") } manualTempBasal.send(manualTempBasalActive) @@ -549,7 +562,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { func pumpManager(_: PumpManager, didError error: PumpManagerError) { dispatchPrecondition(condition: .onQueue(processQueue)) - debug(.deviceManager, "error: \(error), reason: \(String(describing: error.failureReason))") + warning(.pump, "Pump error", description: error.failureReason, error: error) errorSubject.send(error) } @@ -571,7 +584,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { guard let type = $0.type, type == .tempBasal else { return true } return $0.dose?.unitsPerHour ?? 0 <= Double(settingsManager.pumpSettings.maxBasal / concentration) } - debug(.deviceManager, "Storing \(events.count) new pump events: \(events)") + debug(.pump, "Storing \(events.count) new pump events") let purgedUploadedIds = try await pumpHistoryStorage.storePumpEvents( events, replacePendingEvents: replacePendingEvents @@ -588,7 +601,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { } } } catch { - debug(.deviceManager, "\(DebuggingIdentifiers.failed) Failed to store pump events: \(error)") + warning(.storage, "Storing pump events failed", error: error) completion(error) } } @@ -597,14 +610,14 @@ extension BaseDeviceDataManager: PumpManagerDelegate { func pumpManager( _: PumpManager, didReadReservoirValue units: Double, - at date: Date, + at _: Date, completion: @escaping (Result< (newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool), Error >) -> Void ) { dispatchPrecondition(condition: .onQueue(processQueue)) - debug(.deviceManager, "Reservoir Value \(units), at: \(date)") + debug(.pump, "Reservoir \(units) U") storage.save(Decimal(units), as: OpenAPS.Monitor.reservoir) broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { $0.pumpReservoirDidChange(Decimal(units)) @@ -619,9 +632,9 @@ extension BaseDeviceDataManager: PumpManagerDelegate { func pumpManagerRecommendsLoop(_: PumpManager) { dispatchPrecondition(condition: .onQueue(processQueue)) - debug(.deviceManager, "Pump recommends loop") + debug(.pump, "Pump recommends loop") guard let promise = pumpUpdatePromise else { - warning(.deviceManager, "We do not waiting for loop recommendation at this time.") + debug(.pump, "Loop recommendation ignored: no pump update pending") return } promise(.success(true)) @@ -636,22 +649,18 @@ extension BaseDeviceDataManager: PumpManagerDelegate { extension BaseDeviceDataManager: DeviceManagerDelegate { func issueAlert(_ alert: Alert) { - debug(.deviceManager, "issueAlert \(alert.identifier.value)") + debug(.alerts, "Pump alert issued: \(alert.identifier.alertIdentifier)") trioAlertManager.issueAlert(alert) } func retractAlert(identifier: Alert.Identifier) { - debug(.deviceManager, "retractAlert \(identifier.value)") + debug(.alerts, "Pump alert retracted: \(identifier.alertIdentifier)") trioAlertManager.retractAlert(identifier: identifier) } - func doesIssuedAlertExist(identifier _: Alert.Identifier, completion _: @escaping (Result) -> Void) { - debug(.deviceManager, "doesIssueAlertExist") - } + func doesIssuedAlertExist(identifier _: Alert.Identifier, completion _: @escaping (Result) -> Void) {} - func lookupAllUnretracted(managerIdentifier _: String, completion _: @escaping (Result<[PersistedAlert], Error>) -> Void) { - debug(.deviceManager, "lookupAllUnretracted") - } + func lookupAllUnretracted(managerIdentifier _: String, completion _: @escaping (Result<[PersistedAlert], Error>) -> Void) {} func lookupAllUnacknowledgedUnretracted( managerIdentifier _: String, @@ -667,13 +676,19 @@ extension BaseDeviceDataManager: DeviceManagerDelegate { } func deviceManager( - _: DeviceManager, - logEventForDeviceIdentifier _: String?, - type _: DeviceLogEntryType, + _ manager: DeviceManager, + logEventForDeviceIdentifier deviceIdentifier: String?, + type: DeviceLogEntryType, message: String, - completion _: ((Error?) -> Void)? + completion: ((Error?) -> Void)? ) { - debug(.deviceManager, "Device message: \(message)") + deviceLogStore.log( + managerIdentifier: manager.pluginIdentifier, + deviceIdentifier: deviceIdentifier, + type: type, + message: message, + completion: completion + ) } } diff --git a/Trio/Sources/APS/FetchGlucoseManager.swift b/Trio/Sources/APS/FetchGlucoseManager.swift index 7ec4ef611a..762a0549ae 100644 --- a/Trio/Sources/APS/FetchGlucoseManager.swift +++ b/Trio/Sources/APS/FetchGlucoseManager.swift @@ -49,6 +49,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { @Injected() var pluginCGMManager: PluginManager! @Injected() var calibrationService: CalibrationService! @Injected() var trioAlertManager: TrioAlertManager! + @Injected() private var deviceLogStore: DeviceLogStore! /// Guarded by `sensorStateLock`. @Persisted(key: "cgmSensorStateLog") private var sensorStateLog = CGMSensorStateLog() @@ -99,8 +100,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { private func subscribe() { timer.publisher .receive(on: processQueue) - .flatMap(maxPublishers: .max(1)) { [self] _ -> AnyPublisher<[BloodGlucose], Never> in - debug(.nightscout, "FetchGlucoseManager timer heartbeat") + .flatMap { [self] _ -> AnyPublisher<[BloodGlucose], Never> in if let glucoseSource = self.glucoseSource { return glucoseSource.fetch(self.timer).eraseToAnyPublisher() } else { @@ -108,7 +108,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { } } .sink { glucose in - debug(.nightscout, "FetchGlucoseManager callback sensor") + debug(.cgm, "Glucose source returned \(glucose.count) readings") Publishers.CombineLatest( Just(glucose), Just(self.glucoseStorage.syncDate()) @@ -123,7 +123,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { glucose: newGlucose ) } catch { - debug(.deviceManager, "Failed to store glucose: \(error)") + warning(.cgm, "Storing glucose failed", error: error) } self.glucoseStoreAndHeartLock.signal() } @@ -153,7 +153,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { glucose: newGlucose ) } catch { - debug(.deviceManager, "Failed to store glucose from CGM manager: \(error)") + warning(.cgm, "Storing glucose from the CGM failed", error: error) } glucoseStoreAndHeartLock.signal() } @@ -194,10 +194,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { Task { let note = "CGM: " + pending if await nightscoutManager.uploadNoteTreatment(note: note) { - debug(.deviceManager, "CGM sensor state uploaded to Nightscout: \(note)") + debug(.nightscout, "CGM sensor state uploaded: \(note)") } else { self.sensorStateLock.perform { self.sensorStateLog.uploadFailed(pending) } - debug(.deviceManager, "CGM sensor state upload failed, will retry: \(note)") + warning(.nightscout, "CGM sensor state upload failed, will retry: \(note)") } } } @@ -234,13 +234,17 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { let units = settingsManager.settings.units let managerName = cgmManager.map { "\(type(of: $0))" } ?? "nil" let loopkitUnits: HKUnit = units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter - print("manager: \(managerName) is changing units to: \(loopkitUnits.description) ") + debug(.cgm, "\(managerName) units set to \(loopkitUnits.description)") manager?.unitDidChange(to: loopkitUnits) } func updateGlucoseSource(cgmGlucoseSourceType: CGMType, cgmGlucosePluginId: String, newManager: CGMManagerUI?) { // if changed, remove all calibrations if self.cgmGlucoseSourceType != cgmGlucoseSourceType || self.cgmGlucosePluginId != cgmGlucosePluginId { + info( + .cgm, + "Glucose source changed to \(cgmGlucoseSourceType.rawValue)\(cgmGlucosePluginId.isEmpty ? "" : ", \(cgmGlucosePluginId)")" + ) removeCalibrations() cgmManager = nil glucoseSource = nil @@ -253,8 +257,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { // if plugin, if the same pluginID, no change required because the manager is available // if plugin, if not the same pluginID, need to reset the cgmManager // if plugin and newManager provides, update cgmManager - debug(.apsManager, "plugin : \(String(describing: cgmManager?.pluginIdentifier))") - if let manager = newManager { cgmManager = manager removeCalibrations() @@ -277,7 +279,11 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { case .simulator: glucoseSource = simulatorSource case .plugin: - glucoseSource = PluginSource(glucoseStorage: glucoseStorage, glucoseManager: self) + glucoseSource = PluginSource( + glucoseStorage: glucoseStorage, + glucoseManager: self, + deviceLogStore: deviceLogStore + ) } } @@ -321,7 +327,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { // Check if sensor emitted calibrations let calibrations = glucose.filter { $0.mbg != nil } if calibrations.isNotEmpty { - debug(.deviceManager, "New calibration found") + debug(.cgm, "Storing \(calibrations.count) calibrations") try await glucoseStorage.storeGlucose(calibrations) } @@ -333,12 +339,12 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { let backfillGlucose = newGlucose.filter { $0.dateString <= syncDate } var hasBackfilled = false if backfillGlucose.isNotEmpty { - debug(.deviceManager, "Backfilling glucose...") + debug(.cgm, "Backfilling \(backfillGlucose.count) glucose readings") do { try await glucoseStorage.backfillGlucose(backfillGlucose) hasBackfilled = true } catch { - debug(.deviceManager, "Unable to backfill glucose: \(error)") + warning(.cgm, "Backfilling glucose failed", error: error) } } @@ -347,14 +353,14 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { var hasStoredNew = false if filtered.isNotEmpty { - debug(.deviceManager, "New glucose found: \(filtered.count) readings") + debug(.cgm, "\(filtered.count) new glucose readings found") try await glucoseStorage.storeGlucose(filtered) hasStoredNew = true } // Run smoothing if ANY glucose was stored (backfilled or new) if (hasBackfilled || hasStoredNew) && settingsManager.settings.smoothGlucose { - debug(.deviceManager, "Triggering smoothing: hasBackfilled=\(hasBackfilled), hasStoredNew=\(hasStoredNew)") + debug(.cgm, "Smoothing glucose, backfilled \(hasBackfilled), new readings stored \(hasStoredNew)") // Create a fresh context for smoothing to ensure it sees the latest data from the persistent store let smoothingContext = CoreDataStack.shared.newTaskContext() smoothingContext.name = "smoothGlucose" @@ -510,7 +516,7 @@ extension BaseFetchGlucoseManager { /// func smoothGlucose(context: NSManagedObjectContext, hours: Double = 24) async { let algorithm = settingsManager.settings.smoothingAlgorithm - debug(.deviceManager, "Smoothing glucose with algorithm: \(algorithm.displayName), window: \(hours)h") + debug(.cgm, "Smoothing glucose with \(algorithm.displayName) over \(hours) h") switch algorithm { case .exponential: @@ -531,7 +537,7 @@ extension BaseFetchGlucoseManager { do { // get objectIDs let objectIDs = try await fetchGlucose(context: context, hours: hours) - debug(.deviceManager, "Exponential smoothing: fetched \(objectIDs.count) glucose readings") + debug(.cgm, "Exponential smoothing fetched \(objectIDs.count) glucose readings") try await context.perform(schedule: .immediate) { // Load managed objects from object IDs @@ -541,7 +547,7 @@ extension BaseFetchGlucoseManager { } guard !glucoseReadings.isEmpty else { - debug(.deviceManager, "Exponential smoothing: no readings after compactMap") + debug(.cgm, "Exponential smoothing found no usable glucose readings") return } @@ -570,9 +576,9 @@ extension BaseFetchGlucoseManager { } let duration = Date().timeIntervalSince(startTime) - debugPrint(String(format: "Exponential smoothing duration: %0.04fs", duration)) + debug(.cgm, String(format: "Glucose smoothing took %.3f s", duration)) } catch { - debug(.deviceManager, "Failed to smooth glucose: \(error)") + warning(.cgm, "Glucose smoothing failed", error: error) } } @@ -612,8 +618,8 @@ extension BaseFetchGlucoseManager { validWindowCount = recentOffset + 1 // include the more recent reading let dateFormatter = ISO8601DateFormatter() debug( - .deviceManager, - "Exponential: Found gap of \(gapMinutesRounded) minutes at offset \(recentOffset), validWindowCount=\(validWindowCount). Newer: \(dateFormatter.string(from: newerDate)) (\(newer.glucose)), Older: \(dateFormatter.string(from: olderDate)) (\(older.glucose))" + .cgm, + "Exponential smoothing found a \(gapMinutesRounded) min gap between \(dateFormatter.string(from: olderDate)) and \(dateFormatter.string(from: newerDate)), window \(validWindowCount) readings" ) break } @@ -622,8 +628,8 @@ extension BaseFetchGlucoseManager { if Int(newer.glucose) == xDripErrorGlucose { validWindowCount = recentOffset // exclude this 38 value debug( - .deviceManager, - "Exponential: Found xDrip error glucose (38) at offset \(recentOffset), validWindowCount=\(validWindowCount)" + .cgm, + "Exponential smoothing found xDrip error glucose 38 mg/dL, window \(validWindowCount) readings" ) break } @@ -633,8 +639,8 @@ extension BaseFetchGlucoseManager { // Fallback values already set above, so just return guard validWindowCount >= minimumWindowSize else { debug( - .deviceManager, - "Exponential: Insufficient window size (\(validWindowCount) < \(minimumWindowSize)), keeping fallback values" + .cgm, + "Exponential smoothing skipped, only \(validWindowCount) of \(minimumWindowSize) readings, keeping raw values" ) return } @@ -707,7 +713,7 @@ extension BaseFetchGlucoseManager { let clamped = max(rounded, minimumSmoothedGlucose) object.smoothedGlucose = clamped as NSDecimalNumber } - debug(.deviceManager, "Exponential: Stored \(validWindow.count) smoothed values total") + debug(.cgm, "Exponential smoothing stored \(validWindow.count) smoothed values") } /// UKF-based glucose smoothing + storage. @@ -720,21 +726,19 @@ extension BaseFetchGlucoseManager { // get objectIDs let objectIDs = try await fetchGlucose(context: context, hours: hours) let objectIDsCount = objectIDs.count - debug(.deviceManager, "UKF smoothing: fetched \(objectIDsCount) glucose readings") + debug(.cgm, "UKF smoothing fetched \(objectIDsCount) glucose readings") try await context.perform(schedule: .immediate) { - debug(.deviceManager, "UKF smoothing: processing \(objectIDsCount) readings") // Load managed objects from object IDs let glucoseReadings = objectIDs.compactMap { context.object(with: $0) as? GlucoseStored } guard !glucoseReadings.isEmpty else { - debug(.deviceManager, "UKF smoothing: no readings found after filtering") + debug(.cgm, "UKF smoothing found no usable glucose readings") return } - debug(.deviceManager, "UKF smoothing: converting \(glucoseReadings.count) readings to BloodGlucose") // Convert GlucoseStored to BloodGlucose array let bloodGlucoseArray: [BloodGlucose] = glucoseReadings.compactMap { stored -> BloodGlucose? in @@ -756,11 +760,7 @@ extension BaseFetchGlucoseManager { } guard bloodGlucoseArray.count >= 2 else { - debug(.deviceManager, "UKF smoothing: insufficient readings for smoothing, using raw values as fallback") - debug( - .deviceManager, - "UKF smoothing: insufficient readings for smoothing (\(bloodGlucoseArray.count) < 2), using raw values as fallback" - ) + debug(.cgm, "UKF smoothing skipped, only \(bloodGlucoseArray.count) readings, keeping raw values") // Fallback: set smoothed = raw for insufficient data for reading in glucoseReadings { reading.smoothedGlucose = NSDecimalNumber(value: Int(reading.glucose)) @@ -773,7 +773,6 @@ extension BaseFetchGlucoseManager { var ukf = UnscentedKalmanFilter() let smoothed = ukf.smooth(bloodGlucoseArray) - debug(.deviceManager, "UKF smoothing: storing smoothed values") // Store smoothed values back to CoreData for (idx, bloodGlucose) in smoothed.enumerated() where idx < glucoseReadings.count { @@ -781,13 +780,13 @@ extension BaseFetchGlucoseManager { glucoseReadings[idx].smoothedGlucose = NSDecimalNumber(value: smoothedValue) } else { // Fallback: if UKF didn't produce a smoothed value, use raw - debug(.deviceManager, "UKF smoothing: no smoothed value for index \(idx), using raw value") + debug(.cgm, "UKF smoothing has no value for reading \(idx), keeping raw value") glucoseReadings[idx].smoothedGlucose = NSDecimalNumber(value: Int(glucoseReadings[idx].glucose)) } } try context.save() - debug(.deviceManager, "UKF smoothing: saved \(smoothed.count) smoothed values to CoreData") + debug(.cgm, "UKF smoothing stored \(smoothed.count) smoothed values") } // Force viewContext to refresh so UI sees updated smoothed values immediately @@ -799,9 +798,9 @@ extension BaseFetchGlucoseManager { } let duration = Date().timeIntervalSince(startTime) - debugPrint(String(format: "UKF smoothing duration: %0.04fs", duration)) + debug(.cgm, String(format: "UKF smoothing took %0.04f s", duration)) } catch { - debug(.deviceManager, "Failed to smooth glucose with UKF: \(error)") + warning(.cgm, "UKF glucose smoothing failed", error: error) } } @@ -815,7 +814,7 @@ extension BaseFetchGlucoseManager { do { // get objectIDs (oldest-first, see fetchGlucose) let objectIDs = try await fetchGlucose(context: context, hours: hours) - debug(.deviceManager, "Adaptive UKF smoothing: fetched \(objectIDs.count) glucose readings") + debug(.cgm, "Adaptive UKF smoothing fetched \(objectIDs.count) glucose readings") // IOB timeline for the compression-low gate: Tai re-smooths the full window every // run, so each reading is judged with the IOB that was current at its own time @@ -831,8 +830,8 @@ extension BaseFetchGlucoseManager { guard glucoseReadings.count >= 2 else { debug( - .deviceManager, - "Adaptive UKF smoothing: insufficient readings (\(glucoseReadings.count) < 2), using raw values as fallback" + .cgm, + "Adaptive UKF smoothing skipped, only \(glucoseReadings.count) readings, keeping raw values" ) for reading in glucoseReadings { reading.smoothedGlucose = NSDecimalNumber(value: Int(reading.glucose)) @@ -878,9 +877,8 @@ extension BaseFetchGlucoseManager { try context.save() debug( - .deviceManager, - "Adaptive UKF smoothing: saved \(newestFirst.count) smoothed values " + - "(buckets=\(bucketed.count)) to CoreData" + .cgm, + "Adaptive UKF smoothing stored \(newestFirst.count) smoothed values from \(bucketed.count) buckets" ) } @@ -893,9 +891,9 @@ extension BaseFetchGlucoseManager { } let duration = Date().timeIntervalSince(startTime) - debugPrint(String(format: "Adaptive UKF smoothing duration: %0.04fs", duration)) + debug(.cgm, String(format: "Adaptive UKF smoothing took %0.04f s", duration)) } catch { - debug(.deviceManager, "Failed to smooth glucose with Adaptive UKF: \(error)") + warning(.cgm, "Adaptive UKF glucose smoothing failed", error: error) } } @@ -926,7 +924,7 @@ extension BaseFetchGlucoseManager { } } } catch { - debug(.deviceManager, "Adaptive UKF smoothing: IOB timeline fetch failed, compression gate off: \(error)") + warning(.cgm, "Adaptive UKF smoothing could not fetch IOB, compression detection off", error: error) return [] } } diff --git a/Trio/Sources/APS/FetchTreatmentsManager.swift b/Trio/Sources/APS/FetchTreatmentsManager.swift index cde4b60e64..969b9c147d 100644 --- a/Trio/Sources/APS/FetchTreatmentsManager.swift +++ b/Trio/Sources/APS/FetchTreatmentsManager.swift @@ -25,8 +25,7 @@ final class BaseFetchTreatmentsManager: FetchTreatmentsManager, Injectable { .receive(on: processQueue) .sink { [weak self] _ in guard let self = self else { return } - debug(.nightscout, "FetchTreatmentsManager heartbeat") - debug(.nightscout, "Start fetching carbs and temptargets") + debug(.nightscout, "Fetching carbs and temp targets") Task { do { @@ -54,7 +53,7 @@ final class BaseFetchTreatmentsManager: FetchTreatmentsManager, Injectable { else { debug( .nightscout, - "Skipping temp target with date: \(tempTarget.date ?? Date.distantPast)" + "Skipping temp target from \(tempTarget.date ?? Date.distantPast): already stored or a cancel" ) continue } @@ -74,7 +73,7 @@ final class BaseFetchTreatmentsManager: FetchTreatmentsManager, Injectable { Foundation.NotificationCenter.default.post(name: .didUpdateTempTargetConfiguration, object: nil) } } catch { - debug(.default, "\(DebuggingIdentifiers.failed) error in \(#file) \(#function): \(error)") + warning(.nightscout, "Storing carbs or temp targets from Nightscout failed", error: error) } } } diff --git a/Trio/Sources/APS/OpenAPS/OpenAPS.swift b/Trio/Sources/APS/OpenAPS/OpenAPS.swift index 38b13270be..9a04dff0dc 100644 --- a/Trio/Sources/APS/OpenAPS/OpenAPS.swift +++ b/Trio/Sources/APS/OpenAPS/OpenAPS.swift @@ -115,7 +115,7 @@ final class OpenAPS { guard context.hasChanges else { return } try context.save() } catch { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Determination to Core Data") + warning(.storage, "Saving the determination failed", error: error) } } } @@ -263,8 +263,6 @@ final class OpenAPS { simulatedCarbsDate: Date? = nil, simulation: Bool = false ) async throws -> Determination? { - debug(.openAPS, "Start determineBasal") - let context = newContext("determineBasal") // Perform asynchronous calls in parallel @@ -348,7 +346,7 @@ final class OpenAPS { } if !hasSufficientTdd, preferences.useNewFormula || (preferences.useNewFormula && preferences.sigmoid) { - debug(.openAPS, "Insufficient TDD for dynamic formula; disabling for determine basal run.") + debug(.algorithm, "Dynamic ISF off for this run: not enough TDD history") preferences.useNewFormula = false preferences.sigmoid = false } @@ -369,7 +367,10 @@ final class OpenAPS { units: units ) - debug(.openAPS, "\(simulation ? "[SIMULATION]" : "") OREF DETERMINATION: \(String(describing: orefDetermination))") + debug( + .algorithm, + "Determination\(simulation ? " (simulation)" : ""): BG \(orefDetermination?.bg ?? 0) mg/dL, IOB \(orefDetermination?.iob ?? 0) U, COB \(orefDetermination?.cob ?? 0) g, ISF \(orefDetermination?.isf ?? 0) mg/dL/U, sensitivity ratio \(orefDetermination?.sensitivityRatio ?? 1)" + ) if var determination = orefDetermination, let deliverAt = determination.deliverAt { // set both timestamp and deliverAt to the SAME date; this will be updated for timestamp once it is enacted @@ -389,10 +390,7 @@ final class OpenAPS { return determination } else { - debug( - .openAPS, - "\(DebuggingIdentifiers.failed) No determination data. determination: \(String(describing: orefDetermination)), deliverAt: \(String(describing: orefDetermination?.deliverAt))" - ) + warning(.algorithm, "No determination produced\(orefDetermination == nil ? "" : ": missing delivery time")") throw APSError.apsError(message: "No determination data.") } } @@ -481,8 +479,6 @@ final class OpenAPS { } func autosense(shouldSmoothGlucose: Bool) async throws -> Autosens? { - debug(.openAPS, "Start autosens") - let context = newContext("autosense") // Perform asynchronous calls in parallel @@ -523,7 +519,7 @@ final class OpenAPS { clock: Date() ) - debug(.openAPS, "AUTOSENS: \(autosens)") + debug(.algorithm, "Autosens ratio \(autosens.ratio)") autosens.timestamp = Date() await storage.saveAsync(autosens, as: Settings.autosense) @@ -531,8 +527,6 @@ final class OpenAPS { } func createProfiles(for dosingMode: DosingMode) async throws { - debug(.openAPS, "Start creating pump profile and user profile") - let context = newContext("createProfiles") // Load required settings and profiles asynchronously @@ -580,15 +574,15 @@ final class OpenAPS { autosensMax: preferences.autosensMax )) debug( - .openAPS, - "TempTarget: target=\(targetValue), HBT=\(defaultHalfBasalTarget), effectiveHBT=\(effectiveHBT), percentage=\(percentage)%, adjustmentType=Custom" + .algorithm, + "Temp target \(targetValue) mg/dL: half basal target \(defaultHalfBasalTarget) -> \(effectiveHBT) mg/dL, \(percentage)%" ) } } // Overwrite the lowTTlowersSens if autosensMax does not support it if preferences.lowTemptargetLowersSensitivity, preferences.autosensMax <= 1 { adjustedPreferences.lowTemptargetLowersSensitivity = false - debug(.openAPS, "Setting lowTTlowersSens to false due to insufficient autosensMax: \(preferences.autosensMax)") + debug(.algorithm, "Low temp target lowers sensitivity off: autosens max \(preferences.autosensMax) is 1 or below") } } @@ -633,10 +627,7 @@ final class OpenAPS { await storage.saveAsync(pumpProfile, as: Settings.pumpProfile) await storage.saveAsync(profile, as: Settings.profile) } catch { - debug( - .apsManager, - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to create pump profile and normal profile: \(error)" - ) + warning(.algorithm, "Creating profiles failed", error: error) throw error } } @@ -798,7 +789,7 @@ final class OpenAPS { guard context.hasChanges else { return } try context.save() } catch { - print(error.localizedDescription) + warning(.storage, "Saving forecasts failed", error: error) } } } diff --git a/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift b/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift index b487975e5a..c54f47314b 100644 --- a/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift +++ b/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift @@ -15,20 +15,24 @@ import Foundation enum Logger { /// Mirrors `Logger.Category` in the app target. enum Category: String { - case `default` - case service - case businessLogic - case openAPS - case openAPSSwift - case deviceManager - case apsManager + case loop + case algorithm + case pump + case cgm + case bolus + case treatments case nightscout - case remoteControl - case bolusState - case watchManager - case coreData + case tidepool + case remote + case healthKit + case alerts + case watch + case liveActivity case storage + case settings case telemetry + case ui + case lifecycle } } @@ -68,4 +72,28 @@ import Foundation algorithmLogSink?(category, message) } + func error( + _ category: Logger.Category, + _ message: String, + description _: String? = nil, + error _: Swift.Error? = nil, + file _: String = #file, + function _: String = #function, + line _: UInt = #line + ) { + algorithmLogSink?(category, message) + } + + func fault( + _ category: Logger.Category, + _ message: String, + description _: String? = nil, + error _: Swift.Error? = nil, + file _: String = #file, + function _: String = #function, + line _: UInt = #line + ) { + algorithmLogSink?(category, message) + } + #endif diff --git a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/AimiB30.swift b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/AimiB30.swift index ec8115c9c2..033b01417a 100644 --- a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/AimiB30.swift +++ b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/AimiB30.swift @@ -133,8 +133,8 @@ enum AimiB30 { var det = determination debug( - .openAPSSwift, - "B30 safety checks — BG: \(inputs.currentGlucose), minGuardBG: \(inputs.minGuardGlucose), threshold: \(inputs.threshold), eventualBG: \(inputs.eventualGlucose), minBG: \(inputs.minGlucose), minDelta: \(inputs.minDelta), expectedDelta: \(inputs.expectedDelta), IOB: \(inputs.iob)" + .algorithm, + "B30 safety checks: BG \(inputs.currentGlucose) mg/dL, min guard BG \(inputs.minGuardGlucose) mg/dL, threshold \(inputs.threshold) mg/dL, eventual BG \(inputs.eventualGlucose) mg/dL, min BG \(inputs.minGlucose) mg/dL, min delta \(inputs.minDelta) mg/dL, expected delta \(inputs.expectedDelta) mg/dL, IOB \(inputs.iob) U" ) if enforce1ZeroTempSuspend { @@ -153,7 +153,7 @@ enum AimiB30 { determination: det, iobInputs: inputs.iobInputs ) - debug(.openAPSSwift, "B30 check 1 (zeroTempSuspend): fires=\(fires)") + debug(.algorithm, "B30 zero temp suspend check \(fires ? "blocks" : "passes")") if fires { return (true, applyBlockedTag(d, blocker: "zeroTempSuspend")) } @@ -178,7 +178,7 @@ enum AimiB30 { overrideFactor: inputs.overrideFactor, iobInputs: inputs.iobInputs ) - debug(.openAPSSwift, "B30 check 2 (lowEventualBG): fires=\(fires)") + debug(.algorithm, "B30 low eventual BG check \(fires ? "blocks" : "passes")") if fires { return (true, applyBlockedTag(d, blocker: "lowEventualBG")) } @@ -199,7 +199,7 @@ enum AimiB30 { determination: det, iobInputs: inputs.iobInputs ) - debug(.openAPSSwift, "B30 check 3 (glucoseFallingFast): fires=\(fires)") + debug(.algorithm, "B30 falling glucose check \(fires ? "blocks" : "passes")") if fires { return (true, applyBlockedTag(d, blocker: "glucoseFallingFast")) } @@ -218,7 +218,7 @@ enum AimiB30 { determination: det, iobInputs: inputs.iobInputs ) - debug(.openAPSSwift, "B30 check 4 (eventualForecastLow): fires=\(fires)") + debug(.algorithm, "B30 low eventual forecast check \(fires ? "blocks" : "passes")") if fires { return (true, d) } det = d } @@ -233,12 +233,12 @@ enum AimiB30 { determination: det, iobInputs: inputs.iobInputs ) - debug(.openAPSSwift, "B30 check 5 (iobCap): fires=\(fires)") + debug(.algorithm, "B30 IOB cap check \(fires ? "blocks" : "passes")") if fires { return (true, d) } det = d } - debug(.openAPSSwift, "B30 safety checks passed — boost will activate") + debug(.algorithm, "B30 safety checks passed, boost will activate") return (false, det) } diff --git a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/AutoISFGlucose+Helpers.swift b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/AutoISFGlucose+Helpers.swift index 953f720176..1a6c7c3320 100644 --- a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/AutoISFGlucose+Helpers.swift +++ b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/AutoISFGlucose+Helpers.swift @@ -54,7 +54,7 @@ enum AutoISFGlucose { ) -> AutoISFGlucoseStatus? { // Check if autoISF is enabled guard profile.autoisf else { - debug(.openAPSSwift, "AutoISFGlucose.getAutoISFGlucoseStatus: autoISF disabled (profile.autoisf = false)") + debug(.algorithm, "AutoISF glucose status skipped, autoISF is off") return nil } @@ -63,11 +63,11 @@ enum AutoISFGlucose { guard let glucose = reading.glucose ?? reading.sgv else { return nil } return (glucose: glucose, date: reading.dateString) } - debug(.openAPSSwift, "AutoISFGlucose.getAutoISFGlucoseStatus: Found \(readings.count) valid glucose readings") + debug(.algorithm, "AutoISF glucose status uses \(readings.count) glucose readings") guard readings.count > 3 else { // Not enough data for calculations - debug(.openAPSSwift, "AutoISFGlucose.getAutoISFGlucoseStatus: Not enough readings (\(readings.count) <= 3)") + debug(.algorithm, "AutoISF glucose status skipped, only \(readings.count) glucose readings") return nil } diff --git a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift index bab0b3b07d..5dca69849b 100644 --- a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift +++ b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift @@ -36,7 +36,7 @@ enum DeterminationGenerator { guard let glucoseStatus = glucoseStatus else { throw DeterminationError.missingInputs } // Calculate autoISF status if enabled (this is where we have glucose data available) - debug(.openAPSSwift, "determineBasal: profile.autoisf = \(profile.autoisf)") + debug(.algorithm, "AutoISF is \(profile.autoisf ? "on" : "off")") let autoISFStatus: AutoISFGlucoseStatus? = OrefSubTimer.time("determineBasal.autoISFStatus") { profile.autoisf ? glucoseStatus.getAutoISFGlucoseStatus( @@ -44,11 +44,11 @@ enum DeterminationGenerator { profile ) : nil } - debug(.openAPSSwift, "determineBasal: autoISFStatus = \(autoISFStatus != nil ? "populated" : "nil")") + debug(.algorithm, "AutoISF glucose status \(autoISFStatus != nil ? "available" : "unavailable")") if let status = autoISFStatus { - debug(.openAPSSwift, " dura_p=\(status.dura_p), delta_pl=\(status.delta_pl), delta_pn=\(status.delta_pn)") - debug(.openAPSSwift, " r_squ=\(status.r_squ), a_0=\(status.a_0), bg_acceleration=\(status.bg_acceleration)") - debug(.openAPSSwift, " dura_ISF_minutes=\(status.dura_ISF_minutes), dura_ISF_average=\(status.dura_ISF_average)") + debug(.algorithm, "AutoISF dura_p \(status.dura_p) min, delta_pl \(status.delta_pl), delta_pn \(status.delta_pn)") + debug(.algorithm, "AutoISF r_squ \(status.r_squ), a_0 \(status.a_0), BG acceleration \(status.bg_acceleration)") + debug(.algorithm, "AutoISF dura_ISF \(status.dura_ISF_minutes) min, average \(status.dura_ISF_average) mg/dL") } return try OrefSubTimer.time("determineBasal.body") { diff --git a/Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift b/Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift index 1d266360e9..424bc25554 100644 --- a/Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift +++ b/Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift @@ -55,8 +55,8 @@ enum ForecastGenerator { let previousCR = adjustedCarbRatio adjustedCarbRatio = (adjustedCarbRatio * adjustedSensitivity / profileSensitivity).jsRounded(scale: 2) debug( - .openAPSSwift, - "CR: adjusted from \(previousCR.jsRounded(scale: 1)) to \(adjustedCarbRatio.jsRounded(scale: 1)) as ISF changed (sensRatio \((profileSensitivity / adjustedSensitivity).jsRounded(scale: 2)))" + .algorithm, + "CR adjusted from \(previousCR.jsRounded(scale: 1)) to \(adjustedCarbRatio.jsRounded(scale: 1)) as ISF changed (sensRatio \((profileSensitivity / adjustedSensitivity).jsRounded(scale: 2)))" ) } @@ -66,8 +66,8 @@ enum ForecastGenerator { if effectiveUseProfileCSF, rawProfileSens > 0, profileCarbRatio > 0 { carbSensitivityFactor = rawProfileSens / profileCarbRatio debug( - .openAPSSwift, - "Using profile.sens for CSF calculation: \(rawProfileSens.jsRounded()) / \(profileCarbRatio.jsRounded(scale: 1)) = \(carbSensitivityFactor.jsRounded(scale: 1))" + .algorithm, + "CSF from profile ISF \(rawProfileSens.jsRounded()) mg/dL/U and CR \(profileCarbRatio.jsRounded(scale: 1)) g/U is \(carbSensitivityFactor.jsRounded(scale: 1)) mg/dL/g" ) } else { carbSensitivityFactor = adjustedSensitivity / adjustedCarbRatio diff --git a/Trio/Sources/APS/OpenAPSSwift/Profile/Basal.swift b/Trio/Sources/APS/OpenAPSSwift/Profile/Basal.swift index 3ae0667e5f..fef8e63075 100644 --- a/Trio/Sources/APS/OpenAPSSwift/Profile/Basal.swift +++ b/Trio/Sources/APS/OpenAPSSwift/Profile/Basal.swift @@ -15,7 +15,7 @@ struct Basal { func rate(at timestamp: Date) throws -> Decimal? { guard lastRateIsValid else { - warning(.openAPS, "Warning: bad basal schedule \(basalProfile)") + warning(.algorithm, "Basal schedule invalid: last of \(basalProfile.count) rates is 0") return nil } guard basalProfile.count > 1 else { @@ -40,7 +40,7 @@ struct Basal { let basalProfileData = basalProfile guard let lastBasalRate = basalProfileData.last?.rate, lastBasalRate != 0 else { - warning(.openAPS, "Warning: bad basal schedule \(basalProfile)") + warning(.algorithm, "Basal schedule invalid: last of \(basalProfile.count) rates is 0 or missing") return nil } diff --git a/Trio/Sources/APS/OpenAPSSwift/Profile/Carbs.swift b/Trio/Sources/APS/OpenAPSSwift/Profile/Carbs.swift index fc536a031a..acfd6e0191 100644 --- a/Trio/Sources/APS/OpenAPSSwift/Profile/Carbs.swift +++ b/Trio/Sources/APS/OpenAPSSwift/Profile/Carbs.swift @@ -15,12 +15,13 @@ struct Carbs { } } } catch { + warning(.algorithm, "Carb ratio lookup failed", error: error) return nil } // Check for invalid values if currentRatio < 1 || currentRatio > 150 { - warning(.openAPS, "Warning: carbRatio of \(currentRatio) out of bounds.") + warning(.algorithm, "Carb ratio \(currentRatio) is outside 1-150") return nil } diff --git a/Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift b/Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift index c544d0e8ed..a774287c6d 100644 --- a/Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift +++ b/Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift @@ -123,7 +123,7 @@ enum ProfileGenerator { ) { preferences.insulinPeakTime = Decimal(peak) } else { - debug(.openAPS, "don't modify insulin peak time") + debug(.algorithm, "Insulin peak time left unchanged") } return try generateProfile( @@ -231,7 +231,7 @@ enum ProfileGenerator { // Error check: profile.sens < 5 in Javascript if let sens = profile.sens { guard sens >= 5 else { - debug(.openAPS, "ISF of \(String(describing: profile.sens)) is not supported") + debug(.algorithm, "ISF \(sens) mg/dL/U is below the minimum of 5") throw ProfileError.invalidISF(value: profile.sens) } } diff --git a/Trio/Sources/APS/OpenAPSSwift/Profile/Targets.swift b/Trio/Sources/APS/OpenAPSSwift/Profile/Targets.swift index 1621057233..2899d55d8e 100644 --- a/Trio/Sources/APS/OpenAPSSwift/Profile/Targets.swift +++ b/Trio/Sources/APS/OpenAPSSwift/Profile/Targets.swift @@ -53,7 +53,7 @@ struct Targets { break } } else { - warning(.openAPS, "eventualBG target range invalid: \(target.targetBottom ?? -1)-\(target.targetTop ?? -1)") + warning(.algorithm, "Temp target range invalid: \(target.targetBottom ?? -1)-\(target.targetTop ?? -1) mg/dL") break } } diff --git a/Trio/Sources/APS/Storage/AlertStorage.swift b/Trio/Sources/APS/Storage/AlertStorage.swift index 4e73e92829..82c32df843 100644 --- a/Trio/Sources/APS/Storage/AlertStorage.swift +++ b/Trio/Sources/APS/Storage/AlertStorage.swift @@ -248,7 +248,7 @@ final class BaseAlertHistoryStorage: AlertHistoryStorage, Injectable { do { return try JSONCoding.decoder.decode([AlertEntry].self, from: data) } catch { - debug(.storage, "Failed to decode alerts from UserDefaults: \(error)") + warning(.storage, "Decoding stored alerts failed", error: error) // Clear corrupt payload so app can recover defaults.removeObject(forKey: Keys.alertsData) return [] @@ -263,7 +263,7 @@ final class BaseAlertHistoryStorage: AlertHistoryStorage, Injectable { let data = try JSONCoding.encoder.encode(alerts) defaults.set(data, forKey: Keys.alertsData) } catch { - debug(.storage, "Failed to encode alerts to UserDefaults: \(error)") + warning(.storage, "Encoding alerts for storage failed", error: error) } } diff --git a/Trio/Sources/APS/Storage/CarbsStorage.swift b/Trio/Sources/APS/Storage/CarbsStorage.swift index dda377646a..4161e0f2d1 100644 --- a/Trio/Sources/APS/Storage/CarbsStorage.swift +++ b/Trio/Sources/APS/Storage/CarbsStorage.swift @@ -249,7 +249,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { do { try await insertCarbEntry(entry, areFetchedFromRemote: areFetchedFromRemote) } catch { - print(error.localizedDescription) + warning(.storage, "Storing carb entry failed", error: error) } } @@ -268,6 +268,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { do { try context.save() } catch { + warning(.storage, "Saving carb entry failed", error: error) // an injected long-lived context would otherwise save the failed row later context.rollback() throw error @@ -385,12 +386,12 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { await context.perform { do { try context.execute(batchInsert) - debugPrint("Carbs Storage: \(DebuggingIdentifiers.succeeded) saved fpus to core data") + debug(.storage, "Saved \(entries.count) fat and protein entries") // Notify subscriber in Home State Model to update the FPU Array self.updateSubject.send(()) } catch { - debugPrint("Carbs Storage: \(DebuggingIdentifiers.failed) error while saving fpus to core data") + warning(.storage, "Saving fat and protein entries failed", error: error) } } } @@ -488,7 +489,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { do { carbEntryFromCoreData = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored guard let carbEntry = carbEntryFromCoreData else { - debugPrint("Carb entry for batch delete not found. \(DebuggingIdentifiers.failed)") + warning(.storage, "Carb entry to delete not found") return } @@ -507,7 +508,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { // execute the batch delete request let result = try context.execute(deleteRequest) as? NSBatchDeleteResult - debugPrint("\(DebuggingIdentifiers.succeeded) Deleted \(result?.result ?? 0) items with FpuID \(fpuID)") + debug(.storage, "Deleted \(result?.result ?? 0) carb entries with FPU ID \(fpuID)") // Notifiy subscribers of the batch delete self.updateSubject.send(()) @@ -520,13 +521,11 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { guard context.hasChanges else { return } try context.save() - debugPrint( - "CarbsStorage: \(#function) \(DebuggingIdentifiers.succeeded) deleted carb entry from core data" - ) + debug(.storage, "Deleted carb entry") } } catch { - debugPrint("\(DebuggingIdentifiers.failed) Error deleting carb entry: \(error)") + warning(.storage, "Deleting carb entry failed", error: error) } } } diff --git a/Trio/Sources/APS/Storage/ContactImageStorage.swift b/Trio/Sources/APS/Storage/ContactImageStorage.swift index b91ec4bc1f..fe55ac2f19 100644 --- a/Trio/Sources/APS/Storage/ContactImageStorage.swift +++ b/Trio/Sources/APS/Storage/ContactImageStorage.swift @@ -69,7 +69,7 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable { } } } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Error fetching contact image entries: \(error)") + warning(.storage, "Fetching contact image entries failed", error: error) return [] } } @@ -111,9 +111,7 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Contact Trick Entry to Core Data with error: \(error.userInfo)" - ) + warning(.storage, "Saving contact image entry failed", error: error) } } } @@ -156,14 +154,10 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable { guard context.hasChanges else { return } try context.save() } else { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) No matching Contact Trick Entry found to update." - ) + warning(.storage, "Contact image entry to update not found") } } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update Contact Trick Entry with error: \(error.userInfo)" - ) + warning(.storage, "Updating contact image entry failed", error: error) } } } diff --git a/Trio/Sources/APS/Storage/DeterminationStorage.swift b/Trio/Sources/APS/Storage/DeterminationStorage.swift index 658a2e0291..6efd0f5f02 100644 --- a/Trio/Sources/APS/Storage/DeterminationStorage.swift +++ b/Trio/Sources/APS/Storage/DeterminationStorage.swift @@ -56,9 +56,7 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable { let forecasts = Array(forecastSet) return forecasts.map(\.objectID) as [NSManagedObjectID] } catch { - debugPrint( - "Failed \(DebuggingIdentifiers.failed) to fetch Forecast IDs for OrefDetermination with ID \(determinationID): \(error)" - ) + warning(.storage, "Fetching forecast IDs for determination failed", error: error) return [] } } @@ -75,9 +73,7 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable { let forecastValues = forecastValueSet.sorted(by: { $0.index < $1.index }) return forecastValues.map(\.objectID) } catch { - debugPrint( - "Failed \(DebuggingIdentifiers.failed) to fetch Forecast Value IDs with ID \(forecastID): \(error)" - ) + warning(.storage, "Fetching forecast value IDs failed", error: error) return [] } } @@ -103,9 +99,7 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable { } } } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch forecast Values with error: \(error)" - ) + warning(.storage, "Fetching forecast values failed", error: error) } return (data.id, forecast, forecastValues) } @@ -136,9 +130,7 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable { let values = forecast.forecastValuesArray.map { Int($0.value) } return values.isEmpty ? nil : values } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch forecast of type \(type): \(error)" - ) + warning(.storage, "Fetching \(type) forecast failed", error: error) return nil } } @@ -220,9 +212,7 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable { ) } } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch managed object with error: \(error)" - ) + warning(.storage, "Fetching determination for Nightscout upload failed", error: error) } return result diff --git a/Trio/Sources/APS/Storage/GlucoseStorage.swift b/Trio/Sources/APS/Storage/GlucoseStorage.swift index 5a5069cf8a..be35c2ba8b 100644 --- a/Trio/Sources/APS/Storage/GlucoseStorage.swift +++ b/Trio/Sources/APS/Storage/GlucoseStorage.swift @@ -145,10 +145,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { glucose.map { entry in var clamped = entry if let raw = entry.glucose, raw < Config.minimumGlucose { - debug( - .deviceManager, - "Clamping sub-\(Config.minimumGlucose) glucose: raw=\(raw) at \(entry.dateString) -> \(Config.minimumGlucose)" - ) + debug(.cgm, "Clamped BG \(raw) mg/dL at \(entry.dateString) to \(Config.minimumGlucose) mg/dL") clamped.glucose = Config.minimumGlucose } if let raw = entry.sgv, raw < Config.minimumGlucose { @@ -187,7 +184,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { let results = try context.fetch(fetchRequest) as? [NSDictionary] existingDates = results?.compactMap({ $0["date"] as? Date }) ?? [] } catch { - debugPrint("Failed to fetch existing glucose dates: \(error)") + warning(.storage, "Fetching existing glucose dates failed", error: error) } // This is an inefficient filtering algorithm, but I'm assuming that the @@ -254,7 +251,6 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { } private func storeCGMState(_ glucose: [BloodGlucose]) { - debug(.deviceManager, "start storage cgmState") storage.transaction { storage in let file = OpenAPS.Monitor.cgmState var treatments = storage.retrieve(file, as: [NightscoutTreatment].self) ?? [] @@ -274,7 +270,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { let notes = createCGMStateNotes(transmitterID: x.transmitterID, activationDate: x.activationDate) let treatment = createCGMStateTreatment(sessionStartDate: sessionStartDate, notes: notes) - debug(.deviceManager, "CGM sensor change \(treatment)") + info(.cgm, "Recorded CGM sensor change at \(sessionStartDate)") treatments.append(treatment) updated = true } @@ -341,9 +337,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { // Glucose subscribers already listen to the update publisher, so call here to update glucose-related data. self.updateSubject.send() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save manual glucose to Core Data with error: \(error)" - ) + warning(.storage, "Saving manual glucose failed", error: error) } } } @@ -375,7 +369,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { fetchedDate = date } } catch { - debugPrint("Fetch error: \(DebuggingIdentifiers.failed) \(error)") + warning(.storage, "Fetching latest glucose date failed", error: error) } } @@ -397,7 +391,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { let results = try context.fetch(fetchRequest) date = results.first?.date } catch let error as NSError { - debug(.storage, "Fetch error: \(DebuggingIdentifiers.failed) \(error), \(error.userInfo)") + warning(.storage, "Fetching last glucose date failed", error: error) } } @@ -862,7 +856,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { let result = try context.existingObject(with: treatmentObjectID) as? GlucoseStored guard let glucoseToDelete = result else { - debugPrint("Data Table State: \(#function) \(DebuggingIdentifiers.failed) glucose not found in core data") + warning(.storage, "Glucose entry to delete not found") return } @@ -878,11 +872,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { guard context.hasChanges else { return } try context.save() - debugPrint("\(#file) \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from core data") + debug(.storage, "Deleted glucose entry") } catch { - debugPrint( - "\(#file) \(#function) \(DebuggingIdentifiers.failed) error while deleting glucose from core data: \(error)" - ) + warning(.storage, "Deleting glucose entry failed", error: error) } } } @@ -908,7 +900,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { return nil } catch { - debugPrint("Error fetching latest glucose: \(error)") + warning(.storage, "Fetching latest glucose for alarm failed", error: error) return nil } } diff --git a/Trio/Sources/APS/Storage/OverrideStorage.swift b/Trio/Sources/APS/Storage/OverrideStorage.swift index c0858e4ee4..fdfb0534b4 100644 --- a/Trio/Sources/APS/Storage/OverrideStorage.swift +++ b/Trio/Sources/APS/Storage/OverrideStorage.swift @@ -246,9 +246,7 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable { guard self.viewContext.hasChanges else { return } try self.viewContext.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to copy Override with error: \(error.userInfo)" - ) + warning(.storage, "Saving copied override failed", error: error) } } @@ -264,7 +262,7 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable { await taskContext.perform { do { guard let override = try taskContext.existingObject(with: objectID) as? OverrideStored else { - debugPrint("Override for batch delete not found. \(DebuggingIdentifiers.failed)") + warning(.storage, "Override preset to delete not found") return } @@ -273,11 +271,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable { guard taskContext.hasChanges else { return } try taskContext.save() - debugPrint( - "OverrideStorage: \(#function) \(DebuggingIdentifiers.succeeded) deleted override from core data" - ) + debug(.storage, "Deleted override preset") } catch { - debugPrint("\(DebuggingIdentifiers.failed) Error deleting override: \(error)") + warning(.storage, "Deleting override preset failed", error: error) } } } @@ -367,7 +363,7 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable { formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let jsonDate = formatter.date(from: createdAtString) else { - debug(.nightscout, "Could not parse override created_at string: \(createdAtString)") + warning(.nightscout, "Parsing override creation date '\(createdAtString)' failed") return } diff --git a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift index b50b494ba1..7b3bc27db8 100644 --- a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift +++ b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift @@ -65,8 +65,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable { roundingMode: .plain ) debug( - .apsManager, - "Concentration: Pumped bolus volume \(roundedVolume) at U\(Int(concentration * 100)), adjusted to U100 bolus: \(u100Volume) U" + .pump, + "Pumped U\(Int(concentration * 100)) bolus \(roundedVolume) U adjusted to U100 bolus \(u100Volume) U" ) return u100Volume } @@ -82,8 +82,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable { roundingMode: .plain ) debug( - .apsManager, - "Concentration: Pumped rate volume \(pumpedRate.precisionRounded()) U\(Int(concentration * 100))/hr, adjusted to U100 rate: \(u100Rate) IU/hr." + .pump, + "Pumped U\(Int(concentration * 100)) rate \(pumpedRate.precisionRounded()) U/h adjusted to U100 rate \(u100Rate) U/h" ) return u100Rate } @@ -238,8 +238,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable { request.predicate = NSPredicate(format: "isMutable == YES") for orphan in try context.fetch(request) where !assertedRows.contains(orphan.objectID) { debug( - .coreData, - "Purging unasserted mutable event \(orphan.syncIdentifier ?? orphan.id ?? "-") (\(orphan.type ?? "?")), uploaded to NS: \(orphan.isUploadedToNS)" + .storage, + "Purging stale pending pump event \(orphan.syncIdentifier ?? orphan.id ?? "-") (\(orphan.type ?? "?")), uploaded to Nightscout: \(orphan.isUploadedToNS)" ) if orphan.isUploadedToNS, let id = orphan.id { purgedUploadedIds.append(id) @@ -253,9 +253,9 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable { try context.save() self.updateSubject.send(()) - debug(.coreData, "\(DebuggingIdentifiers.succeeded) stored pump events in Core Data") + debug(.storage, "Stored pump events") } catch let error as NSError { - debug(.coreData, "\(DebuggingIdentifiers.failed) failed to store pump events with error: \(error.userInfo)") + warning(.storage, "Storing pump events failed", error: error) throw error } return purgedUploadedIds @@ -306,7 +306,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable { event.isUploadedToNS = false } if !dose.isMutable { - debug(.coreData, "Finalized bolus \(dose.syncIdentifier ?? "-"): \(bolus.amount ?? 0) U") + debug(.pump, "Finalized bolus \(dose.syncIdentifier ?? "-"): \(bolus.amount ?? 0) U") } case PumpEventStored.EventType.tempBasal.rawValue: @@ -331,8 +331,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable { } if !dose.isMutable { debug( - .coreData, - "Finalized temp basal \(dose.syncIdentifier ?? "-"): \(tempBasal.rate ?? 0) U/hr, \(tempBasal.duration) min" + .pump, + "Finalized temp basal \(dose.syncIdentifier ?? "-"): rate \(tempBasal.rate ?? 0) U/h for \(tempBasal.duration) min" ) } @@ -372,10 +372,10 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable { do { guard context.hasChanges else { return } try context.save() - debug(.coreData, "External insulin saved") + debug(.storage, "Saved external insulin \(amount) U") self.updateSubject.send(()) } catch { - debug(.coreData, "Failed to store external insulin in context: \(error)") + warning(.storage, "Saving external insulin failed", error: error) } } } diff --git a/Trio/Sources/APS/Storage/TDDStorage.swift b/Trio/Sources/APS/Storage/TDDStorage.swift index cb8e675885..15867231da 100644 --- a/Trio/Sources/APS/Storage/TDDStorage.swift +++ b/Trio/Sources/APS/Storage/TDDStorage.swift @@ -46,7 +46,7 @@ final class BaseTDDStorage: TDDStorage, Injectable { pumpHistory: [PumpHistoryEvent], basalProfile: [BasalProfileEntry] ) async throws -> TDDResult { - debug(.apsManager, "Starting TDD calculation with \(pumpHistory.count) pump events") + debug(.algorithm, "Calculating TDD from \(pumpHistory.count) pump events") // Log the first and last pump history events if available let earliestEvent: String @@ -59,7 +59,6 @@ final class BaseTDDStorage: TDDStorage, Injectable { } else { earliestEvent = "No events available" latestEvent = "No events available" - debug(.apsManager, "No pump history events available for logging.") } // Group events by type once to avoid multiple filters @@ -126,21 +125,14 @@ final class BaseTDDStorage: TDDStorage, Injectable { let weightedAvgString = String(format: "%.2f", NSDecimalNumber(decimal: weighted?.rounded(toPlaces: 2) ?? 0).doubleValue) let hoursString = String(format: "%.5f", NSDecimalNumber(decimal: Decimal(hours).truncated(toPlaces: 5)).doubleValue) - debug(.apsManager, """ - TDD Summary: - +-------------------+-----------+-----------+ - | Type\t\t\t\t| Amount U\t| Percent %\t| - +-------------------+-----------+-----------+ - | Total\t\t\t\t| \(totalString)\t\t| \t\t\t| - | Bolus\t\t\t\t| \(bolusString)\t\t| \(percentage(bolus, total))\t\t| - | Temp Basal\t\t| \(tempBasalString)\t\t| \(percentage(temp, total))\t\t| - | Scheduled Basal\t| \(scheduledBasalString)\t\t| \(percentage(scheduled, total))\t\t| - | Weighted Average\t| \(weightedAvgString)\t\t| \t\t\t| - +-------------------+-----------+-----------+ - - Hours of Data: \(hoursString) - - Earliest Event: \(earliestEvent) - - Latest Event: \(latestEvent) - """) + debug( + .algorithm, + "TDD \(totalString) U: bolus \(bolusString) U (\(percentage(bolus, total))%), " + + "temp basal \(tempBasalString) U (\(percentage(temp, total))%), " + + "scheduled basal \(scheduledBasalString) U (\(percentage(scheduled, total))%), " + + "weighted average \(weightedAvgString) U, \(hoursString) h of data, " + + "earliest event \(earliestEvent), latest event \(latestEvent)" + ) // Return calculated TDDResult return TDDResult( @@ -172,7 +164,7 @@ final class BaseTDDStorage: TDDStorage, Injectable { guard context.hasChanges else { return } try context.save() } catch { - debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to save TDD: \(error)") + warning(.storage, "Saving TDD failed", error: error) } } } diff --git a/Trio/Sources/APS/Storage/TempTargetsStorage.swift b/Trio/Sources/APS/Storage/TempTargetsStorage.swift index ceb9dffd1c..1e20313611 100644 --- a/Trio/Sources/APS/Storage/TempTargetsStorage.swift +++ b/Trio/Sources/APS/Storage/TempTargetsStorage.swift @@ -171,7 +171,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable { try context.save() } } catch let error as NSError { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to save new temp target with error: \(error.userInfo)") + warning(.storage, "Saving new temp target failed", error: error) throw error } @@ -213,7 +213,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable { let results = try context.fetch(fetchRequest) return !results.isEmpty } catch let error as NSError { - debugPrint("\(DebuggingIdentifiers.failed) Failed to check for existing Temp Target: \(error)") + warning(.storage, "Checking for an existing temp target failed", error: error) return false } } @@ -238,9 +238,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable { guard self.viewContext.hasChanges else { return } try self.viewContext.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to copy Temp Target with error: \(error.userInfo)" - ) + warning(.storage, "Saving copied temp target failed", error: error) } } @@ -255,7 +253,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable { do { let result = try context.existingObject(with: objectID) as? TempTargetStored guard let tempTarget = result else { - debug(.default, "\(DebuggingIdentifiers.failed) Temp Target for batch delete not found.") + warning(.storage, "Temp target preset to delete not found") return } @@ -264,7 +262,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable { guard context.hasChanges else { return } try context.save() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete Temp Target: \(error)") + warning(.storage, "Deleting temp target preset failed", error: error) } } } diff --git a/Trio/Sources/Application/AppDelegate.swift b/Trio/Sources/Application/AppDelegate.swift index 1a0377ad01..a597e09e35 100644 --- a/Trio/Sources/Application/AppDelegate.swift +++ b/Trio/Sources/Application/AppDelegate.swift @@ -35,7 +35,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNoti didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { - debug(.remoteControl, "Received notification") + debug(.remote, "Received remote notification") do { let jsonData = try JSONSerialization.data(withJSONObject: userInfo) @@ -46,15 +46,12 @@ class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNoti try await TrioRemoteControl.shared.handleRemoteNotification(encryptedData: encryptedMessage.encryptedData) completionHandler(.newData) } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) failed to handle remote notification with error: \(error)" - ) + warning(.remote, "Handling remote notification failed", error: error) completionHandler(.failed) } } } catch { - debug(.remoteControl, "Error decoding push message shell: \(error)") + warning(.remote, "Decoding remote notification failed", error: error) completionHandler(.failed) } } @@ -70,10 +67,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNoti do { try await TrioRemoteControl.shared.handleAPNSChanges(deviceToken: token) } catch { - debug( - .remoteControl, - "\(DebuggingIdentifiers.failed) failed to register for remote notifications: \(error)" - ) + warning(.remote, "Updating the push token failed", error: error) } } } @@ -82,6 +76,6 @@ class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNoti _: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error ) { - debug(.remoteControl, "Failed to register for remote notifications: \(error)") + warning(.remote, "Registering for remote notifications failed", error: error) } } diff --git a/Trio/Sources/Application/TrioApp.swift b/Trio/Sources/Application/TrioApp.swift index b8daabe4d8..21b4e6afed 100644 --- a/Trio/Sources/Application/TrioApp.swift +++ b/Trio/Sources/Application/TrioApp.swift @@ -130,6 +130,7 @@ extension Notification.Name { } init() { + TrioLogger.install(AppLogStore.shared) FileProtectionFixer.fixFlagFileProtectionForPropertyPersistentFlags() // TODO: ‼️ REMOVE ME BEFORE PUBLIC BETA / RELEASE let notificationCenter = Foundation.NotificationCenter.default @@ -155,10 +156,6 @@ extension Notification.Name { showOnboardingCompletedSplash = true } - let submodulesInfo = BuildDetails.shared.submodules.map { key, value in - "\(key): \(value.branch) \(value.commitSHA)" - }.joined(separator: ", ") - /// The current development version of the app. /// /// Follows a semantic pattern where release versions are like `0.5.0`, and @@ -173,9 +170,13 @@ extension Notification.Name { /// If the dev version is unavailable, `"unknown"` is returned. let devVersion = Bundle.main.appDevVersion ?? "unknown" - debug( - .default, - "Trio Started: v\(devVersion)(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.shared.buildDate()))] [buildExpires: \(String(describing: BuildDetails.shared.calculateExpirationDate()))] [Branch: \(BuildDetails.shared.branchAndSha)] [submodules: \(submodulesInfo)]" + // Submodules can change between launches within one exported window + let submodules = BuildDetails.shared.submodules.sorted { $0.key < $1.key } + .map { "\($0.key) \($0.value.commitSHA.prefix(7))" } + .joined(separator: ", ") + info( + .lifecycle, + "Trio started: v\(devVersion) (\(Bundle.main.buildVersionNumber ?? "")) \(BuildDetails.shared.branchAndSha); \(submodules)" ) // Fix bug in iOS 18 related to the translucent tab bar configureTabBarAppearance() @@ -218,14 +219,11 @@ extension Notification.Name { do { try await BuildDetails.shared.handleExpireDateChange() } catch { - debug(.default, "Failed to handle expire date change: \(error)") + warning(.nightscout, "Uploading profile after build change failed", error: error) } }.value - } catch { - debug( - .coreData, - "\(DebuggingIdentifiers.failed) Failed to initialize Core Data Stack: \(error)" - ) + } catch let initError { + error(.storage, "Core Data stack could not be initialized", error: initError) await MainActor.run { self.initState.error = true @@ -243,31 +241,22 @@ extension Notification.Name { try await importer.importGlucoseHistoryIfNeeded() } catch { importErrors - .append(String( - localized: "Failed to import glucose history.", - comment: "Migration error shown on first launch when legacy glucose JSON fails to import into Core Data" - )) - debug(.coreData, "❌ Failed to import JSON-based Glucose History: \(error)") + .append(String(localized: "Failed to import glucose history.")) + warning(.storage, "Importing legacy glucose history failed", error: error) } do { try await importer.importPumpHistoryIfNeeded() } catch { - importErrors.append(String( - localized: "Failed to import pump history.", - comment: "Migration error shown on first launch when legacy pump-history JSON fails to import into Core Data" - )) - debug(.coreData, "❌ Failed to import JSON-based Pump History: \(error)") + importErrors.append(String(localized: "Failed to import pump history.")) + warning(.storage, "Importing legacy pump history failed", error: error) } do { try await importer.importCarbHistoryIfNeeded() } catch { - importErrors.append(String( - localized: "Failed to import algorithm data.", - comment: "Migration error shown on first launch when legacy carb/algorithm JSON fails to import into Core Data" - )) - debug(.coreData, "❌ Failed to import JSON-based Carb History: \(error)") + importErrors.append(String(localized: "Failed to import algorithm data.")) + warning(.storage, "Importing legacy carb history failed", error: error) } do { @@ -280,7 +269,7 @@ extension Notification.Name { comment: "Migration error on first launch — the interpolated value is the underlying system error description" ) ) - debug(.coreData, "❌ Failed to import JSON-based OpenAPS Determination Data: \(error)") + warning(.storage, "Importing legacy determinations failed", error: error) } initState.migrationErrors = importErrors @@ -419,7 +408,7 @@ extension Notification.Name { .developmentBranchWarning(developmentBranchAlerter) } .onChange(of: scenePhase) { _, newScenePhase in - debug(.default, "APPLICATION PHASE: \(newScenePhase)") + info(.lifecycle, "Scene phase changed to \(newScenePhase)") /// If the App goes to the background we should ensure that all the changes are saved from the viewContext to the Persistent Container if newScenePhase == .background { @@ -488,12 +477,8 @@ extension Notification.Name { async let cleanupTokens: () = coreDataStack.cleanupPersistentHistoryTokens(before: Date.oneWeekAgo) async let purgeData: () = purgeOldNSManagedObjects() - // Use the async version of log cleanup - async let cleanupLogs: () = SimpleLogReporter.cleanupAllLogsAsync() - await cleanupTokens try await purgeData - await cleanupLogs // Update the last cleanup date PropertyPersistentFlags.shared.lastCleanupDate = Date() diff --git a/Trio/Sources/Assemblies/APSAssembly.swift b/Trio/Sources/Assemblies/APSAssembly.swift index 0f06726940..cee1766f22 100644 --- a/Trio/Sources/Assemblies/APSAssembly.swift +++ b/Trio/Sources/Assemblies/APSAssembly.swift @@ -3,6 +3,7 @@ import Swinject final class APSAssembly: Assembly { func assemble(container: Container) { + container.register(DeviceLogStore.self) { _ in DeviceLogStore() }.inObjectScope(.container) container.register(DeviceDataManager.self) { r in BaseDeviceDataManager(resolver: r) } container.register(APSManager.self) { r in BaseAPSManager(resolver: r) } container.register(FetchGlucoseManager.self) { r in BaseFetchGlucoseManager(resolver: r) } diff --git a/Trio/Sources/Assemblies/ServiceAssembly.swift b/Trio/Sources/Assemblies/ServiceAssembly.swift index 42d18a9bc0..19f39e8e5c 100644 --- a/Trio/Sources/Assemblies/ServiceAssembly.swift +++ b/Trio/Sources/Assemblies/ServiceAssembly.swift @@ -6,14 +6,6 @@ final class ServiceAssembly: Assembly { func assemble(container: Container) { container.register(NotificationCenter.self) { _ in Foundation.NotificationCenter.default } container.register(Broadcaster.self) { _ in BaseBroadcaster() } - container.register(GroupedIssueReporter.self) { _ in - let reporter = CollectionIssueReporter() - reporter.add(reporters: [ - SimpleLogReporter() - ]) - reporter.setup() - return reporter - } container.register(CalendarManager.self) { r in BaseCalendarManager(resolver: r) } container.register(HKHealthStore.self) { _ in HKHealthStore() } container.register(HealthKitManager.self) { r in BaseHealthKitManager(resolver: r) } @@ -51,5 +43,6 @@ final class ServiceAssembly: Assembly { .inObjectScope(.container) container.register(TelemetryAttestor.self) { r in TelemetryAttestor(resolver: r) } .inObjectScope(.container) + container.register(LogExport.self) { r in LogExport(resolver: r) } } } diff --git a/Trio/Sources/Helpers/BackgroundTask+Helper.swift b/Trio/Sources/Helpers/BackgroundTask+Helper.swift index 871708569c..bb2067c751 100644 --- a/Trio/Sources/Helpers/BackgroundTask+Helper.swift +++ b/Trio/Sources/Helpers/BackgroundTask+Helper.swift @@ -6,10 +6,10 @@ import UIKit func endBackgroundTaskSafely(_ taskID: inout UIBackgroundTaskIdentifier, taskName: String = "Unnamed Task") { if taskID != .invalid { UIApplication.shared.endBackgroundTask(taskID) - debug(.default, "Background task '\(taskName)' ended successfully.") + debug(.lifecycle, "Background task '\(taskName)' ended") taskID = .invalid } else { - debug(.default, "Background task '\(taskName)' was already invalid or ended.") + debug(.lifecycle, "Background task '\(taskName)' already ended") } } @@ -26,11 +26,11 @@ func startBackgroundTask(withName name: String) -> UIBackgroundTaskIdentifier { // we simply end the Task with the given ID if taskID != .invalid { UIApplication.shared.endBackgroundTask(taskID) - debug(.default, "Background task '\(name)' ended in expiration handler.") + warning(.lifecycle, "Background task '\(name)' expired") } } } - debug(.default, "Background task '\(name)' started with ID: \(taskID)") + debug(.lifecycle, "Background task '\(name)' started") return taskID } diff --git a/Trio/Sources/Helpers/BuildDetails.swift b/Trio/Sources/Helpers/BuildDetails.swift index eafc814d82..bf58e01da9 100644 --- a/Trio/Sources/Helpers/BuildDetails.swift +++ b/Trio/Sources/Helpers/BuildDetails.swift @@ -114,10 +114,10 @@ class BuildDetails: Injectable { let remoteCommands = TrioRemoteControl.CommandType.supported.map(\.rawValue) if previousExpireDate != expireDate { - debug(.nightscout, "New build expire date detected, uploading profile") + info(.nightscout, "Build expiration date changed, uploading profile") try await nightscoutManager.uploadProfiles() } else if previousRemoteCommands != remoteCommands { - debug(.nightscout, "Supported remote commands changed, uploading profile") + info(.nightscout, "Supported remote commands changed, uploading profile") try await nightscoutManager.uploadProfiles() } } diff --git a/Trio/Sources/Helpers/Bundle+Extensions.swift b/Trio/Sources/Helpers/Bundle+Extensions.swift index c6f2e669e6..641b6cb287 100644 --- a/Trio/Sources/Helpers/Bundle+Extensions.swift +++ b/Trio/Sources/Helpers/Bundle+Extensions.swift @@ -20,9 +20,7 @@ extension Bundle { // Note: We use `NSString` instead of `String`, because it makes it easier working with regex, ranges, substring etc. let profileNSString = NSString(data: profileData, encoding: String.Encoding.ascii.rawValue) else { - print( - "WARNING: Could not find or read `embedded.mobileprovision`. If running on Simulator, there are no provisioning profiles." - ) + debug(.lifecycle, "No provisioning profile found, expected on the simulator") return nil } @@ -35,7 +33,7 @@ extension Bundle { ), let range = Range(match.range(at: 1), in: profileNSString as String) else { - print("Warning: Could not create regex or find match.") + warning(.lifecycle, "Provisioning profile expiration date not found") return nil } @@ -58,16 +56,14 @@ extension Bundle { // Note: We use `NSString` instead of `String`, because it makes it easier working with regex, ranges, substring etc. let profileNSString = NSString(data: profileData, encoding: String.Encoding.ascii.rawValue) else { - print( - "WARNING: Could not find or read `embedded.mobileprovision`. If running on Simulator, there are no provisioning profiles." - ) + debug(.lifecycle, "No provisioning profile found, expected on the simulator") return "N/A" } // NOTE: We have the `[\\W]*?` check to make sure that variations in number of tabs or new lines in the future does not influence the result. guard let regex = try? NSRegularExpression(pattern: "ExpirationDate[\\W]*?(.*?)", options: []) else { - print("Warning: Could not create regex.") + warning(.lifecycle, "Creating the provisioning profile expiration regex failed") return "N/A" } @@ -79,7 +75,7 @@ extension Bundle { // NOTE: range `0` corresponds to the full regex match, so to get the first capture group, we use range `1` guard let rangeOfCapturedGroupForDate = regExMatches.first?.range(at: 1) else { - print("Warning: Could not find regex match or capture group.") + warning(.lifecycle, "Provisioning profile expiration date not found") return "N/A" } diff --git a/Trio/Sources/Helpers/JSON.swift b/Trio/Sources/Helpers/JSON.swift index 2066f66591..4458332e6e 100644 --- a/Trio/Sources/Helpers/JSON.swift +++ b/Trio/Sources/Helpers/JSON.swift @@ -19,7 +19,7 @@ extension JSON { let object = try JSONCoding.decoder.decode(Self.self, from: data) self = object } catch { - warning(.service, "Cannot decode JSON", error: error) + warning(.storage, "Decoding \(Self.self) from JSON failed", error: error) return nil } } diff --git a/Trio/Sources/Helpers/MainChartHelper.swift b/Trio/Sources/Helpers/MainChartHelper.swift index 3d62fd599f..b48b41699c 100644 --- a/Trio/Sources/Helpers/MainChartHelper.swift +++ b/Trio/Sources/Helpers/MainChartHelper.swift @@ -202,12 +202,10 @@ enum MainChartHelper { return TimeInterval(doubleValue * 60) // return seconds } } else { - debugPrint("Attribute \(attribute) not found or not of type NSDecimalNumber") + debug(.ui, "Chart attribute \(attribute) missing or not a decimal") } } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to calculate duration for object with error: \(error)" - ) + warning(.ui, "Calculating chart duration for \(attribute) failed", error: error) } return nil @@ -220,9 +218,7 @@ enum MainChartHelper { return attributeValue.decimalValue } } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to calculate target for object with error: \(error)" - ) + warning(.ui, "Calculating chart target for \(attribute) failed", error: error) } return nil } diff --git a/Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift b/Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift index 2b7776e6d0..c9feb97d13 100644 --- a/Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift +++ b/Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift @@ -77,12 +77,12 @@ import Foundation guard let value = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? Value else { - debug(.storage, "[PersistedProperty:\(key)] Could not cast property list to expected type.") + warning(.storage, "Persisted value '\(key)' has an unexpected type") return nil } return value } catch { - debug(.storage, "❌ [PersistedProperty:\(key)] Failed to read value: \(error)") + warning(.storage, "Reading persisted value '\(key)' failed", error: error) return nil } } @@ -91,9 +91,9 @@ import Foundation guard FileManager.default.fileExists(atPath: storageURL.path) else { return } do { try FileManager.default.removeItem(at: storageURL) - debug(.storage, "[PersistedProperty:\(key)] Removed value.") + debug(.storage, "Removed persisted value '\(key)'") } catch { - debug(.storage, "❌ [PersistedProperty:\(key)] Failed to remove value: \(error)") + warning(.storage, "Removing persisted value '\(key)' failed", error: error) } return } @@ -108,9 +108,9 @@ import Foundation ofItemAtPath: storageURL.path ) - debug(.storage, "✅ [PersistedProperty:\(key)] Saved value successfully.") + debug(.storage, "Saved persisted value '\(key)'") } catch { - debug(.storage, "❌ [PersistedProperty:\(key)] Failed to write value: \(error)") + warning(.storage, "Saving persisted value '\(key)' failed", error: error) } } } @@ -146,7 +146,7 @@ enum FileProtectionFixer { appropriateFor: nil, create: false ) else { - debug(.storage, "⚠️ Could not access the documents directory.") + warning(.storage, "Documents directory unavailable, skipping file protection update") return } @@ -162,9 +162,9 @@ enum FileProtectionFixer { [.protectionKey: FileProtectionType.none], ofItemAtPath: fileURL.path ) - debug(.storage, "✅ Updated protection for \(fileName)") + debug(.storage, "Updated file protection for \(fileName)") } catch { - debug(.storage, "❌ Failed to update protection for \(fileName): \(error)") + warning(.storage, "Updating file protection for \(fileName) failed", error: error) } } } diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 7bc0ec1180..0f2ab1b973 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1088,6 +1088,24 @@ } } } + }, + "Ask on Discord" : { + + }, + "Ask on Facebook" : { + + }, + "Open a GitHub Issue" : { + + }, + "Public. Only for bugs you can describe without personal or health data." : { + + }, + "Report a Problem" : { + + }, + "Save the file or send it to someone helping you on Discord or Facebook. Contains glucose, insulin and carbs; passwords and serial numbers are removed." : { + }, "Choose the preferred lower threshold for Time in Range statistics." : { "comment" : "Short description of the lower range threshold setting.", @@ -42198,6 +42216,9 @@ } } } + }, + "All Logs" : { + }, "All Past Days of Data " : { "comment" : "Duration displayed in statPanel", @@ -76475,6 +76496,9 @@ } } } + }, + "CGM Logs" : { + }, "CGM noise level too high: %lld." : { "localizations" : { @@ -90581,6 +90605,9 @@ } } } + }, + "Could not prepare logs" : { + }, "Could not save the change." : { "comment" : "Adjustment could not be saved", @@ -91501,6 +91528,9 @@ } } } + }, + "Creates one file you can send when asking for help." : { + }, "Critical" : { "comment" : "Name of the critical alarm sound.", @@ -163330,6 +163360,9 @@ } } } + }, + "How far back?" : { + }, "How it Works" : { "localizations" : { @@ -178247,6 +178280,9 @@ }, "Last 24 Hours" : { "comment" : "Home stats panel range option" + }, + "Last 3 Days" : { + }, "last 3 months" : { "comment" : "Stats banner scope" @@ -243693,6 +243729,9 @@ } } } + }, + "Pump Logs" : { + }, "Pump Model" : { "localizations" : { @@ -268241,6 +268280,9 @@ } } } + }, + "Share CGM Logs" : { + }, "Share logs" : { "comment" : "2 log files to share", @@ -268639,6 +268681,12 @@ } } } + }, + "Share Pump Logs" : { + + }, + "Share Trio Logs" : { + }, "Shared Secret" : { "localizations" : { @@ -285198,136 +285246,6 @@ } } }, - "Submit Ticket on GitHub" : { - "localizations" : { - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Подайте тикет в GitHub" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nahlásit problém na GitHubu" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Indsend billet på GitHub" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ticket an GitHub senden" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enviar Ticket en GitHub" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Créer un ticket sur GitHub" - } - }, - "he" : { - "stringUnit" : { - "state" : "new", - "value" : "Submit Ticket on GitHub" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Invia Ticket su GitHub" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "GitHub에 이슈(Ticket) 등록하기" - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio GitHub" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ticket verzenden op GitHub" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prześlij zgłoszenie na GitHub" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enviar Ticket no GitHub" - } - }, - "ro" : { - "stringUnit" : { - "state" : "new", - "value" : "Submit Ticket on GitHub" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Отправить заявку на GitHub" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skapa en ticket på GitHub" - } - }, - "tr" : { - "stringUnit" : { - "state" : "new", - "value" : "Submit Ticket on GitHub" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Надіслати заявку на GitHub" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tạo yêu cầu trên GitHub" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "在 GitHub 上提交工单" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "在 GitHub 提交問題單" - } - } - } - }, "Submodules" : { "localizations" : { "bg" : { @@ -320536,136 +320454,6 @@ } } }, - "Trio Discord" : { - "localizations" : { - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Discord – Trio" - } - }, - "he" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio Discord" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio 디스코드" - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "ro" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio Discord" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Трио Discord" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "tr" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio Discord" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Тріо Discord" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Discord" - } - } - } - }, "Trio Docs" : { "localizations" : { "bg" : { @@ -321056,136 +320844,6 @@ } } }, - "Trio Facebook" : { - "localizations" : { - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Facebook – Trio" - } - }, - "he" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio Facebook" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio 페이스북" - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "ro" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio Facebook" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Трио Facebook" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "tr" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio Facebook" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Тріо Facebook" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio Facebook" - } - } - } - }, "Trio Features" : { "localizations" : { "bg" : { @@ -350733,6 +350391,9 @@ } } } + }, + "Your Nightscout address, passwords and device serial numbers are removed." : { + }, "Your phone or app is not enabled for NFC communications, which is needed to pair to libre2 sensors" : { "extractionState" : "manual", diff --git a/Trio/Sources/Logger/Contracts/LogRecord.swift b/Trio/Sources/Logger/Contracts/LogRecord.swift new file mode 100644 index 0000000000..8ecda19986 --- /dev/null +++ b/Trio/Sources/Logger/Contracts/LogRecord.swift @@ -0,0 +1,37 @@ +import Foundation +import os.log + +enum LogLevel: Int16, Codable, CaseIterable, Comparable { + case debug + case info + case notice + case warning + case error + case fault + + static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var osLogType: OSLogType { + switch self { + case .debug: return .debug + case .info: return .info + case .notice, + .warning: return .default + case .error: return .error + case .fault: return .fault + } + } +} + +struct LogRecord: Sendable, Codable, Equatable { + let timestamp: Date + let level: LogLevel + let category: String + let message: String + let file: String + let function: String + let line: UInt + let launchID: UUID +} diff --git a/Trio/Sources/Logger/Contracts/LogRetentionPolicy.swift b/Trio/Sources/Logger/Contracts/LogRetentionPolicy.swift new file mode 100644 index 0000000000..92e720bfd2 --- /dev/null +++ b/Trio/Sources/Logger/Contracts/LogRetentionPolicy.swift @@ -0,0 +1,13 @@ +import Foundation + +struct LogRetentionPolicy: Equatable { + var maxAge: TimeInterval + var persistsDebug: Bool + var maxRows: Int + + static let `default` = LogRetentionPolicy( + maxAge: 8 * 24 * 60 * 60, + persistsDebug: true, + maxRows: 250_000 + ) +} diff --git a/Trio/Sources/Logger/Contracts/LogSink.swift b/Trio/Sources/Logger/Contracts/LogSink.swift new file mode 100644 index 0000000000..db6cb301c0 --- /dev/null +++ b/Trio/Sources/Logger/Contracts/LogSink.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol LogSink: Sendable { + func accept(_ record: LogRecord) + func flush() async + func records(in window: DateInterval, minimumLevel: LogLevel) async -> [LogRecord] +} diff --git a/Trio/Sources/Logger/Core/InMemoryLogSink.swift b/Trio/Sources/Logger/Core/InMemoryLogSink.swift new file mode 100644 index 0000000000..dea1b0b560 --- /dev/null +++ b/Trio/Sources/Logger/Core/InMemoryLogSink.swift @@ -0,0 +1,39 @@ +import Foundation +import os + +final class InMemoryLogSink: LogSink { + private struct Ring: Sendable { + var buffer: [LogRecord] = [] + var next = 0 + } + + let capacity: Int + private let ring = OSAllocatedUnfairLock(initialState: Ring()) + + init(capacity: Int = 5000) { + self.capacity = max(1, capacity) + } + + func accept(_ record: LogRecord) { + ring.withLock { ring in + if ring.buffer.count < capacity { + ring.buffer.append(record) + } else { + ring.buffer[ring.next] = record + ring.next = (ring.next + 1) % capacity + } + } + } + + func flush() async {} + + func records(in window: DateInterval, minimumLevel: LogLevel) async -> [LogRecord] { + snapshot().filter { window.contains($0.timestamp) && $0.level >= minimumLevel } + } + + func snapshot() -> [LogRecord] { + ring.withLock { ring in + Array(ring.buffer[ring.next...] + ring.buffer[.. Void) { + state.withLock { $0.capture = sink } + defer { state.withLock { $0.capture = nil } } + body() + } + + static func log( + _ level: LogLevel, + _ category: Logger.Category, + _ message: String, + printToConsole: Bool = true, + file: String, + function: String, + line: UInt + ) { + // Strictly increasing within the process, so equal or backward wall clock steps keep call order + let (timestamp, sink) = state.withLock { state in + let timestamp = max(Date(), state.lastTimestamp.addingTimeInterval(0.000_001)) + state.lastTimestamp = timestamp + return (timestamp, state.capture ?? state.sink) + } + let record = LogRecord( + timestamp: timestamp, + level: level, + category: category.name, + message: message, + file: file.lastPathComponentFast, + function: function, + line: line, + launchID: LaunchID.current + ) + if printToConsole { + category.osLogger.log( + level: level.osLogType, + "\(record.file, privacy: .public) - \(function, privacy: .public) - \(line) \(level.linePrefix, privacy: .public)\(message, privacy: .public)" + ) + } + sink.accept(record) + } +} + +extension Logger.Category { + private static let osLoggers: [Logger.Category: os.Logger] = { + let subsystem = Bundle.main.bundleIdentifier ?? "org.nightscout.Trio" + return Dictionary(uniqueKeysWithValues: allCases.map { ($0, os.Logger(subsystem: subsystem, category: $0.name)) }) + }() + + var osLogger: os.Logger { + Self.osLoggers[self] ?? os.Logger() + } +} + +private extension LogLevel { + var linePrefix: String { + switch self { + case .debug: return "DEV: " + case .info: return "INFO: " + case .notice: return "NOTICE: " + case .warning: return "WARN: " + case .error: return "ERR: " + case .fault: return "FAULT: " + } + } +} + +private extension String { + var lastPathComponentFast: String { + guard let slash = lastIndex(of: "/") else { return self } + return String(self[index(after: slash)...]) + } +} diff --git a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift new file mode 100644 index 0000000000..dcbc7afc6a --- /dev/null +++ b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift @@ -0,0 +1,310 @@ +import CoreData +import Foundation +import LoopKit +import UIKit + +final class DeviceLogStore: @unchecked Sendable { + static let bufferLimit = 5000 + static let purgeStep: TimeInterval = 24 * 60 * 60 + + private struct PendingEntry { + let managerIdentifier: String + let deviceIdentifier: String? + let type: DeviceLogEntryType + let message: String + let date: Date + } + + private static let queuedDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private let queue = DispatchQueue(label: "DeviceLogStore.queue", qos: .utility) + private let storageURL: URL + private let maxAge: TimeInterval + private let notificationCenter: Foundation.NotificationCenter + private let preflight: (URL) -> Bool + private let isProtectedDataAvailable: @MainActor() -> Bool + private var observer: NSObjectProtocol? + + // Confined to `queue` + private var deviceLog: PersistentDeviceLog? + private var buffer: [PendingEntry] = [] + private var isCheckingProtectedData = false + private var retryAfterCheck = false + private var didMoveStoreAside = false + private var isPurging = false + private var afterPurge: [() -> Void] = [] + + init( + storageURL: URL = DeviceLogStore.defaultStorageURL, + maxAge: TimeInterval = LogRetentionPolicy.default.maxAge, + notificationCenter: Foundation.NotificationCenter = .default, + preflight: @escaping (URL) -> Bool = DeviceLogStore.preflight, + isProtectedDataAvailable: @escaping @MainActor() -> Bool = { UIApplication.shared.isProtectedDataAvailable } + ) { + self.storageURL = storageURL + self.maxAge = maxAge + self.notificationCenter = notificationCenter + self.preflight = preflight + self.isProtectedDataAvailable = isProtectedDataAvailable + + observer = notificationCenter.addObserver( + forName: UIApplication.protectedDataDidBecomeAvailableNotification, + object: nil, + queue: nil + ) { [weak self] _ in + guard let self else { return } + self.queue.async { self.openIfNeeded() } + } + queue.async { self.openIfNeeded() } + } + + deinit { + if let observer { + notificationCenter.removeObserver(observer) + } + } + + static var defaultStorageURL: URL { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + // Shares the legacy lowercase directory; a sibling "Logs" collides on case-insensitive volumes + .appendingPathComponent("logs", isDirectory: true) + .appendingPathComponent("DeviceLog.sqlite") + } + + func log( + managerIdentifier: String, + deviceIdentifier: String?, + type: DeviceLogEntryType, + message: String, + completion: ((Error?) -> Void)? = nil + ) { + let date = Date() + queue.async { + if let deviceLog = self.deviceLog { + deviceLog.log( + managerIdentifier: managerIdentifier, + deviceIdentifier: deviceIdentifier, + type: type, + message: message, + completion: completion + ) + return + } + self.buffer.append(PendingEntry( + managerIdentifier: managerIdentifier, + deviceIdentifier: deviceIdentifier, + type: type, + message: message, + date: date + )) + if self.buffer.count > Self.bufferLimit { + self.buffer.removeFirst(self.buffer.count - Self.bufferLimit) + } + completion?(nil) + } + } + + func entries(in window: DateInterval, managerIdentifier: String? = nil) async -> [StoredDeviceLogEntry] { + await withCheckedContinuation { continuation in + queue.async { + // Reading makes LoopKit delete every expired entry one by one, so let the purge finish first + guard !self.isPurging else { + self.afterPurge.append { self.fetchEntries(in: window, managerIdentifier: managerIdentifier, continuation) } + return + } + self.fetchEntries(in: window, managerIdentifier: managerIdentifier, continuation) + } + } + } + + private func fetchEntries( + in window: DateInterval, + managerIdentifier: String?, + _ continuation: CheckedContinuation<[StoredDeviceLogEntry], Never> + ) { + guard let deviceLog else { + continuation.resume(returning: buffer.compactMap { entry in + guard window.contains(entry.date), + managerIdentifier == nil || entry.managerIdentifier == managerIdentifier + else { return nil } + return StoredDeviceLogEntry( + type: entry.type, + managerIdentifier: entry.managerIdentifier, + deviceIdentifier: entry.deviceIdentifier, + message: entry.message, + timestamp: entry.date + ) + }) + return + } + deviceLog.getLogEntries(startDate: window.start, endDate: window.end) { result in + let entries = (try? result.get()) ?? [] + guard let managerIdentifier else { + continuation.resume(returning: entries) + return + } + continuation.resume(returning: entries.filter { $0.managerIdentifier == managerIdentifier }) + } + } + + private func openIfNeeded() { + guard deviceLog == nil else { return } + // An unlock during the check must not be lost + guard !isCheckingProtectedData else { + retryAfterCheck = true + return + } + try? FileManager.default.createDirectory( + at: storageURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if preflight(storageURL) { + open() + return + } + // A locked device also fails to load; only move the store aside once data is readable + isCheckingProtectedData = true + Task { @MainActor in + let available = self.isProtectedDataAvailable() + self.queue.async { + self.isCheckingProtectedData = false + self.recover(protectedDataAvailable: available) + if self.retryAfterCheck { + self.retryAfterCheck = false + self.openIfNeeded() + } + } + } + } + + private func recover(protectedDataAvailable: Bool) { + guard deviceLog == nil, protectedDataAvailable, !didMoveStoreAside else { return } + // The first attempt may have raced an unlock + if preflight(storageURL) { + open() + return + } + didMoveStoreAside = true + moveStoreAside() + if preflight(storageURL) { + open() + } else { + warning(.storage, "Device log store unavailable, keeping entries in memory") + } + } + + private func open() { + let deviceLog = PersistentDeviceLog(storageFile: storageURL, maxEntryAge: maxAge) + self.deviceLog = deviceLog + for entry in buffer { + deviceLog.log( + managerIdentifier: entry.managerIdentifier, + deviceIdentifier: entry.deviceIdentifier, + type: entry.type, + message: "[queued \(Self.queuedDateFormatter.string(from: entry.date))] \(entry.message)" + ) + } + buffer.removeAll() + FileManager.default.removeUnreadableStores(of: storageURL) + let cutoff = Date().addingTimeInterval(-maxAge) + if let oldest = Self.oldestEntryDate(in: storageURL), oldest < cutoff { + isPurging = true + purge(from: oldest, before: cutoff) + } + } + + // A day per step bounds the memory of each delete and lets queued logging run in between + private func purge(from start: Date, before cutoff: Date) { + let end = min(start.addingTimeInterval(Self.purgeStep), cutoff) + deviceLog?.purgeLogEntries(before: end) + guard end >= cutoff else { + queue.async { self.purge(from: end, before: cutoff) } + return + } + isPurging = false + let waiting = afterPurge + afterPurge.removeAll() + waiting.forEach { $0() } + } + + private func moveStoreAside() { + let stamp = Int(Date().timeIntervalSince1970) + for suffix in ["", "-wal", "-shm"] { + let source = URL(fileURLWithPath: storageURL.path + suffix) + guard FileManager.default.fileExists(atPath: source.path) else { continue } + let destination = URL(fileURLWithPath: source.path + ".unreadable-\(stamp)") + do { + try FileManager.default.moveItem(at: source, to: destination) + } catch { + warning(.storage, "Moving unreadable device log aside failed", error: error) + } + } + } + + static func preflight(_ url: URL) -> Bool { + guard FileManager.default.isWritableStore(at: url) else { return false } + return withContainer(at: url) { !$0.persistentStoreCoordinator.persistentStores.contains(where: \.isReadOnly) } ?? false + } + + private static func oldestEntryDate(in url: URL) -> Date? { + withContainer(at: url) { container -> Date? in + let context = container.newBackgroundContext() + return context.performAndWait { + let request = NSFetchRequest(entityName: "Entry") + request.resultType = .dictionaryResultType + request.propertiesToFetch = ["timestamp"] + request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)] + request.fetchLimit = 1 + return (try? context.fetch(request))?.first?["timestamp"] as? Date + } + } ?? nil + } + + private static func withContainer(at url: URL, _ body: (NSPersistentContainer) -> T) -> T? { + autoreleasepool { + guard let modelURL = Bundle(for: PersistentDeviceLog.self).url(forResource: "DeviceLog", withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) + else { return nil } + let container = NSPersistentContainer(name: "DeviceLog", managedObjectModel: model) + let description = NSPersistentStoreDescription(url: url) + description.shouldAddStoreAsynchronously = false + container.persistentStoreDescriptions = [description] + var loadError: Error? + container.loadPersistentStores { _, error in loadError = error } + let result = loadError == nil ? body(container) : nil + let coordinator = container.persistentStoreCoordinator + for store in coordinator.persistentStores { + try? coordinator.remove(store) + } + return result + } + } +} + +extension FileManager { + static let unreadableStoreMaxAge: TimeInterval = 7 * 24 * 60 * 60 + + // Stores moved aside as ".unreadable-" + func removeUnreadableStores(of url: URL) { + let directory = url.deletingLastPathComponent() + let names = (try? contentsOfDirectory(atPath: directory.path)) ?? [] + for name in names where name.hasPrefix(url.lastPathComponent) { + guard let marker = name.range(of: ".unreadable-", options: .backwards), + let stamp = TimeInterval(name[marker.upperBound...]), + Date().timeIntervalSince1970 - stamp > Self.unreadableStoreMaxAge + else { continue } + try? removeItem(at: directory.appendingPathComponent(name)) + } + } + + func isWritableStore(at url: URL) -> Bool { + guard isWritableFile(atPath: url.deletingLastPathComponent().path) else { return false } + return ["", "-wal", "-shm"].allSatisfy { suffix in + !fileExists(atPath: url.path + suffix) || isWritableFile(atPath: url.path + suffix) + } + } +} diff --git a/Trio/Sources/Logger/Error+Extensions.swift b/Trio/Sources/Logger/Error+Extensions.swift deleted file mode 100644 index 9da8577487..0000000000 --- a/Trio/Sources/Logger/Error+Extensions.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -extension Error { - var shouldReportNonFatalIssue: Bool { true } -} diff --git a/Trio/Sources/Logger/Export/LogExport.swift b/Trio/Sources/Logger/Export/LogExport.swift new file mode 100644 index 0000000000..f81295c7e1 --- /dev/null +++ b/Trio/Sources/Logger/Export/LogExport.swift @@ -0,0 +1,321 @@ +import CoreData +import Foundation +import LoopKit +import OSLog +import Swinject +import TidepoolServiceKit +import UIKit + +final class LogExport: Injectable { + enum Kind { + case all + case pump + case cgm + } + + @Injected() private var deviceLogStore: DeviceLogStore! + @Injected() private var deviceDataManager: DeviceDataManager! + @Injected() private var fetchGlucoseManager: FetchGlucoseManager! + @Injected() private var settingsManager: SettingsManager! + @Injected() private var keychain: Keychain! + @Injected() private var tidepoolManager: TidepoolManager! + + // Read-only view of BaseAlertHistoryStorage's persisted history + static let alertHistoryKey = "openaps.monitor.alertHistory.data" + + private static let numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = false + formatter.maximumFractionDigits = 2 + return formatter + }() + + private let appLog: () -> any LogSink + private let managers: (@MainActor() -> (pump: DeviceManager?, cgm: DeviceManager?))? + private let traceSubsystems: [String: [String]] + private let coreDataStack: CoreDataStack? + private let storedValues: SensitiveValues + private let requestWatchLogs: (() async -> Void)? + + init(resolver: Resolver) { + appLog = { TrioLogger.sink } + managers = nil + traceSubsystems = OSLogCapture.subsystems + coreDataStack = CoreDataStack.shared + storedValues = SensitiveValues() + requestWatchLogs = { await WatchLogIngest.shared.requestLatest() } + injectServices(resolver) + } + + init( + appLog: any LogSink, + deviceLogStore: DeviceLogStore, + pump: DeviceManager?, + cgm: DeviceManager?, + storedValues: SensitiveValues = SensitiveValues(), + coreDataStack: CoreDataStack? = nil, + requestWatchLogs: (() async -> Void)? = nil + ) { + self.appLog = { appLog } + managers = { (pump, cgm) } + traceSubsystems = [:] + self.coreDataStack = coreDataStack + self.storedValues = storedValues + self.requestWatchLogs = requestWatchLogs + self.deviceLogStore = deviceLogStore + } + + func makeFile(_ kind: Kind, days: Int) async throws -> URL { + let backgroundTask = await BackgroundTask(name: "Share Logs") + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let started = Date() + do { + let file = try await writeLog(kind, days: days, into: directory.appendingPathComponent("log", isDirectory: true)) + try Task.checkCancellation() + let written = Date() + let archive = try Self.zip(file, in: directory) + let size = (try? archive.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + info( + .storage, + "Log file prepared in \(String(format: "%.1f", Date().timeIntervalSince(started))) s, zipped in \(String(format: "%.1f", Date().timeIntervalSince(written))) s, \(size / 1024) KB" + ) + await backgroundTask.end() + return archive + } catch { + try? FileManager.default.removeItem(at: directory) + await backgroundTask.end() + throw error + } + } + + static func discard(_ file: URL) { + // Each file gets its own temporary directory + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + } + + // NSFileCoordinator zips a directory for upload; the folder name becomes the zip's top level + private static func zip(_ file: URL, in directory: URL) throws -> URL { + let name = file.deletingPathExtension().lastPathComponent + let folder = directory.appendingPathComponent(name, isDirectory: true) + try FileManager.default.moveItem(at: file.deletingLastPathComponent(), to: folder) + let archive = directory.appendingPathComponent("\(name).zip") + var coordinationError: NSError? + var copyError: Error? + NSFileCoordinator().coordinate(readingItemAt: folder, options: .forUploading, error: &coordinationError) { zipped in + do { + try FileManager.default.copyItem(at: zipped, to: archive) + } catch { + copyError = error + } + } + if let error = coordinationError ?? copyError { throw error } + try? FileManager.default.removeItem(at: folder) + return archive + } + + func writeLog(_ kind: Kind, days: Int, into directory: URL) async throws -> URL { + let now = Date() + let window = DateInterval(start: now.addingTimeInterval(-Double(max(days, 1)) * 24 * 60 * 60), end: now) + let (pump, cgm) = await deviceSnapshots() + + var header = LogFileHeader(kind: kind, days: days, created: now, pump: pump, cgm: cgm) + if kind == .all { + if let settingsManager { + header.settings = LogFileHeader.settingsLines( + settingsManager.settings, + settingsManager.preferences, + settingsManager.pumpSettings + ) + } + header.alerts = Self.alerts(in: window) + } + + // Later assignments win when one manager is both pump and CGM + var deviceLabels: [String: String] = [:] + if kind != .pump, let cgm { deviceLabels[cgm.identifier] = "CGM" } + if kind != .cgm, let pump { deviceLabels[pump.identifier] = "Pump" } + var traceLabels: [String: String] = [:] + for (identifier, label) in deviceLabels { + for subsystem in traceSubsystems[identifier] ?? [] { + traceLabels[subsystem] = label + } + } + let filter: String? = kind == .all ? nil : deviceLabels.keys.first + let appLog = kind == .all ? self.appLog() : nil + if appLog != nil { + await requestWatchLogs?() + } + await appLog?.flush() + let traceStore = traceLabels.isEmpty ? nil : try? OSLogStore(scope: .currentProcessIdentifier) + let redactor = Redactor(exactValues: try await sensitiveValues(in: window)) + let algorithmStack = kind == .all ? coreDataStack : nil + + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("\(header.title) \(Self.fileStamp(now)).txt") + try await LogFileWriter.write(header: header.text, window: window, to: url, redactor: redactor) { slice in + var lines: [LogLine] = [] + if let appLog { + lines += await appLog.records(in: slice, minimumLevel: .debug).map(Self.line) + } + if let deviceLogStore, kind == .all || filter != nil { + lines += await deviceLogStore.entries(in: slice, managerIdentifier: filter).map { + Self.line(for: $0, source: deviceLabels[$0.managerIdentifier] ?? $0.managerIdentifier) + } + } + if let traceStore { + lines += OSLogCapture.lines(from: traceStore, sources: traceLabels, in: slice) + } + if let algorithmStack { + lines += await Self.algorithmLines(in: slice, from: algorithmStack) + } + return lines + } + return url + } + + @MainActor private func currentManagers() -> (pump: DeviceManager?, cgm: DeviceManager?) { + if let managers { return managers() } + return ( + deviceDataManager?.pumpManager, + fetchGlucoseManager?.cgmGlucoseSourceType == .plugin ? fetchGlucoseManager?.cgmManager : nil + ) + } + + @MainActor private func deviceSnapshots() -> (LogFileHeader.Device?, LogFileHeader.Device?) { + let (pump, cgm) = currentManagers() + return (pump.map(Self.snapshot), cgm.map(Self.snapshot)) + } + + private static func snapshot(_ manager: DeviceManager) -> LogFileHeader.Device { + LogFileHeader.Device( + title: manager.localizedTitle, + identifier: manager.pluginIdentifier, + description: String(reflecting: manager) + ) + } + + static func line(for record: LogRecord) -> LogLine { + let level: String + switch record.level { + case .warning: level = "WARNING: " + case .error: level = "ERROR: " + case .fault: level = "FAULT: " + case .debug, + .info, + .notice: level = "" + } + if record.category.hasPrefix(WatchLogIngest.categoryPrefix) { + let category = record.category.dropFirst(WatchLogIngest.categoryPrefix.count) + return LogLine(date: record.timestamp, source: "Watch", text: "\(level)[\(category)] \(record.message)") + } + return LogLine(date: record.timestamp, source: "Trio", text: "\(level)[\(record.category)] \(record.message)") + } + + static func line(for entry: StoredDeviceLogEntry, source: String) -> LogLine { + LogLine(date: entry.timestamp, source: source, text: "[\(entry.type.rawValue)] \(entry.message)") + } + + static func algorithmLines(in window: DateInterval, from stack: CoreDataStack) async -> [LogLine] { + let context = stack.newTaskContext() + context.name = "logExportDeterminations" + do { + return try await context.perform { + let request: NSFetchRequest = OrefDetermination.fetchRequest() + request.predicate = NSPredicate( + format: "deliverAt >= %@ AND deliverAt <= %@", + window.start as NSDate, + window.end as NSDate + ) + request.fetchBatchSize = 100 + return try context.fetch(request).compactMap { determination in + determination.deliverAt.map { LogLine(date: $0, source: "Algorithm", text: algorithmText(determination)) } + } + } + } catch { + warning(.storage, "Reading determinations for the log file failed", error: error) + return [] + } + } + + private static func algorithmText(_ determination: OrefDetermination) -> String { + func number(_ value: NSDecimalNumber?) -> String? { + value.flatMap { numberFormatter.string(from: $0) } + } + var parts = [determination.enacted ? "enacted" : "suggested"] + if let glucose = number(determination.glucose) { parts.append("BG \(glucose)") } + if let iob = number(determination.iob) { parts.append("IOB \(iob)") } + parts.append("COB \(determination.cob)") + if let rate = number(determination.rate) { + parts.append(number(determination.duration).map { "rate \(rate)/\($0)m" } ?? "rate \(rate)") + } + if let smb = number(determination.smbToDeliver) { parts.append("SMB \(smb)") } + if let eventual = number(determination.eventualBG) { parts.append("eventual \(eventual)") } + if let reason = determination.reason, !reason.isEmpty { parts.append(reason) } + return parts.joined(separator: " · ") + } + + private static func alerts(in window: DateInterval) -> [AlertEntry] { + guard let data = UserDefaults.standard.data(forKey: alertHistoryKey), + let alerts = try? JSONCoding.decoder.decode([AlertEntry].self, from: data) + else { return [] } + return alerts.filter { window.contains($0.issuedDate) } + } + + private func sensitiveValues(in window: DateInterval) async throws -> [Redactor.ExactValue] { + var values = await currentSensitiveValues() + // Devices replaced during the window are only known from the device log + if let deviceLogStore { + for slice in LogFileWriter.slices(of: window) { + try Task.checkCancellation() + for entry in await deviceLogStore.entries(in: slice) { + values.add(entry.deviceIdentifier, .device) + } + } + } + return values.values + } + + @MainActor private func currentSensitiveValues() -> SensitiveValues { + var values = storedValues + if let keychain { + values.addNightscout( + url: keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey), + secret: keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) + ) + values.addTrioIdentifiers() + } + values.add(tidepool: tidepoolManager?.getTidepoolServiceUI() as? TidepoolService) + let (pump, cgm) = currentManagers() + for manager in [pump, cgm].compactMap({ $0 }) { + values.add(manager) + } + return values + } + + private static func fileStamp(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HHmm" + return formatter.string(from: date) + } +} + +private extension LogExport { + @MainActor final class BackgroundTask { + private var identifier: UIBackgroundTaskIdentifier = .invalid + + init(name: String) { + identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in + MainActor.assumeIsolated { self?.end() } + } + } + + func end() { + guard identifier != .invalid else { return } + UIApplication.shared.endBackgroundTask(identifier) + identifier = .invalid + } + } +} diff --git a/Trio/Sources/Logger/Export/LogFileHeader.swift b/Trio/Sources/Logger/Export/LogFileHeader.swift new file mode 100644 index 0000000000..aa2c0a859b --- /dev/null +++ b/Trio/Sources/Logger/Export/LogFileHeader.swift @@ -0,0 +1,162 @@ +import Foundation + +struct LogFileHeader { + struct Device { + let title: String + let identifier: String + let description: String + } + + var kind: LogExport.Kind + var days: Int + var created: Date + var pump: Device? + var cgm: Device? + var settings: [String] = [] + var alerts: [AlertEntry] = [] + + private static let alertLimit = 200 + + var title: String { + switch kind { + case .all: return "Trio Logs" + case .pump: return "Trio Pump Logs" + case .cgm: return "Trio CGM Logs" + } + } + + var text: String { + let formatter = LogFileWriter.timestampFormatter(milliseconds: false) + var lines = [ + title, + "Created: \(formatter.string(from: created))", + "Covers: last \(days == 1 ? "24 hours" : "\(days) days")", + "Personal details such as your Nightscout address, passwords and device serial numbers have been removed." + ] + lines += Self.section("App", Self.app()) + lines += Self.section("Phone", Self.phone()) + if kind != .cgm { + lines += Self.section("Pump", Self.lines(for: pump)) + } + if kind != .pump { + lines += Self.section("CGM", Self.lines(for: cgm)) + } + if kind == .all { + lines += Self.section("Settings", settings.isEmpty ? ["Not available"] : settings) + lines += Self.section("Alerts", alertLines(formatter: formatter)) + } + return lines.joined(separator: "\n") + "\n" + } + + private static func section(_ name: String, _ lines: [String]) -> [String] { + ["", "== \(name) =="] + lines + } + + private static func app() -> [String] { + let bundle = Bundle.main + let details = BuildDetails.shared + let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let build = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "unknown" + var lines = [ + "Trio: \(version) (\(build)), dev version \(bundle.appDevVersion ?? "unknown")", + "Branch: \(details.trioBranch)", + "Commit: \(details.trioCommitSHA)", + "Built: \(details.buildDateString ?? "unknown")" + ] + lines += details.submodules.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value.branch) \($0.value.commitSHA)" } + return lines + } + + private static func phone() -> [String] { + let processInfo = ProcessInfo.processInfo + return [ + "iOS: \(processInfo.operatingSystemVersionString)", + "Model: \(modelIdentifier)", + "Low Power Mode: \(processInfo.isLowPowerModeEnabled ? "on" : "off")", + "Free storage: \(freeStorage)" + ] + } + + private static func lines(for device: Device?) -> [String] { + guard let device else { return ["Not set up"] } + return ["Name: \(device.title)", "Plugin: \(device.identifier)", device.description] + } + + private func alertLines(formatter: DateFormatter) -> [String] { + let newestFirst = alerts.sorted { $0.issuedDate > $1.issuedDate } + guard !newestFirst.isEmpty else { return ["None"] } + var lines = newestFirst.prefix(Self.alertLimit).map { alert in + var parts = [formatter.string(from: alert.issuedDate), "\(alert.managerIdentifier)/\(alert.alertIdentifier)"] + let content = [alert.contentTitle, alert.contentBody].compactMap { $0 }.joined(separator: ": ") + if !content.isEmpty { + parts.append(content.replacingOccurrences(of: "\n", with: " ")) + } + parts.append(alert.acknowledgedDate.map { "acknowledged \(formatter.string(from: $0))" } ?? "not acknowledged") + if let error = alert.errorMessage { + parts.append("error: \(error)") + } + return parts.joined(separator: " · ") + } + if newestFirst.count > Self.alertLimit { + lines.append("\(newestFirst.count - Self.alertLimit) older alerts left out") + } + return lines + } + + private static var modelIdentifier: String { + if let simulated = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { + return "\(simulated) (simulator)" + } + var systemInfo = utsname() + uname(&systemInfo) + return withUnsafeBytes(of: &systemInfo.machine) { bytes in + String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) + } + } + + private static var freeStorage: String { + let url = URL(fileURLWithPath: NSHomeDirectory()) + guard let capacity = try? url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + .volumeAvailableCapacityForImportantUsage + else { return "unknown" } + return ByteCountFormatter.string(fromByteCount: capacity, countStyle: .file) + } +} + +extension LogFileHeader { + // One `group.key: value` line per encoded setting + static func settingsLines(_ settings: TrioSettings, _ preferences: Preferences, _ pumpSettings: PumpSettings) -> [String] { + let encoder = JSONCoding.encoder + let groups: [(String, [String: Any]?)] = [ + ("settings", encode(settings, encoder)), + ("preferences", encode(preferences, encoder)), + ("pumpSettings", encode(pumpSettings, encoder)) + ] + var lines: [String] = [] + for case let (group, values?) in groups { + lines += values.keys.sorted().map { "\(group).\($0): \(describe(values[$0]))" } + } + return lines + } + + private static func encode(_ value: T, _ encoder: JSONEncoder) -> [String: Any]? { + guard let data = try? encoder.encode(value) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + + private static func describe(_ value: Any?) -> String { + switch value { + case let number as NSNumber where CFGetTypeID(number) == CFBooleanGetTypeID(): + return number.boolValue ? "true" : "false" + case let string as String: + return string + case let number as NSNumber: + return number.stringValue + case let .some(value) where JSONSerialization.isValidJSONObject(value): + let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .withoutEscapingSlashes]) + return data.map { String(decoding: $0, as: UTF8.self) } ?? "-" + default: + return "-" + } + } +} diff --git a/Trio/Sources/Logger/Export/LogFileWriter.swift b/Trio/Sources/Logger/Export/LogFileWriter.swift new file mode 100644 index 0000000000..e4ff01bc26 --- /dev/null +++ b/Trio/Sources/Logger/Export/LogFileWriter.swift @@ -0,0 +1,78 @@ +import Foundation + +struct LogLine { + let date: Date + let source: String + let text: String +} + +enum LogFileWriter { + static let sliceDuration: TimeInterval = 6 * 60 * 60 + private static let bufferLimit = 64 * 1024 + + static func timestampFormatter(milliseconds: Bool = true) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = milliseconds ? "yyyy-MM-dd HH:mm:ss.SSS Z" : "yyyy-MM-dd HH:mm:ss Z" + return formatter + } + + static func slices(of window: DateInterval) -> [DateInterval] { + var slices: [DateInterval] = [] + var start = window.start + repeat { + let end = min(start.addingTimeInterval(sliceDuration), window.end) + slices.append(DateInterval(start: start, end: end)) + start = end + } while start < window.end + return slices + } + + static func write( + header: String, + window: DateInterval, + to url: URL, + redactor: Redactor, + lines: (DateInterval) async -> [LogLine] + ) async throws { + guard FileManager.default.createFile(atPath: url.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: url.path]) + } + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + let formatter = timestampFormatter() + + var buffer = header + "\n== Log ==\n" + func flush() throws { + guard !buffer.isEmpty else { return } + // Redact in bulk; per-line regex passes are too slow for 7 days of debug lines + try handle.write(contentsOf: Data(redactor.redact(buffer).utf8)) + buffer.removeAll(keepingCapacity: true) + } + + for slice in slices(of: window) { + try Task.checkCancellation() + let isLast = slice.end >= window.end + let sorted = await lines(slice).enumerated() + .filter { contains(slice, $0.element.date, isLast: isLast) } + .sorted { ($0.element.date, $0.offset) < ($1.element.date, $1.offset) } + for (_, line) in sorted { + buffer += format(line, formatter: formatter) + if buffer.utf8.count >= bufferLimit { + try flush() + } + } + try flush() + } + } + + // Sources may return their inclusive slice end; it belongs to the next slice + private static func contains(_ slice: DateInterval, _ date: Date, isLast: Bool) -> Bool { + date >= slice.start && (date < slice.end || isLast && date == slice.end) + } + + static func format(_ line: LogLine, formatter: DateFormatter) -> String { + let text = line.text.replacingOccurrences(of: "\n", with: "\n ") + return "\(formatter.string(from: line.date)) \(line.source) \(text)\n" + } +} diff --git a/Trio/Sources/Logger/Export/OSLogCapture.swift b/Trio/Sources/Logger/Export/OSLogCapture.swift new file mode 100644 index 0000000000..276247a2e7 --- /dev/null +++ b/Trio/Sources/Logger/Export/OSLogCapture.swift @@ -0,0 +1,55 @@ +import Darwin +import Foundation +import OSLog + +enum OSLogCapture { + static let processStartDate: Date? = { + var info = kinfo_proc() + var size = MemoryLayout.stride + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0 else { return nil } + let start = info.kp_proc.p_starttime + return Date(timeIntervalSince1970: TimeInterval(start.tv_sec) + TimeInterval(start.tv_usec) / 1_000_000) + }() + + // Kit subsystems per pluginIdentifier; Trio's own and LoopKit's mirror duplicate lines already in the file + static let subsystems: [String: [String]] = [ + "Omni": ["com.loopkit.OmnipodKit", "OmnipodKitPlugin", "com.ps2.rileylink"], + "Minimed500": ["com.loopkit.MinimedKit", "com.randallknutson.OmniBLE", "com.ps2.rileylink"], + "Dana": ["com.randallknutson.DanaKit"], + "Medtrum": ["org.nightscout.MedtrumKit"], + "EversenseKit": ["org.nightscout.EversenseKit"], + "AccuChek": ["com.bastiaanv.AccuChekKit"], + "DexG5Transmitter": ["com.loopkit.CGMBLEKit"], + "DexG6Transmitter": ["com.loopkit.CGMBLEKit"], + "G7CGMManager": ["org.loopkit.G7SensorKit", "org.loopkit.G7SensorKitUI", "org.loopkit.G7SensorPlugin"], + "LibreTransmitterManagerV3": ["com.loopkit.LibreTransmitter", "com.loopkit.libre"], + "LibreLoopCGMManager": ["org.loopkit.LibreLoop"], + "SyaiCGMManager": ["org.loopkit.SyaiKit"], + "DexShareClient": ["com.loopkit.ShareClient"] + ] + + static func lines(from store: OSLogStore, sources: [String: String], in window: DateInterval) -> [LogLine] { + guard !sources.isEmpty, window.end >= processStartDate ?? .distantPast else { return [] } + do { + let entries = try store.getEntries( + at: store.position(date: window.start), + matching: NSPredicate(format: "subsystem IN %@", Array(sources.keys)) + ) + var lines: [LogLine] = [] + for case let entry as OSLogEntryLog in entries { + if entry.date > window.end { break } + guard let source = sources[entry.subsystem] else { continue } + lines.append(LogLine( + date: entry.date, + source: source, + text: "[trace] [\(entry.category)] \(entry.composedMessage)" + )) + } + return lines + } catch { + warning(.lifecycle, "Reading Bluetooth traces failed", error: error) + return [] + } + } +} diff --git a/Trio/Sources/Logger/Export/Redactor.swift b/Trio/Sources/Logger/Export/Redactor.swift new file mode 100644 index 0000000000..732289bd36 --- /dev/null +++ b/Trio/Sources/Logger/Export/Redactor.swift @@ -0,0 +1,203 @@ +import Foundation + +final class Redactor { + enum Kind: String { + case nightscoutURL = "nightscout-url" + case nightscoutHost = "nightscout-host" + case apiSecret = "api-secret" + case remoteSecret = "remote-secret" + case pushToken = "push-token" + case installID = "install-id" + case secret + case email + case account + case pumpSerial = "pump-serial" + case cgmSerial = "cgm-serial" + case device + case remoteUser = "remote-user" + + // One value per install; the others are numbered per file + var isSingular: Bool { + switch self { + case .apiSecret, + .installID, + .nightscoutHost, + .nightscoutURL, + .pushToken, + .remoteSecret: return true + default: return false + } + } + } + + struct ExactValue: Equatable { + let value: String + let kind: Kind + } + + private struct Needle { + let bytes: [UInt8] + let kind: Kind + let isWholeToken: Bool + } + + private struct Match { + let start: Int + let end: Int + let needle: Int + } + + private struct InTransitRule { + let marker: String + let regex: NSRegularExpression + let kind: Kind + } + + static let minimumLength = 4 + + // Values only ever seen in transit, never stored on this phone + private static let inTransitRules: [InTransitRule] = [ + ("Command Type:", #"\bUser: ([^.\n]{1,64})\. Command Type:"#, Kind.remoteUser), + ("Fleet secret:", #"Fleet secret: ([^\n]+)"#, Kind.secret) + ].compactMap { marker, pattern, kind in + (try? NSRegularExpression(pattern: pattern)).map { InTransitRule(marker: marker, regex: $0, kind: kind) } + } + + private let needles: [Needle] + private var placeholders: [String: String] = [:] + private var counters: [Kind: Int] = [:] + + init(exactValues: [ExactValue]) { + var seen = Set<[UInt8]>() + needles = exactValues + .map { (bytes: Array($0.value.trimmingCharacters(in: .whitespacesAndNewlines).utf8).map(Self.fold), kind: $0.kind) } + .filter { $0.bytes.count >= Self.minimumLength && seen.insert($0.bytes).inserted } + .sorted { $0.bytes.count > $1.bytes.count } + .map { + Needle(bytes: $0.bytes, kind: $0.kind, isWholeToken: $0.bytes.allSatisfy(Self.isHexDigit)) + } + } + + func redact(_ text: String) -> String { + var source = text + // The standard library lowercases in bulk; per-byte folding is slow in unoptimized builds + var lowered = text.lowercased() + let (exact, markers) = source.withUTF8 { source in + lowered.withUTF8 { lowered in + guard lowered.count == source.count else { + return Self.fold(source).withUnsafeBufferPointer { scan(source, folded: $0) } + } + return scan(source, folded: lowered) + } + } + var result = exact ?? text + for (rule, present) in zip(Self.inTransitRules, markers) where present { + result = apply(rule, to: result) + } + return result + } + + private func scan(_ source: UnsafeBufferPointer, folded: UnsafeBufferPointer) -> (String?, [Bool]) { + let markers = Self.inTransitRules.map { rule in + Array(rule.marker.lowercased().utf8).withUnsafeBytes { + memmem(folded.baseAddress, folded.count, $0.baseAddress, $0.count) != nil + } + } + let matches = self.matches(in: folded, source: source) + guard !matches.isEmpty else { return (nil, markers) } + + var output: [UInt8] = [] + output.reserveCapacity(source.count) + var cursor = 0 + for match in matches { + let needle = needles[match.needle] + output.append(contentsOf: UnsafeBufferPointer(rebasing: source[cursor ..< match.start])) + output.append(contentsOf: placeholder(for: String(decoding: needle.bytes, as: UTF8.self), kind: needle.kind).utf8) + cursor = match.end + } + output.append(contentsOf: UnsafeBufferPointer(rebasing: source[cursor...])) + return (String(decoding: output, as: UTF8.self), markers) + } + + // Hex and decimal values match only as whole tokens so they never split a packet dump or a number + private func matches(in folded: UnsafeBufferPointer, source: UnsafeBufferPointer) -> [Match] { + guard let textStart = folded.baseAddress else { return [] } + var found: [Match] = [] + for (index, needle) in needles.enumerated() { + needle.bytes.withUnsafeBufferPointer { pattern in + guard let patternStart = pattern.baseAddress else { return } + var offset = 0 + while offset + pattern.count <= folded.count, + let hit = memmem(textStart + offset, folded.count - offset, patternStart, pattern.count) + { + let start = UnsafeRawPointer(hit) - UnsafeRawPointer(textStart) + let end = start + pattern.count + let opens = start == 0 || !Self.isAlphanumeric(source[start - 1]) + let closes = end == source.count || !Self.isAlphanumeric(source[end]) + // Lowercasing may shift non-ASCII text, so confirm the hit against the original bytes + let same = (0 ..< pattern.count).allSatisfy { Self.fold(source[start + $0]) == pattern[$0] } + if same, !needle.isWholeToken || opens && closes { + found.append(Match(start: start, end: end, needle: index)) + } + offset = start + 1 + } + } + } + + // Needles are sorted longest first, so the longest value wins at a shared start + found.sort { ($0.start, $0.needle) < ($1.start, $1.needle) } + var kept: [Match] = [] + var cursor = 0 + for match in found where match.start >= cursor { + kept.append(match) + cursor = match.end + } + return kept + } + + private func apply(_ rule: InTransitRule, to text: String) -> String { + let source = text as NSString + var output = "" + var cursor = 0 + for match in rule.regex.matches(in: text, range: NSRange(location: 0, length: source.length)) { + let range = match.range(at: 1) + output += source.substring(with: NSRange(location: cursor, length: range.location - cursor)) + output += placeholder(for: source.substring(with: range), kind: rule.kind) + cursor = range.location + range.length + } + output += source.substring(from: cursor) + return output + } + + private func placeholder(for value: String, kind: Kind) -> String { + if kind.isSingular { return "<\(kind.rawValue)>" } + let key = "\(kind.rawValue)|\(value.lowercased())" + if let existing = placeholders[key] { return existing } + let next = (counters[kind] ?? 0) + 1 + counters[kind] = next + let placeholder = "<\(kind.rawValue)-\(next)>" + placeholders[key] = placeholder + return placeholder + } + + private static func fold(_ bytes: UnsafeBufferPointer) -> [UInt8] { + [UInt8](unsafeUninitializedCapacity: bytes.count) { buffer, count in + for index in 0 ..< bytes.count { + buffer[index] = fold(bytes[index]) + } + count = bytes.count + } + } + + private static func fold(_ byte: UInt8) -> UInt8 { + byte &- 0x41 < 26 ? byte | 0x20 : byte + } + + private static func isHexDigit(_ byte: UInt8) -> Bool { + (0x30 ... 0x39).contains(byte) || (0x61 ... 0x66).contains(byte) + } + + private static func isAlphanumeric(_ byte: UInt8) -> Bool { + (0x30 ... 0x39).contains(byte) || (0x41 ... 0x5A).contains(byte) || (0x61 ... 0x7A).contains(byte) + } +} diff --git a/Trio/Sources/Logger/Export/SensitiveValues.swift b/Trio/Sources/Logger/Export/SensitiveValues.swift new file mode 100644 index 0000000000..b18be256fe --- /dev/null +++ b/Trio/Sources/Logger/Export/SensitiveValues.swift @@ -0,0 +1,216 @@ +import CGMBLEKit +import CryptoKit +import Foundation +import G7SensorKit +import LibreTransmitter +import LoopKit +import SyaiKit +import TidepoolServiceKit + +struct SensitiveValues { + private(set) var values: [Redactor.ExactValue] = [] + + private typealias Field = (path: [String], kind: Redactor.Kind) + + private static let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) + + // rawState paths per pluginIdentifier; kits persist these values verbatim + private static let fields: [String: [Field]] = [ + "Omni": ["podState", "previousPodState"].flatMap { pod in + [ + ([pod, "lotNo"], .pumpSerial), + ([pod, "lotSeq"], .pumpSerial), + ([pod, "lot"], .pumpSerial), + ([pod, "tid"], .pumpSerial), + ([pod, "bleIdentifier"], .device), + ([pod, "ltk"], .secret), + ([pod, "signingKey"], .secret) + ] + } + [(["rileyLinkConnectionManagerState", "autoConnectIDs"], .device)], + "Minimed500": [ + (["pumpID"], .pumpSerial), + (["rileyLinkConnectionManagerState", "autoConnectIDs"], .device) + ], + "Dana": [ + (["deviceName"], .pumpSerial), + (["bleIdentifier"], .device), + (["ble5Keys"], .secret), + (["pairingKey"], .secret), + (["randomPairingKey"], .secret) + ], + "Medtrum": [ + (["pumpSN"], .pumpSerial), + (["sessionToken"], .secret), + (["backupSessionToken"], .secret), + (["peripheralIdentifier"], .device) + ], + "DexG5Transmitter": [(["transmitterID"], .cgmSerial)], + "DexG6Transmitter": [(["transmitterID"], .cgmSerial)], + "G7CGMManager": [ + (["sensorID"], .cgmSerial), + (["previousSensor", "sensorID"], .cgmSerial), + (["previousSensor", "serialNumber"], .cgmSerial), + (["peripheralIdentifier"], .device), + (["sharedKey"], .secret), + (["shareUsername"], .account) + ], + "SyaiCGMManager": [ + (["sensors", "activeMAC"], .device), + (["sensors", "pendingBind", "mac"], .device) + ], + "LibreLoopCGMManager": [ + (["sensorSerial"], .cgmSerial), + (["bleAddress"], .device), + (["peripheralID"], .device), + (["blePIN"], .secret) + ], + "EversenseKit": [ + (["bleNameString"], .cgmSerial), + (["transmitterId"], .cgmSerial), + (["sensorId"], .cgmSerial), + (["peripheralIdentifier"], .device), + (["username"], .account), + (["password"], .secret), + (["accessToken"], .secret), + (["clientIdV2"], .secret) + ], + "AccuChek": [ + (["deviceName"], .cgmSerial), + (["previousDeviceName"], .cgmSerial), + (["sensorInfo", "serialNumber"], .cgmSerial), + (["pinCode"], .secret), + (["aesKey"], .secret), + (["accessToken"], .secret), + (["refreshToken"], .secret) + ] + ] + + mutating func add(_ value: String?, _ kind: Redactor.Kind) { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + value.count >= Redactor.minimumLength + else { return } + values.append(Redactor.ExactValue(value: value, kind: kind)) + // Query strings and form bodies carry values percent-encoded, with spaces as %20 or + + if let encoded = value.addingPercentEncoding(withAllowedCharacters: Self.unreserved), encoded != value { + values.append(Redactor.ExactValue(value: encoded, kind: kind)) + values.append(Redactor.ExactValue(value: encoded.replacingOccurrences(of: "%20", with: "+"), kind: kind)) + } + } + + mutating func addNightscout(url: String?, secret: String?) { + if let url = url?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty { + // Without the trailing slash, so request paths keep theirs + add(url.hasSuffix("/") ? String(url.dropLast()) : url, .nightscoutURL) + add(URL(string: url)?.host, .nightscoutHost) + } + if let secret, !secret.isEmpty { + add(secret, .apiSecret) + // Nightscout requests carry the secret's SHA-1 in the api-secret header + add(Insecure.SHA1.hash(data: Data(secret.utf8)).map { String(format: "%02x", $0) }.joined(), .apiSecret) + } + } + + mutating func addTrioIdentifiers(defaults: UserDefaults = .standard) { + add(defaults.string(forKey: "trioRemoteControlSharedSecret"), .remoteSecret) + add(defaults.string(forKey: "deviceToken"), .pushToken) + add(PropertyPersistentFlags.shared.telemetryInstallId, .installID) + } + + mutating func add(tidepool service: TidepoolService?) { + guard let session = service?.session else { return } + add(session.accessToken, .secret) + add(session.refreshToken, .secret) + add(session.username, .email) + add(session.userId, .account) + } + + mutating func add(_ manager: DeviceManager) { + add(rawState: manager.rawState, pluginIdentifier: manager.pluginIdentifier) + addProperties(of: manager) + } + + mutating func add(rawState state: [String: Any], pluginIdentifier: String) { + for field in Self.fields[pluginIdentifier] ?? [] { + for value in Self.strings(at: field.path, in: state) { + add(value, field.kind) + } + } + switch pluginIdentifier { + case "Omni": + // Addresses print as 8 hex digits; packet dumps embed them glued, which whole-token matching leaves alone + for pod in ["podState", "previousPodState"] { + add(Self.hex32((state[pod] as? [String: Any])?["address"]), .device) + } + add(Self.hex32(state["controllerId"]), .device) + add(Self.hex32(state["podId"]), .device) + case "Medtrum": + // Advertisements carry the serial byte-reversed + add((state["pumpSN"] as? Data).map { Self.hex(Data($0.reversed())) }, .pumpSerial) + case "G7CGMManager": + if let code = (state["previousSensor"] as? [String: Any])?["pairingCode"] as? String { + // Four digits collide with numbers, so match the code only as debugDescription prints it + add("\"pairingCode\": \"\(code)\"", .secret) + } + case "LibreLoopCGMManager": + // The sensor advertises its MAC without colons; receiver IDs log as a little-endian UInt32 + add((state["bleAddress"] as? String)?.replacingOccurrences(of: ":", with: "").uppercased(), .device) + add((state["receiverID"] as? Data).map { Self.hex(Data($0.reversed())) }, .device) + default: + break + } + } + + private mutating func addProperties(of manager: DeviceManager) { + switch manager { + case let manager as TransmitterManager: + let credentials = manager.shareManager.shareService.credentialValues + add(credentials.first ?? nil, .account) + add(credentials.dropFirst().first ?? nil, .secret) + case let manager as G7CGMManager: + add(manager.state.transmitterVersion?.serialNumberString, .cgmSerial) + case is SyaiCGMManager: + // The account lives in the kit's own keychain item, not in rawState + guard let account = SyaiKeychain.loadAccount() else { return } + add(account.email, .email) + add(account.userId, .account) + for secret in [account.password, account.refreshToken, account.accessToken, account.glucoseSecretKey] { + add(secret, .secret) + } + case let manager as LibreTransmitterManagerV3: + add(UserDefaults.standard.currentSensor, .cgmSerial) + add(UserDefaults.standard.preSelectedDevice, .device) + add(manager.sensorInfoObservable.sensorSerial, .cgmSerial) + add(manager.transmitterInfoObservable.transmitterMacAddress, .device) + default: + guard manager.pluginIdentifier == "EversenseKit", + let data = try? KeychainManager().getGenericPasswordForServiceAsData("com.bastiaanv.Eversensekit"), + let credentials = try? JSONSerialization.jsonObject(with: data) as? [String: String] + else { return } + add(credentials["username"], .account) + add(credentials["password"], .secret) + } + } + + private static func strings(at path: [String], in state: [String: Any]) -> [String] { + guard let key = path.first else { return [] } + let value = state[key] + if path.count > 1 { + return (value as? [String: Any]).map { strings(at: Array(path.dropFirst()), in: $0) } ?? [] + } + switch value { + case let string as String: return [string] + case let strings as [String]: return strings + case let data as Data: return [hex(data)] + case let number as NSNumber: return [number.stringValue] + default: return [] + } + } + + private static func hex32(_ value: Any?) -> String? { + (value as? NSNumber).map { String(format: "%08X", $0.uint32Value) } + } + + private static func hex(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Trio/Sources/Logger/IssueReporter/CollectionIssueReporter.swift b/Trio/Sources/Logger/IssueReporter/CollectionIssueReporter.swift deleted file mode 100644 index 9b7e553efb..0000000000 --- a/Trio/Sources/Logger/IssueReporter/CollectionIssueReporter.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import Swinject - -protocol GroupedIssueReporter: IssueReporter { - func add(reporters: [IssueReporter]) - func remove(reporter: IssueReporter) -} - -final class CollectionIssueReporter: GroupedIssueReporter { - private let reportersLock = NSRecursiveLock(label: "CollectionIssueReporter.reportersLock") - private var reporters: [IssueReporter] = [] - - func setup() { - reportersLock.perform { - reporters.forEach { $0.setup() } - } - } - - func setUserIdentifier(_ identifier: String?) { - reportersLock.perform { - reporters.forEach { $0.setUserIdentifier(identifier) } - } - } - - func reportNonFatalIssue(withName name: String, attributes: [String: String]) { - reportersLock.perform { - reporters.forEach { $0.reportNonFatalIssue(withName: name, attributes: attributes) } - } - } - - func reportNonFatalIssue(withError error: NSError) { - reportersLock.perform { - reporters.forEach { $0.reportNonFatalIssue(withError: error) } - } - } - - func log(_ category: String, _ message: String, file: String, function: String, line: UInt) { - reportersLock.perform { - reporters.forEach { $0.log(category, message, file: file, function: function, line: line) } - } - } - - func add(reporters: [IssueReporter]) { - reportersLock.perform { - self.reporters.append(contentsOf: reporters) - } - } - - func remove(reporter: IssueReporter) { - reportersLock.perform { - reporters.removeAll { $0 === reporter } - } - } -} diff --git a/Trio/Sources/Logger/IssueReporter/IssueReporter.swift b/Trio/Sources/Logger/IssueReporter/IssueReporter.swift deleted file mode 100644 index a7b0b7a1b3..0000000000 --- a/Trio/Sources/Logger/IssueReporter/IssueReporter.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -protocol IssueReporter: AnyObject { - /// Call this method in `applicationDidFinishLaunching()`. - func setup() - - func setUserIdentifier(_: String?) - - func reportNonFatalIssue(withName: String, attributes: [String: String]) - - func reportNonFatalIssue(withError: NSError) - - func log(_ category: String, _ message: String, file: String, function: String, line: UInt) -} diff --git a/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift b/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift deleted file mode 100644 index 593522b63c..0000000000 --- a/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift +++ /dev/null @@ -1,506 +0,0 @@ -import Foundation -import os.log -import SwiftDate -import System - -final class SimpleLogReporter: IssueReporter { - // Prevent infinite recursion by never calling debug() from within this reporter - private static let errorLog = OSLog(subsystem: "org.nightscout.trio", category: "SimpleLogReporter") - private let fileManager = FileManager.default - - // Constants for maintenance - private static let logRetentionDays = 4 // Keep logs for last 4 days - private static let zipRetentionCount = 3 // Keep 3 most recent zip files - - // Property list key for persistent storage - private static let lastCleanupDateKey = "SimpleLogReporter.lastDailyCleanupDate" - - // Track last cleanup time persistently across app launches - private static var lastDailyCleanupDate: Date? { - get { - UserDefaults.standard.object(forKey: lastCleanupDateKey) as? Date - } - set { - if let date = newValue { - UserDefaults.standard.set(date, forKey: lastCleanupDateKey) - } else { - UserDefaults.standard.removeObject(forKey: lastCleanupDateKey) - } - } - } - - // MARK: - Date and Name Utilities - - static func currentLogName() -> String { - let now = Date() - return Formatter.logDateFormatter.string(from: now) - } - - static func logNameForDate(daysAgo: Int) -> String { - let date = Calendar.current.date(byAdding: .day, value: -daysAgo, to: Date())! - return Formatter.logDateFormatter.string(from: date) - } - - static func getAllLogNames() -> [String] { - var names: [String] = [] - for i in 0 ..< logRetentionDays { - names.append(logNameForDate(daysAgo: i)) - } - return names - } - - static func currentDate() -> Date { - Date() - } - - static func startOfCurrentDay() -> Date { - let now = Date() - return Calendar.current.startOfDay(for: now) - } - - // MARK: - IssueReporter Implementation - - func setup() {} - - func setUserIdentifier(_: String?) {} - - func reportNonFatalIssue(withName _: String, attributes _: [String: String]) {} - - func reportNonFatalIssue(withError _: NSError) {} - - func log(_ category: String, _ message: String, file: String, function: String, line: UInt) { - let now = SimpleLogReporter.currentDate() - let startOfDay = SimpleLogReporter.startOfCurrentDay() - let logName = SimpleLogReporter.currentLogName() - - // Ensure the logs directory exists - let logsDir = SimpleLogReporter.logsDirectory - if !fileManager.fileExists(atPath: logsDir.path) { - do { - try fileManager.createDirectory(at: logsDir, withIntermediateDirectories: true) - } catch { - // Never call debug() from within the reporter to prevent infinite recursion - os_log( - "Failed to create logs directory: %{public}@", - log: Self.errorLog, - type: .error, - error.localizedDescription - ) - return - } - } - - // Create today's log file if it doesn't exist - let logFileURL = SimpleLogReporter.logFileURL(name: logName) - if !fileManager.fileExists(atPath: logFileURL.path) { - createFile(at: startOfDay) - - // Perform cleanup only when date changes - SimpleLogReporter.performDailyCleanupIfNeeded() - } - - // Append the log entry using ISO8601 formatter - let logEntry = "\(Formatter.iso8601.string(from: now)) [\(category)] \(file.file) - \(function) - \(line) - \(message)\n" - guard let data = logEntry.data(using: .utf8) else { - // Never call debug() from within the reporter to prevent infinite recursion - os_log("Failed to encode log entry as UTF-8", log: Self.errorLog, type: .error) - return - } - - do { - try data.append(fileURL: logFileURL) - } catch { - // Never call debug() from within the reporter to prevent infinite recursion - os_log( - "Failed to append log entry to %{public}@: %{public}@", - log: Self.errorLog, - type: .error, - logFileURL.lastPathComponent, - error.localizedDescription - ) - } - } - - private func createFile(at date: Date) { - let logName = SimpleLogReporter.currentLogName() - let logFileURL = SimpleLogReporter.logFileURL(name: logName) - let success = fileManager.createFile(atPath: logFileURL.path, contents: nil, attributes: [.creationDate: date]) - if !success { - // Never call debug() from within the reporter to prevent infinite recursion - os_log("Failed to create log file: %{public}@", log: Self.errorLog, type: .error, logFileURL.lastPathComponent) - } - } - - // MARK: - File Path Utilities - - static var documentsDirectory: URL { - do { - return try Disk.url(for: nil, in: .documents) - } catch { - os_log("Failed to get documents directory: %{public}@", log: Self.errorLog, type: .error, error.localizedDescription) - // Fallback to FileManager approach - let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - return paths[0] - } - } - - static var logsDirectory: URL { - do { - return try Disk.AppDirectoryURL.logs() - } catch { - os_log( - "Failed to get logs directory via Disk: %{public}@", - log: Self.errorLog, - type: .error, - error.localizedDescription - ) - // Fallback to manual construction - return documentsDirectory.appendingPathComponent("logs", isDirectory: true) - } - } - - static func logFileURL(name: String) -> URL { - do { - return try Disk.AppDirectoryURL.logFile(name: name) - } catch { - os_log( - "Failed to get log file URL via Disk: %{public}@", - log: Self.errorLog, - type: .error, - error.localizedDescription - ) - // Fallback to manual construction - return logsDirectory.appendingPathComponent("\(name).log") - } - } - - // Legacy string-based methods for backward compatibility - static func logFile(name: String) -> String { - logFileURL(name: name).path - } - - static var logDir: String { - logsDirectory.path - } - - static func getDocumentsDirectory() -> URL { - documentsDirectory - } - - // MARK: - Watch Log Functions - - static func watchLogFileURL(name: String) -> URL { - do { - return try Disk.AppDirectoryURL.watchLogFile(name: name) - } catch { - os_log( - "Failed to get watch log file URL via Disk: %{public}@", - log: Self.errorLog, - type: .error, - error.localizedDescription - ) - // Fallback to manual construction - return logsDirectory.appendingPathComponent("watch_\(name).log") - } - } - - static func watchLogFile(name: String) -> String { - watchLogFileURL(name: name).path - } - - static func appendToWatchLog(_ logContent: String) { - let startOfDay = startOfCurrentDay() - let logName = currentLogName() - - let fileManager = FileManager.default - let logsDir = logsDirectory - let logFileURL = watchLogFileURL(name: logName) - - // Create logs directory if needed - if !fileManager.fileExists(atPath: logsDir.path) { - do { - try fileManager.createDirectory(at: logsDir, withIntermediateDirectories: true) - } catch { - os_log( - "Failed to create logs directory for watch logs: %{public}@", - log: Self.errorLog, - type: .error, - error.localizedDescription - ) - return - } - } - - // Check if need to create a new log file for today - let needNewFile: Bool - if fileManager.fileExists(atPath: logFileURL.path) { - // Check if the file was created on a previous day - do { - let attributes = try fileManager.attributesOfItem(atPath: logFileURL.path) - if let creationDate = attributes[.creationDate] as? Date, - creationDate < startOfDay - { - needNewFile = true - } else { - needNewFile = false - } - } catch { - os_log( - "Failed to get watch log file attributes: %{public}@", - log: Self.errorLog, - type: .error, - error.localizedDescription - ) - needNewFile = true // Default to creating new file if we can't check - } - } else { - needNewFile = true - } - - if needNewFile { - let success = fileManager.createFile(atPath: logFileURL.path, contents: nil, attributes: [.creationDate: startOfDay]) - if !success { - os_log( - "Failed to create watch log file: %{public}@", - log: Self.errorLog, - type: .error, - logFileURL.lastPathComponent - ) - return - } - - // Perform cleanup only when date changes - performDailyCleanupIfNeeded() - } - - // Append the log entry - guard let data = (logContent + "\n").data(using: .utf8) else { - os_log("Failed to encode watch log content as UTF-8", log: Self.errorLog, type: .error) - return - } - - do { - try data.append(fileURL: logFileURL) - } catch { - os_log( - "Failed to append to watch log %{public}@: %{public}@", - log: Self.errorLog, - type: .error, - logFileURL.lastPathComponent, - error.localizedDescription - ) - } - } - - // MARK: - Intelligent Cleanup Management - - // Cleanup that runs when date changes - only removes very old logs (beyond retention period) - private static func performDailyCleanupIfNeeded() { - let today = Calendar.current.startOfDay(for: Date()) - - // If we've never done a daily cleanup or it was on a previous day - if lastDailyCleanupDate == nil || !Calendar.current.isDate(lastDailyCleanupDate!, inSameDayAs: today) { - // Only remove logs beyond retention period - no zip cleanup here - cleanupLogs() - lastDailyCleanupDate = today - os_log( - "Performed daily log cleanup on %{public}@", - log: Self.errorLog, - type: .info, - Formatter.logDateFormatter.string(from: today) - ) - } - } - - // MARK: - Cleanup Functions - - // Generic helper function for directory cleanup - private static func cleanupDirectory( - at directory: URL, - keepLast: Int? = nil, - olderThanDays: Int? = nil, - extensions: [String] = [] - ) throws -> Int { - let fileManager = FileManager.default - - guard fileManager.fileExists(atPath: directory.path) else { - return 0 - } - - let contents = try fileManager.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey], - options: [.skipsHiddenFiles] - ) - - // Filter by extensions if specified - let filteredFiles = extensions.isEmpty ? contents : contents.filter { fileURL in - extensions.contains(fileURL.pathExtension) - } - - var filesToDelete: [URL] = [] - - if let keepLast = keepLast { - // Sort by creation date (newest first) and keep only the specified number - let sortedFiles = try filteredFiles.sorted { - let date1 = try $0.resourceValues(forKeys: [.creationDateKey]).creationDate ?? Date.distantPast - let date2 = try $1.resourceValues(forKeys: [.creationDateKey]).creationDate ?? Date.distantPast - return date1 > date2 - } - - if sortedFiles.count > keepLast { - filesToDelete.append(contentsOf: sortedFiles.suffix(from: keepLast)) - } - } - - if let olderThanDays = olderThanDays { - // Delete files older than specified days - let calendar = Calendar.current - guard let cutoffDate = calendar.date(byAdding: .day, value: -olderThanDays, to: Date()) else { - return 0 - } - - for fileURL in filteredFiles { - let filename = fileURL.deletingPathExtension().lastPathComponent - var fileDate: Date? - - // Special handling for log files with date parsing - if fileURL.pathExtension == "log" { - if filename.hasPrefix("watch_") { - // For watch logs, extract the date part after "watch_" - let dateStart = filename.index(filename.startIndex, offsetBy: 6) - let dateSubstring = String(filename[dateStart...]) - fileDate = Formatter.logDateFormatter.date(from: dateSubstring) - } else { - // Regular logs - try to parse the whole filename as a date - fileDate = Formatter.logDateFormatter.date(from: filename) - } - } - - // If no date parsed from filename, fall back to file attributes - if fileDate == nil { - do { - let attributes = try fileURL.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey]) - // Prefer modification date, fall back to creation date - fileDate = attributes.contentModificationDate ?? attributes.creationDate - } catch { - // If cannot get attributes, skip this file - continue - } - } - - if let date = fileDate, date < cutoffDate { - filesToDelete.append(fileURL) - } - } - } - - // Remove duplicates (in case a file matches both criteria) - let uniqueFilesToDelete = Array(Set(filesToDelete)) - - var removedCount = 0 - for fileURL in uniqueFilesToDelete { - do { - try fileManager.removeItem(at: fileURL) - removedCount += 1 - } catch { - os_log( - "Failed to remove file %{public}@: %{public}@", - log: Self.errorLog, - type: .error, - fileURL.lastPathComponent, - error.localizedDescription - ) - } - } - - return removedCount - } - - // Clean up old log files - static func cleanupLogs() { - do { - let removedCount = try cleanupDirectory( - at: logsDirectory, - olderThanDays: logRetentionDays, - extensions: ["log"] - ) - - if removedCount > 0 { - os_log( - "Removed %d log file(s) older than %d days", - log: Self.errorLog, - type: .info, - removedCount, - logRetentionDays - ) - } - } catch { - os_log("Error cleaning up logs: %{public}@", log: Self.errorLog, type: .error, error.localizedDescription) - } - } - - // Clean up old zip exports - static func cleanupZipExports() { - do { - let exportsDirectoryURL = try Disk.AppDirectoryURL.logExports() - let removedCount = try cleanupDirectory( - at: exportsDirectoryURL, - keepLast: zipRetentionCount, - extensions: ["zip"] - ) - - if removedCount > 0 { - os_log( - "Removed %d old zip files, keeping %d most recent", - log: Self.errorLog, - type: .info, - removedCount, - zipRetentionCount - ) - } - } catch { - os_log( - "Error accessing or cleaning up zip exports directory: %{public}@", - log: Self.errorLog, - type: .error, - error.localizedDescription - ) - } - } - - // MARK: - Async Cleanup Methods - - // Combined cleanup method - used by the app's scheduled maintenance - static func cleanupAllLogsAsync() async { - await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .utility).async { - // Full cleanup - both logs and zip files - cleanupLogs() - cleanupZipExports() - - // Update daily cleanup date too - lastDailyCleanupDate = Calendar.current.startOfDay(for: Date()) - - os_log("Performed complete log maintenance (scheduled cleanup)", log: Self.errorLog, type: .info) - continuation.resume() - } - } - } -} - -private extension Data { - func append(fileURL: URL) throws { - let descriptor = try FileDescriptor.open( - FilePath(fileURL.path), - .writeOnly, - options: [.append, .create], - permissions: [.ownerReadWrite, .groupRead, .otherRead] - ) - try descriptor.closeAfter { _ = try descriptor.writeAll(self) } - } -} - -private extension String { - var file: String { components(separatedBy: "/").last ?? "" } -} diff --git a/Trio/Sources/Logger/Logger.swift b/Trio/Sources/Logger/Logger.swift index eaf5f88098..5361345f32 100644 --- a/Trio/Sources/Logger/Logger.swift +++ b/Trio/Sources/Logger/Logger.swift @@ -1,12 +1,4 @@ -import os.log -import os.signpost -import UIKit - -var LoggerTestMode = false - -private let baseReporter = TrioApp.resolver.resolve(GroupedIssueReporter.self)! - -let loggerLock = NSRecursiveLock() +import Foundation func debug( _ category: Logger.Category, @@ -16,12 +8,7 @@ func debug( function: String = #function, line: UInt = #line ) { - let msg = message() - DispatchWorkItem(qos: .background, flags: .enforceQoS) { - loggerLock.perform { - category.logger.debug(msg, printToConsole: printToConsole, file: file, function: function, line: line) - } - }.perform() + TrioLogger.log(.debug, category, message(), printToConsole: printToConsole, file: file, function: function, line: line) } func info( @@ -31,11 +18,7 @@ func info( function: String = #function, line: UInt = #line ) { - DispatchWorkItem(qos: .background, flags: .enforceQoS) { - loggerLock.perform { - category.logger.info(message, file: file, function: function, line: line) - } - }.perform() + TrioLogger.log(.info, category, message, file: file, function: function, line: line) } func warning( @@ -47,18 +30,8 @@ func warning( function: String = #function, line: UInt = #line ) { - DispatchWorkItem(qos: .background, flags: .enforceQoS) { - loggerLock.perform { - category.logger.warning( - message, - description: description, - error: maybeError, - file: file, - function: function, - line: line - ) - } - }.perform() + let message = composed(message, description: description, error: maybeError) + TrioLogger.log(.warning, category, message, file: file, function: function, line: line) } func error( @@ -69,258 +42,51 @@ func error( file: String = #file, function: String = #function, line: UInt = #line -) -> Never { - loggerLock.perform { - category.logger.errorWithoutFatalError( - message, - description: description, - error: maybeError, - file: file, - function: function, - line: line - ) - - fatalError( - "\(message) @ \(String(describing: description)) @ \(String(describing: maybeError)) @ \(file) @ \(function) @ \(line)" - ) - } +) { + let message = composed(message, description: description, error: maybeError) + TrioLogger.log(.error, category, message, file: file, function: function, line: line) } -func check( - _ condition: @autoclosure () -> Bool, - _ message: @autoclosure () -> String, - description: @autoclosure () -> String? = nil, +func fault( + _ category: Logger.Category, + _ message: String, + description: String? = nil, + error maybeError: Swift.Error? = nil, file: String = #file, function: String = #function, line: UInt = #line ) { - guard !condition() else { return } - let msg = message() - let descr = description() - loggerLock.perform { - warning(.default, msg, description: descr, file: file.file, function: function, line: line) - } + let message = composed(message, description: description, error: maybeError) + TrioLogger.log(.fault, category, message, file: file, function: function, line: line) } -final class Logger { - static let `default` = Logger(category: .default, reporter: baseReporter) - static let service = Logger(category: .service, reporter: baseReporter) - static let businessLogic = Logger(category: .businessLogic, reporter: baseReporter) - static let openAPS = Logger(category: .openAPS, reporter: baseReporter) - static let openAPSSwift = Logger(category: .openAPSSwift, reporter: baseReporter) - static let deviceManager = Logger(category: .deviceManager, reporter: baseReporter) - static let apsManager = Logger(category: .apsManager, reporter: baseReporter) - static let nightscout = Logger(category: .nightscout, reporter: baseReporter) - static let remoteControl = Logger(category: .remoteControl, reporter: baseReporter) - static let bolusState = Logger(category: .bolusState, reporter: baseReporter) - static let watchManager = Logger(category: .watchManager, reporter: baseReporter) - static let coreData = Logger(category: .coreData, reporter: baseReporter) - static let storage = Logger(category: .storage, reporter: baseReporter) - static let telemetry = Logger(category: .telemetry, reporter: baseReporter) - - enum Category: String { - case `default` - case service - case businessLogic - case openAPS - case openAPSSwift - case deviceManager - case apsManager +enum Logger { + enum Category: String, CaseIterable { + case loop + case algorithm + case pump + case cgm + case bolus + case treatments case nightscout - case remoteControl - case bolusState - case watchManager - case coreData + case tidepool + case remote + case healthKit + case alerts + case watch + case liveActivity case storage + case settings case telemetry + case ui + case lifecycle var name: String { rawValue.capitalizingFirstLetter() } - - var logger: Logger { - switch self { - case .default: return .default - case .service: return .service - case .businessLogic: return .businessLogic - case .openAPS: return .openAPS - case .openAPSSwift: return .openAPSSwift - case .deviceManager: return .deviceManager - case .apsManager: return .apsManager - case .nightscout: return .nightscout - case .remoteControl: return .remoteControl - case .bolusState: return .bolusState - case .watchManager: return .watchManager - case .coreData: return .coreData - case .storage: return .storage - case .telemetry: return .telemetry - } - } - - fileprivate var log: OSLog { - let subsystem = Bundle.main.bundleIdentifier! - switch self { - case .default: return OSLog.default - case .apsManager, - .bolusState, - .businessLogic, - .coreData, - .deviceManager, - .nightscout, - .openAPS, - .openAPSSwift, - .remoteControl, - .service, - .storage, - .telemetry, - .watchManager: - return OSLog(subsystem: subsystem, category: name) - } - } - } - - fileprivate enum Error: Swift.Error { - case error(String) - case errorWithInnerError(String, Swift.Error) - case errorWithDescription(String, String) - case errorWithDescriptionAndInnerError(String, String, Swift.Error) - - private func domain() -> String { - switch self { - case let .error(domain), - let .errorWithDescription(domain, _), - let .errorWithDescriptionAndInnerError(domain, _, _), - let .errorWithInnerError(domain, _): - return domain - } - } - - private func innerError() -> Swift.Error? { - switch self { - case let .errorWithDescriptionAndInnerError(_, _, error), - let .errorWithInnerError(_, error): - return error - default: return nil - } - } - - func asNSError() -> NSError { - var info: [String: Any] = ["Description": String(describing: self)] - - if let error = innerError() { - info["Error"] = String(describing: error) - } - - return NSError(domain: domain(), code: -1, userInfo: info) - } - } - - private let category: Category - private let reporter: IssueReporter - let log: OSLog - - private init(category: Category, reporter: IssueReporter) { - self.category = category - self.reporter = reporter - log = category.log - } - - static func setup() { - loggerLock.perform { - baseReporter.setup() - } - } - - func debug( - _ message: @autoclosure () -> String, - printToConsole: Bool = true, - file: String = #file, - function: String = #function, - line: UInt = #line - ) { - let message = "DEV: \(message())" - if printToConsole { - os_log("%@ - %@ - %d %{public}@", log: log, type: .debug, file.file, function, line, message) - } - reporter.log(category.name, message, file: file, function: function, line: line) - } - - func info( - _ message: String, - file: String = #file, - function: String = #function, - line: UInt = #line - ) { - let printedMessage = "INFO: \(message)" - os_log("%@ - %@ - %d %{public}@", log: log, type: .info, file.file, function, line, printedMessage) - reporter.log(category.name, printedMessage, file: file, function: function, line: line) - } - - func warning( - _ message: String, - description: String? = nil, - error maybeError: Swift.Error? = nil, - file: String = #file, - function: String = #function, - line: UInt = #line - ) { - let loggerError = maybeError.loggerError(message: message, withDescription: description) - let message = "WARN: \(String(describing: loggerError))" - - os_log("%@ - %@ - %d %{public}@", log: log, type: .default, file.file, function, line, message) - reporter.log(category.name, message, file: file, function: function, line: line) - if !LoggerTestMode, maybeError?.shouldReportNonFatalIssue ?? true { - reporter.reportNonFatalIssue(withError: loggerError.asNSError()) - } - } - - func error( - _ message: String, - description: String? = nil, - error maybeError: Swift.Error? = nil, - file: String = #file, - function: String = #function, - line: UInt = #line - ) -> Never { - errorWithoutFatalError(message, description: description, error: maybeError, file: file, function: function, line: line) - - fatalError( - "\(message) @ \(String(describing: description)) @ \(String(describing: maybeError)) @ \(file) @ \(function) @ \(line)" - ) - } - - fileprivate func errorWithoutFatalError( - _ message: String, - description: String? = nil, - error maybeError: Swift.Error? = nil, - file: String = #file, - function: String = #function, - line: UInt = #line - ) { - let loggerError = maybeError.loggerError(message: message, withDescription: description) - let message = "ERR: \(String(describing: loggerError))" - - os_log("%@ - %@ - %d %{public}@", log: log, type: .error, file.file, function, line, message) - reporter.log(category.name, message, file: file, function: function, line: line) - reporter.reportNonFatalIssue(withError: loggerError.asNSError()) - } -} - -private extension Optional where Wrapped == Swift.Error { - func loggerError(message: String, withDescription description: String?) -> Logger.Error { - switch (description, self) { - case (nil, nil): - return .error(message) - case let (descr?, nil): - return .errorWithDescription(message, descr) - case let (nil, error?): - return .errorWithInnerError(message, error) - case let (descr?, error?): - return .errorWithDescriptionAndInnerError(message, descr, error) - } } } -private extension String { - var file: String { components(separatedBy: "/").last ?? "" } +private func composed(_ message: String, description: String?, error: Swift.Error?) -> String { + [message, description, error.map { String(describing: $0) }].compactMap { $0 }.joined(separator: ": ") } diff --git a/Trio/Sources/Logger/Signpost.swift b/Trio/Sources/Logger/Signpost.swift deleted file mode 100644 index 9a5ef17c6e..0000000000 --- a/Trio/Sources/Logger/Signpost.swift +++ /dev/null @@ -1,150 +0,0 @@ -import os.log -import os.signpost - -protocol RangeSignpost { - func end(_ format: @autoclosure () -> StaticString, _ arguments: @autoclosure () -> [String]) - func end(_ format: @autoclosure () -> StaticString) -} - -enum Signpost { - static func point( - _ category: Logger.Category, - name: StaticString, - format: StaticString? = nil, - arguments: @autoclosure () -> [String] = [] - ) { - guard Config.withSignPosts else { return } - if #available(iOS 12.0, *) { - let log = category.logger.log - let signpostID = OSSignpostID(log: log) - set(.event, log: log, name: name, signpostID: signpostID, format, arguments()) - } - } - - static func perform( - _: Logger.Category, - name: StaticString, - block: () throws -> T - ) rethrows -> T { - let signpost = Signpost.range(.businessLogic, name: name) - defer { signpost.end(name) } - return try block() - } - - static func range( - _ category: Logger.Category, - name: StaticString - ) -> RangeSignpost { - guard Config.withSignPosts else { return EmptySignpost() } - if #available(iOS 12.0, *) { - return BaseRangeSignpost(category, name) - } - return EmptySignpost() - } - - static func range( - _ category: Logger.Category, - name: StaticString, - format: StaticString, - arguments: @autoclosure () -> [String] = [] - ) -> RangeSignpost { - guard Config.withSignPosts else { return EmptySignpost() } - if #available(iOS 12.0, *) { - return BaseRangeSignpost(category, name, format, arguments()) - } - return EmptySignpost() - } - - @available(iOS 12.0, *) fileprivate static func set( - _ type: OSSignpostType, - log: OSLog, - name: StaticString, - signpostID: OSSignpostID, - _ format: StaticString? = nil, - _ arguments: [String] = [] - ) { - if let format = format { - switch arguments.count { - case 0: os_signpost(type, log: log, name: name, signpostID: signpostID, format) - case 1: os_signpost(type, log: log, name: name, signpostID: signpostID, format, arguments[0]) - case 2: os_signpost(type, log: log, name: name, signpostID: signpostID, format, arguments[0], arguments[1]) - case 3: os_signpost( - type, - log: log, - name: name, - signpostID: signpostID, - format, - arguments[0], - arguments[1], - arguments[2] - ) - case 4: os_signpost( - type, - log: log, - name: name, - signpostID: signpostID, - format, - arguments[0], - arguments[1], - arguments[2], - arguments[3] - ) - case 5: os_signpost( - type, - log: log, - name: name, - signpostID: signpostID, - format, - arguments[0], - arguments[1], - arguments[2], - arguments[3], - arguments[4] - ) - default: error(.service, "Signpost.set is not implemented for size.", description: "\(arguments.count)") - } - } else { - os_signpost(type, log: log, name: name, signpostID: signpostID) - } - } -} - -struct EmptySignpost: RangeSignpost { - func end(_: @autoclosure () -> StaticString, _: @autoclosure () -> [String]) {} - func end(_: @autoclosure () -> StaticString) {} -} - -@available(iOS 12.0, *) final class BaseRangeSignpost: RangeSignpost { - private let log: OSLog - private let signpostID: OSSignpostID - private let name: StaticString - private var endFormat: StaticString? - private var endArguments: [String] = [] - - init(_ category: Logger.Category, _ name: StaticString) { - log = category.logger.log - signpostID = OSSignpostID(log: log) - self.name = name - Signpost.set(.begin, log: log, name: name, signpostID: signpostID) - } - - init(_ category: Logger.Category, _ name: StaticString, _ format: StaticString, _ arguments: [String]) { - log = category.logger.log - signpostID = OSSignpostID(log: log) - self.name = name - Signpost.set(.begin, log: log, name: name, signpostID: signpostID, format, arguments) - } - - deinit { - Signpost.set(.end, log: log, name: name, signpostID: signpostID, endFormat, endArguments) - } - - func end(_ format: @autoclosure () -> StaticString, _ arguments: @autoclosure () -> [String]) { - endFormat = format() - endArguments = arguments() - } - - func end(_ format: @autoclosure () -> StaticString) { - end(format(), []) - } -} diff --git a/Trio/Sources/Logger/Store/AppLog.xcdatamodeld/AppLog.xcdatamodel/contents b/Trio/Sources/Logger/Store/AppLog.xcdatamodeld/AppLog.xcdatamodel/contents new file mode 100644 index 0000000000..9fc13bb6f1 --- /dev/null +++ b/Trio/Sources/Logger/Store/AppLog.xcdatamodeld/AppLog.xcdatamodel/contents @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift new file mode 100644 index 0000000000..d3722bd59b --- /dev/null +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -0,0 +1,463 @@ +import CoreData +import Foundation +import os +import UIKit + +final class AppLogStore: LogSink, @unchecked Sendable { + static let shared = AppLogStore() + + static let bufferLimit = 5000 + static let batchSize = 200 + static let flushDelay: TimeInterval = 2 + static let purgeInterval: TimeInterval = 24 * 60 * 60 + static let purgeStep: TimeInterval = 24 * 60 * 60 + static let legacyFileMaxAge: TimeInterval = 7 * 24 * 60 * 60 + + private static let entityName = "AppLogEntry" + private static let model: NSManagedObjectModel? = Bundle.main.url(forResource: "AppLog", withExtension: "momd") + .flatMap { NSManagedObjectModel(contentsOf: $0) } + + private struct Pending: Sendable { + var records: [LogRecord] = [] + var persistsDebug: Bool + var flushScheduled = false + var batchFlushScheduled = false + } + + let storageURL: URL + var directory: URL { storageURL.deletingLastPathComponent() } + + private let pending: OSAllocatedUnfairLock + private let queue = DispatchQueue(label: "AppLogStore.queue", qos: .utility) + private let notificationCenter: Foundation.NotificationCenter + private let loadContainer: (URL) -> NSPersistentContainer? + private let isProtectedDataAvailable: @MainActor() -> Bool + private var observers: [NSObjectProtocol] = [] + + // Confined to `queue` + private var container: NSPersistentContainer? + private var context: NSManagedObjectContext? + private var policy: LogRetentionPolicy + private var lastPurge: Date? + private var isCheckingProtectedData = false + private var retryAfterCheck = false + private var didMoveStoreAside = false + private var didReopenAfterFailure = false + + init( + storageURL: URL = AppLogStore.defaultStorageURL, + policy: LogRetentionPolicy = .default, + notificationCenter: Foundation.NotificationCenter = .default, + loadContainer: @escaping (URL) -> NSPersistentContainer? = AppLogStore.loadContainer, + isProtectedDataAvailable: @escaping @MainActor() -> Bool = { UIApplication.shared.isProtectedDataAvailable } + ) { + self.storageURL = storageURL + self.policy = policy + self.notificationCenter = notificationCenter + self.loadContainer = loadContainer + self.isProtectedDataAvailable = isProtectedDataAvailable + pending = OSAllocatedUnfairLock(initialState: Pending(persistsDebug: policy.persistsDebug)) + + observers = [ + notificationCenter.addObserver( + forName: UIApplication.protectedDataDidBecomeAvailableNotification, + object: nil, + queue: nil + ) { [weak self] _ in + guard let self else { return } + self.queue.async { self.openIfNeeded() } + }, + notificationCenter.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: nil + ) { [weak self] _ in + guard let self else { return } + self.queue.async { self.flushPending() } + }, + notificationCenter.addObserver( + forName: UIApplication.willTerminateNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.queue.sync { self?.flushPending() } + } + ] + queue.async { self.openIfNeeded() } + } + + deinit { + for observer in observers { + notificationCenter.removeObserver(observer) + } + } + + static var defaultStorageURL: URL { + (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory) + // Shares the legacy lowercase directory; a sibling "Logs" collides on case-insensitive volumes + .appendingPathComponent("logs", isDirectory: true) + .appendingPathComponent("AppLog.sqlite") + } + + func accept(_ record: LogRecord) { + let (scheduleDelayed, flushNow) = pending.withLock { state -> (Bool, Bool) in + guard record.level != .debug || state.persistsDebug else { return (false, false) } + state.records.append(record) + if state.records.count > Self.bufferLimit { + // Drop a chunk so a full buffer doesn't shift on every record + state.records.removeFirst(state.records.count - Self.bufferLimit + Self.bufferLimit / 10) + } + let scheduleDelayed = !state.flushScheduled + state.flushScheduled = true + let flushNow = state.records.count >= Self.batchSize && !state.batchFlushScheduled + if flushNow { state.batchFlushScheduled = true } + return (scheduleDelayed, flushNow) + } + if flushNow { + queue.async { self.flushPending() } + } + if scheduleDelayed { + queue.asyncAfter(deadline: .now() + Self.flushDelay) { + self.pending.withLock { $0.flushScheduled = false } + self.flushPending() + } + } + } + + // Exports wait on these, so they run ahead of background logging load + func flush() async { + await withCheckedContinuation { continuation in + queue.async(qos: .userInitiated, flags: .enforceQoS) { + self.flushPending() + continuation.resume() + } + } + } + + func records(in window: DateInterval, minimumLevel: LogLevel) async -> [LogRecord] { + await withCheckedContinuation { continuation in + queue.async(qos: .userInitiated, flags: .enforceQoS) { + self.flushPending() + continuation.resume(returning: self.fetch(in: window, minimumLevel: minimumLevel)) + } + } + } + + func apply(_ policy: LogRetentionPolicy) { + pending.withLock { $0.persistsDebug = policy.persistsDebug } + queue.async { + guard self.policy != policy else { return } + self.policy = policy + self.purge() + } + } + + private func flushPending() { + guard let context else { return } + let batch = pending.withLock { state -> [LogRecord] in + defer { + state.records.removeAll(keepingCapacity: true) + state.batchFlushScheduled = false + } + return state.records + } + let unsaved = batch.isEmpty ? [] : insert(batch[...], into: context) + guard unsaved.isEmpty else { + storeFailed(keeping: unsaved) + return + } + if let lastPurge, Date().timeIntervalSince(lastPurge) < Self.purgeInterval { return } + purge() + } + + // Returns the records the store itself failed to take; records it rejects are dropped one by one + private func insert(_ batch: ArraySlice, into context: NSManagedObjectContext) -> ArraySlice { + var iterator = batch.makeIterator() + let request = NSBatchInsertRequest(entityName: Self.entityName, dictionaryHandler: { dictionary in + guard let record = iterator.next() else { return true } + dictionary.addEntries(from: [ + "timestamp": record.timestamp, + "level": record.level.rawValue, + "category": Self.sanitized(record.category), + "message": Self.sanitized(record.message), + "file": Self.sanitized(record.file), + "function": Self.sanitized(record.function), + "line": Int32(clamping: record.line), + "launchID": record.launchID + ]) + return false + }) + do { + try context.performAndWait { _ = try context.execute(request) } + return [] + } catch { + if Self.isStoreFailure(error) { + Logger.Category.storage.osLogger.error("App log insert failed: \(String(describing: error), privacy: .public)") + return batch + } + guard batch.count > 1 else { + Logger.Category.storage.osLogger.fault("App log record dropped: \(String(describing: error), privacy: .public)") + return [] + } + let middle = batch.startIndex + batch.count / 2 + let unsaved = insert(batch[.. String { + // Native Swift strings are valid UTF-8, so this also repairs lone surrogates from bridged strings + var string = string + string.makeContiguousUTF8() + guard string.utf8.contains(0) else { return string } + return string.replacingOccurrences(of: "\u{0}", with: "\u{FFFD}") + } + + private static func isStoreFailure(_ error: Error) -> Bool { + let error = error as NSError + let code = error.domain == NSSQLiteErrorDomain ? error.code : error.userInfo[NSSQLiteErrorDomain] as? Int + // SQLITE_READONLY, IOERR, CORRUPT, FULL, CANTOPEN, NOTADB + return code.map { [8, 10, 11, 13, 14, 26].contains($0 & 0xFF) } ?? false + } + + private func fetch(in window: DateInterval, minimumLevel: LogLevel) -> [LogRecord] { + guard let context else { + return pending.withLock { $0.records } + .filter { window.contains($0.timestamp) && $0.level >= minimumLevel } + } + let request = NSFetchRequest(entityName: Self.entityName) + request.resultType = .dictionaryResultType + request.propertiesToFetch = Self.fetchedProperties + request.predicate = NSPredicate( + format: "timestamp >= %@ AND timestamp <= %@ AND level >= %d", + window.start as NSDate, + window.end as NSDate, + minimumLevel.rawValue + ) + request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)] + let rows = context.performAndWait { (try? context.fetch(request)) ?? [] } + var fetched: [(record: LogRecord, objectID: NSManagedObjectID?)] = rows.compactMap { row in + guard let timestamp = row["timestamp"] as? Date, + let level = (row["level"] as? Int16).flatMap(LogLevel.init(rawValue:)) + else { return nil } + let record = LogRecord( + timestamp: timestamp, + level: level, + category: row["category"] as? String ?? "", + message: row["message"] as? String ?? "", + file: row["file"] as? String ?? "", + function: row["function"] as? String ?? "", + line: UInt(max(0, row["line"] as? Int32 ?? 0)), + launchID: row["launchID"] as? UUID ?? UUID() + ) + return (record, row["objectID"] as? NSManagedObjectID) + } + Self.sortTiesByInsertion(&fetched) + return fetched.map(\.record) + } + + private static let fetchedProperties: [Any] = { + let objectID = NSExpressionDescription() + objectID.name = "objectID" + objectID.expression = NSExpression.expressionForEvaluatedObject() + objectID.expressionResultType = .objectIDAttributeType + return ["timestamp", "level", "category", "message", "file", "function", "line", "launchID", objectID] + }() + + // SQLite returns equal timestamps in no particular order; primary keys follow insertion + private static func sortTiesByInsertion(_ rows: inout [(record: LogRecord, objectID: NSManagedObjectID?)]) { + var start = 0 + while start < rows.count { + var end = start + 1 + while end < rows.count, rows[end].record.timestamp == rows[start].record.timestamp { + end += 1 + } + if end - start > 1 { + let ordered = rows[start ..< end].map { (insertionOrder($0.objectID), $0) }.sorted { $0.0 < $1.0 } + rows.replaceSubrange(start ..< end, with: ordered.map(\.1)) + } + start = end + } + } + + private static func insertionOrder(_ objectID: NSManagedObjectID?) -> Int { + // Permanent IDs end in "p" + objectID.flatMap { Int($0.uriRepresentation().lastPathComponent.dropFirst()) } ?? Int.max + } + + private func purge() { + guard let context else { return } + lastPurge = Date() + let cutoff = Date().addingTimeInterval(-policy.maxAge) + let oldest = context.performAndWait { + let request = NSFetchRequest(entityName: Self.entityName) + request.resultType = .dictionaryResultType + request.propertiesToFetch = ["timestamp"] + request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)] + request.fetchLimit = 1 + return (try? context.fetch(request))?.first?["timestamp"] as? Date + } + purge(from: min(oldest ?? cutoff, cutoff), before: cutoff) + } + + // A day per step keeps flushes and exports from waiting behind a large backlog + private func purge(from start: Date, before cutoff: Date) { + guard let context else { + lastPurge = nil + return + } + let end = min(start.addingTimeInterval(Self.purgeStep), cutoff) + context.performAndWait { deleteEntries(before: end, in: context) } + guard end >= cutoff else { + queue.async { self.purge(from: end, before: cutoff) } + return + } + let maxRows = policy.maxRows + context.performAndWait { + let newest = NSFetchRequest(entityName: Self.entityName) + newest.resultType = .dictionaryResultType + newest.propertiesToFetch = ["timestamp"] + newest.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: false)] + newest.fetchOffset = max(0, maxRows - 1) + newest.fetchLimit = 1 + if let cutoff = (try? context.fetch(newest))?.first?["timestamp"] as? Date { + deleteEntries(before: cutoff, in: context) + } + } + } + + private func deleteEntries(before date: Date, in context: NSManagedObjectContext) { + let request = NSFetchRequest(entityName: Self.entityName) + request.predicate = NSPredicate(format: "timestamp < %@", date as NSDate) + let delete = NSBatchDeleteRequest(fetchRequest: request) + delete.resultType = .resultTypeCount + do { + try context.execute(delete) + } catch { + Logger.Category.storage.osLogger.error("App log purge failed: \(String(describing: error), privacy: .public)") + } + } + + private func openIfNeeded() { + guard container == nil else { return } + // An unlock during the check must not be lost + guard !isCheckingProtectedData else { + retryAfterCheck = true + return + } + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + if let container = loadContainer(storageURL) { + open(container) + return + } + // A locked device also fails to load; only move the store aside once data is readable + isCheckingProtectedData = true + Task { @MainActor in + let available = self.isProtectedDataAvailable() + self.queue.async { + self.isCheckingProtectedData = false + self.recover(protectedDataAvailable: available) + if self.retryAfterCheck { + self.retryAfterCheck = false + self.openIfNeeded() + } + } + } + } + + private func recover(protectedDataAvailable: Bool) { + guard container == nil, protectedDataAvailable, !didMoveStoreAside else { return } + // The first attempt may have raced an unlock + if let container = loadContainer(storageURL) { + open(container) + return + } + didMoveStoreAside = true + moveStoreAside() + if let container = loadContainer(storageURL) { + open(container) + } else { + Logger.Category.storage.osLogger.error("App log store unavailable, keeping records in memory") + } + } + + private func storeFailed(keeping unsaved: ArraySlice) { + pending.withLock { state in + state.records.insert(contentsOf: unsaved, at: 0) + if state.records.count > Self.bufferLimit { + state.records.removeFirst(state.records.count - Self.bufferLimit) + } + } + if let coordinator = container?.persistentStoreCoordinator { + for store in coordinator.persistentStores { + try? coordinator.remove(store) + } + } + container = nil + context = nil + guard !didReopenAfterFailure else { + Logger.Category.storage.osLogger.error("App log store unavailable, keeping records in memory") + return + } + didReopenAfterFailure = true + openIfNeeded() + } + + private func open(_ container: NSPersistentContainer) { + self.container = container + let context = container.newBackgroundContext() + context.undoManager = nil + self.context = context + deleteLegacyFiles() + FileManager.default.removeUnreadableStores(of: storageURL) + flushPending() + } + + private func moveStoreAside() { + let stamp = Int(Date().timeIntervalSince1970) + for suffix in ["", "-wal", "-shm"] { + let source = URL(fileURLWithPath: storageURL.path + suffix) + guard FileManager.default.fileExists(atPath: source.path) else { continue } + let destination = URL(fileURLWithPath: source.path + ".unreadable-\(stamp)") + do { + try FileManager.default.moveItem(at: source, to: destination) + } catch { + Logger.Category.storage.osLogger + .error("Could not move app log file aside: \(String(describing: error), privacy: .public)") + } + } + } + + private func deleteLegacyFiles() { + let cutoff = Date().addingTimeInterval(-Self.legacyFileMaxAge) + let files = (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey] + )) ?? [] + for file in files where file.pathExtension == "txt" { + guard let modified = try? file.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate, + modified < cutoff + else { continue } + try? FileManager.default.removeItem(at: file) + } + } + + static func loadContainer(_ url: URL) -> NSPersistentContainer? { + guard let model, FileManager.default.isWritableStore(at: url) else { return nil } + let container = NSPersistentContainer(name: "AppLog", managedObjectModel: model) + let description = NSPersistentStoreDescription(url: url) + description.shouldAddStoreAsynchronously = false + container.persistentStoreDescriptions = [description] + var loadError: Error? + container.loadPersistentStores { _, error in loadError = error } + let coordinator = container.persistentStoreCoordinator + guard loadError == nil, !coordinator.persistentStores.contains(where: \.isReadOnly) else { + for store in coordinator.persistentStores { + try? coordinator.remove(store) + } + return nil + } + return container + } +} diff --git a/Trio/Sources/Logger/Watch/WatchLogEntry.swift b/Trio/Sources/Logger/Watch/WatchLogEntry.swift new file mode 100644 index 0000000000..eb6b1fb6a5 --- /dev/null +++ b/Trio/Sources/Logger/Watch/WatchLogEntry.swift @@ -0,0 +1,63 @@ +import Foundation + +struct WatchLogEntry: Codable, Sendable, Equatable { + enum Level: String, Codable, Sendable, CaseIterable { + case debug + case info + case warning + case error + case fault + } + + enum Category: String, Codable, Sendable, CaseIterable { + case connectivity + case data + case bolus + case treatments + case lifecycle + + var name: String { + rawValue.prefix(1).uppercased() + rawValue.dropFirst() + } + } + + let seq: UInt64 + let date: Date + let level: Level + let category: Category + let message: String +} + +extension WatchLogEntry { + static func jsonLines(_ entries: [WatchLogEntry]) -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + var data = Data() + for entry in entries { + guard let line = try? encoder.encode(entry) else { continue } + data.append(line) + data.append(0x0A) + } + return data + } + + // Lines that don't decode (torn writes, unknown levels or categories) are skipped + static func entries(fromJSONLines data: Data) -> [WatchLogEntry] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + return data.split(separator: 0x0A).compactMap { try? decoder.decode(WatchLogEntry.self, from: Data($0)) } + } +} + +enum WatchLogTransfer { + static let marker = "trioWatchLog" + static let version = 1 + static let firstSeq = "firstSeq" + static let lastSeq = "lastSeq" + static let watchBuild = "watchBuild" + static let installID = "installID" + static let created = "created" + static let ack = "trioWatchLogAck" + static let request = "trioWatchLogRequest" + static let maxFileSize = 2_000_000 +} diff --git a/Trio/Sources/Logger/Watch/WatchLogIngest.swift b/Trio/Sources/Logger/Watch/WatchLogIngest.swift new file mode 100644 index 0000000000..4554b273bf --- /dev/null +++ b/Trio/Sources/Logger/Watch/WatchLogIngest.swift @@ -0,0 +1,197 @@ +import Foundation +import os +import WatchConnectivity + +final class WatchLogIngest: @unchecked Sendable { + static let shared = WatchLogIngest() + + struct Batch: Sendable { + let file: URL + let installID: String + let firstSeq: UInt64 + let lastSeq: UInt64 + } + + static let categoryPrefix = "Watch/" + static let requestTimeout: Duration = .seconds(2) + private static let lastSeqKey = "WatchLogIngest.lastSeq" + + private let defaults: UserDefaults + private let sink: () -> any LogSink + private let queue = DispatchQueue(label: "WatchLogIngest.queue", qos: .utility) + private let ingestedBatches = OSAllocatedUnfairLock(initialState: 0) + private let lastStore = OSAllocatedUnfairLock?>(initialState: nil) + + init(defaults: UserDefaults = .standard, sink: @escaping () -> any LogSink = { TrioLogger.sink }) { + self.defaults = defaults + self.sink = sink + } + + static var stagingDirectory: URL { + FileManager.default.temporaryDirectory.appendingPathComponent("WatchLogs", isDirectory: true) + } + + // Runs inside session(_:didReceive:); the system deletes the received file once that returns + static func stage(_ file: URL, metadata: [String: Any]?, into directory: URL = stagingDirectory) -> Batch? { + guard let metadata, + (metadata[WatchLogTransfer.marker] as? NSNumber)?.intValue == WatchLogTransfer.version, + let installID = metadata[WatchLogTransfer.installID] as? String, !installID.isEmpty, + let firstSeq = (metadata[WatchLogTransfer.firstSeq] as? NSNumber)?.uint64Value, + let lastSeq = (metadata[WatchLogTransfer.lastSeq] as? NSNumber)?.uint64Value, + firstSeq <= lastSeq, + let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize, + size <= WatchLogTransfer.maxFileSize + else { return nil } + + let staged = directory.appendingPathComponent("\(UUID().uuidString).jsonl") + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.copyItem(at: file, to: staged) + } catch { + warning(.watch, "Copying a received watch log file failed", error: error) + return nil + } + return Batch(file: staged, installID: installID, firstSeq: firstSeq, lastSeq: lastSeq) + } + + // Returns the seq to acknowledge to the watch, once the records are stored + @discardableResult func ingest(_ batch: Batch) async -> UInt64 { + // The watch resends unacknowledged entries, so batches overlap; storing them one at a time keeps the seq filter exact + let store = lastStore.withLock { previous in + let store = Task { [previous] in + _ = await previous?.value + return await self.store(batch) + } + previous = store + return store + } + return await store.value + } + + private func store(_ batch: Batch) async -> UInt64 { + defer { try? FileManager.default.removeItem(at: batch.file) } + let previous = queue.sync { lastSeq(for: batch.installID) } + let data = (try? Data(contentsOf: batch.file)) ?? Data() + let fresh = WatchLogEntry.entries(fromJSONLines: data) + .filter { $0.seq > previous } + .sorted { $0.seq < $1.seq } + let sink = self.sink() + fresh.map(Self.record).forEach(sink.accept) + await sink.flush() + + // Only what was parsed and stored; a cut-off file gets the rest resent + let ack = queue.sync { + let ack = max(lastSeq(for: batch.installID), fresh.last?.seq ?? 0) + var lastSeqs = defaults.dictionary(forKey: Self.lastSeqKey) as? [String: NSNumber] ?? [:] + lastSeqs[batch.installID] = NSNumber(value: ack) + defaults.set(lastSeqs, forKey: Self.lastSeqKey) + return ack + } + ingestedBatches.withLock { $0 += 1 } + return ack + } + + private func lastSeq(for installID: String) -> UInt64 { + let lastSeqs = defaults.dictionary(forKey: Self.lastSeqKey) as? [String: NSNumber] ?? [:] + return lastSeqs[installID]?.uint64Value ?? 0 + } + + func receive(_ file: URL, metadata: [String: Any]?) { + guard metadata?[WatchLogTransfer.marker] != nil else { return } + guard let batch = Self.stage(file, metadata: metadata) else { + warning(.watch, "Rejected a watch log file with invalid metadata or over the size limit") + return + } + Task.detached(priority: .utility) { + let ack = await self.ingest(batch) + Self.send([WatchLogTransfer.ack: NSNumber(value: ack), WatchLogTransfer.installID: batch.installID]) + } + } + + // Asks the watch for its newest entries and waits briefly for them to land + func requestLatest(timeout: Duration = requestTimeout) async { + guard WCSession.isSupported() else { return } + let session = WCSession.default + guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return } + let before = ingestedBatches.withLock { $0 } + Self.send([WatchLogTransfer.request: true]) + guard session.isReachable else { return } + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + try? await Task.sleep(for: .milliseconds(100)) + if ingestedBatches.withLock({ $0 }) != before { return } + } + } + + private static func send(_ message: [String: Any]) { + let session = WCSession.default + guard session.activationState == .activated else { return } + if session.isReachable { + session.sendMessage(message, replyHandler: nil) { _ in + session.transferUserInfo(message) + } + } else { + session.transferUserInfo(message) + } + } + + static func record(_ entry: WatchLogEntry) -> LogRecord { + LogRecord( + timestamp: entry.date, + level: entry.level.logLevel, + category: categoryPrefix + entry.category.name, + message: entry.message, + file: "Watch", + function: "", + line: 0, + launchID: LaunchID.current + ) + } + + private static let legacyTimestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + return formatter + }() + + // String payloads from watch builds before structured logging + static func legacyRecords(from payload: String, receivedAt date: Date = Date()) -> [LogRecord] { + payload.split(whereSeparator: \.isNewline).compactMap { line in + var message = line.trimmingCharacters(in: .whitespaces) + guard !message.isEmpty else { return nil } + var timestamp = date + if message.hasPrefix("["), let close = message.firstIndex(of: "]"), + let parsed = legacyTimestampFormatter + .date(from: String(message[message.index(after: message.startIndex) ..< close])) + { + timestamp = parsed + message = message[message.index(after: close)...].trimmingCharacters(in: .whitespaces) + } + return LogRecord( + timestamp: timestamp, + level: .debug, + category: categoryPrefix + "Legacy", + message: message, + file: "Watch", + function: "", + line: 0, + launchID: LaunchID.current + ) + } + } +} + +private extension WatchLogEntry.Level { + var logLevel: LogLevel { + switch self { + case .debug: return .debug + case .info: return .info + case .warning: return .warning + case .error: return .error + case .fault: return .fault + } + } +} diff --git a/Trio/Sources/Logger/Watch/WatchLogRing.swift b/Trio/Sources/Logger/Watch/WatchLogRing.swift new file mode 100644 index 0000000000..49baacae41 --- /dev/null +++ b/Trio/Sources/Logger/Watch/WatchLogRing.swift @@ -0,0 +1,203 @@ +import Foundation +import os + +struct WatchLogDraft: Sendable { + let date: Date + let level: WatchLogEntry.Level + let category: WatchLogEntry.Category + let message: String +} + +// Append-only JSONL segments; whole segments are dropped once the ring is over its limits +final class WatchLogRing { + struct Limits { + var maxAge: TimeInterval = 2 * 24 * 60 * 60 + var maxEntries = 5000 + var maxBytes = 1_000_000 + var segmentEntries = 500 + var segmentBytes = 100_000 + } + + private struct Segment { + let url: URL + let firstSeq: UInt64 + var lastSeq: UInt64 + var count: Int + var bytes: Int + var lastDate: Date + var isOpen: Bool + } + + private enum Key { + static let nextSeq = "WatchLogRing.nextSeq" + static let ackedSeq = "WatchLogRing.ackedSeq" + static let installID = "WatchLogRing.installID" + } + + private static let osLog = os.Logger(subsystem: Bundle.main.bundleIdentifier ?? "Trio", category: "WatchLog") + + let directory: URL + let installID: String + private let defaults: UserDefaults + private let limits: Limits + private var segments: [Segment] = [] + private(set) var nextSeq: UInt64 + private(set) var ackedSeq: UInt64 + + var lastSeq: UInt64 { nextSeq - 1 } + var count: Int { segments.reduce(0) { $0 + $1.count } } + var bytes: Int { segments.reduce(0) { $0 + $1.bytes } } + + static var defaultDirectory: URL { + ( + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default + .temporaryDirectory + ) + .appendingPathComponent("WatchLogs", isDirectory: true) + } + + init(directory: URL = WatchLogRing.defaultDirectory, defaults: UserDefaults = .standard, limits: Limits = Limits()) { + self.directory = directory + self.defaults = defaults + self.limits = limits + + if let stored = defaults.string(forKey: Key.installID) { + installID = stored + } else { + installID = UUID().uuidString + defaults.set(installID, forKey: Key.installID) + } + + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var values = URLResourceValues() + values.isExcludedFromBackup = true + var url = directory + try url.setResourceValues(values) + } catch { + Self.osLog.error("Watch log directory setup failed: \(String(describing: error), privacy: .public)") + } + + nextSeq = max(1, (defaults.object(forKey: Key.nextSeq) as? NSNumber)?.uint64Value ?? 1) + ackedSeq = (defaults.object(forKey: Key.ackedSeq) as? NSNumber)?.uint64Value ?? 0 + segments = scan() + if let last = segments.last, last.lastSeq >= nextSeq { + nextSeq = last.lastSeq + 1 + } + ackedSeq = min(ackedSeq, lastSeq) + } + + func append(_ drafts: [WatchLogDraft]) { + var remaining = drafts[...] + while !remaining.isEmpty { + let index = writableSegment(startingAt: nextSeq, date: remaining[remaining.startIndex].date) + let room = max(1, limits.segmentEntries - segments[index].count) + let chunk = remaining.prefix(room) + remaining = remaining.dropFirst(chunk.count) + + let entries = chunk.enumerated().map { offset, draft in + WatchLogEntry( + seq: nextSeq + UInt64(offset), + date: draft.date, + level: draft.level, + category: draft.category, + message: draft.message + ) + } + let data = WatchLogEntry.jsonLines(entries) + do { + let handle = try FileHandle(forWritingTo: segments[index].url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: data) + } catch { + Self.osLog.error("Watch log append failed: \(String(describing: error), privacy: .public)") + segments[index].isOpen = false + continue + } + guard let last = entries.last else { continue } + segments[index].lastSeq = last.seq + segments[index].count += entries.count + segments[index].bytes += data.count + segments[index].lastDate = last.date + nextSeq = last.seq + 1 + } + defaults.set(NSNumber(value: nextSeq), forKey: Key.nextSeq) + } + + func entries(after seq: UInt64) -> [WatchLogEntry] { + segments.filter { $0.lastSeq >= $0.firstSeq && $0.lastSeq > seq }.flatMap { segment -> [WatchLogEntry] in + guard let data = try? Data(contentsOf: segment.url) else { return [] } + return WatchLogEntry.entries(fromJSONLines: data).filter { $0.seq > seq } + } + } + + func acknowledge(_ seq: UInt64) { + let seq = min(seq, lastSeq) + guard seq > ackedSeq else { return } + ackedSeq = seq + defaults.set(NSNumber(value: seq), forKey: Key.ackedSeq) + } + + func prune(now: Date = Date()) { + let cutoff = now.addingTimeInterval(-limits.maxAge) + var count = self.count + var bytes = self.bytes + while let oldest = segments.first { + let isLast = segments.count == 1 + let overLimit = !isLast && (count > limits.maxEntries || bytes > limits.maxBytes) + guard overLimit || oldest.lastDate < cutoff else { break } + do { + try FileManager.default.removeItem(at: oldest.url) + } catch { + Self.osLog.error("Watch log segment removal failed: \(String(describing: error), privacy: .public)") + } + segments.removeFirst() + count -= oldest.count + bytes -= oldest.bytes + } + } + + private func writableSegment(startingAt seq: UInt64, date: Date) -> Int { + if let last = segments.indices.last, segments[last].isOpen, + segments[last].count < limits.segmentEntries, + segments[last].bytes < limits.segmentBytes + { + return last + } + let url = directory.appendingPathComponent(String(format: "%020llu.jsonl", seq)) + if !FileManager.default.createFile(atPath: url.path, contents: nil) { + Self.osLog.error("Watch log segment creation failed") + } + segments.append(Segment(url: url, firstSeq: seq, lastSeq: seq - 1, count: 0, bytes: 0, lastDate: date, isOpen: true)) + return segments.count - 1 + } + + private func scan() -> [Segment] { + let files = (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey] + )) ?? [] + return files.filter { $0.pathExtension == "jsonl" }.compactMap { url -> Segment? in + guard let firstSeq = UInt64(url.deletingPathExtension().lastPathComponent), + let data = try? Data(contentsOf: url) + else { return nil } + let entries = WatchLogEntry.entries(fromJSONLines: data) + guard let last = entries.last else { + try? FileManager.default.removeItem(at: url) + return nil + } + return Segment( + url: url, + firstSeq: firstSeq, + lastSeq: last.seq, + count: entries.count, + bytes: data.count, + lastDate: last.date, + // A torn final line would swallow the next append + isOpen: data.last == 0x0A + ) + } + .sorted { $0.firstSeq < $1.firstSeq } + } +} diff --git a/Trio/Sources/Models/BGTargets.swift b/Trio/Sources/Models/BGTargets.swift index b29c7bee6b..e58108d196 100644 --- a/Trio/Sources/Models/BGTargets.swift +++ b/Trio/Sources/Models/BGTargets.swift @@ -31,7 +31,7 @@ extension BGTargets { for (index, entry) in targets.enumerated() { guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else { - debug(.default, "Invalid BG target entry start time: \(entry.start)") + warning(.settings, "Skipping glucose target with invalid start time \(entry.start)") continue } let components = calendar.dateComponents([.hour, .minute, .second], from: entryTime) diff --git a/Trio/Sources/Models/Icons.swift b/Trio/Sources/Models/Icons.swift index b4d7a0e330..ae12fbb880 100644 --- a/Trio/Sources/Models/Icons.swift +++ b/Trio/Sources/Models/Icons.swift @@ -32,7 +32,7 @@ class Icons: ObservableObject, Equatable { UIApplication.shared.setAlternateIconName(iconName) { error in if let error = error { - print("Failed request to update the app’s icon: \(error)") + warning(.ui, "Changing the app icon failed", error: error) } } diff --git a/Trio/Sources/Modules/AdaptProfile/AdaptProfileProvider.swift b/Trio/Sources/Modules/AdaptProfile/AdaptProfileProvider.swift index ce54e2e8f5..54d2b64977 100644 --- a/Trio/Sources/Modules/AdaptProfile/AdaptProfileProvider.swift +++ b/Trio/Sources/Modules/AdaptProfile/AdaptProfileProvider.swift @@ -77,7 +77,7 @@ extension AdaptProfile { ) } } catch { - debug(.coreData, "AdaptProfileProvider.fetchAll failed: \(error)") + warning(.storage, "Fetching profiles failed", error: error) return [] } } @@ -189,7 +189,7 @@ extension AdaptProfile { try context.save() } } catch { - debug(.coreData, "AdaptProfileProvider.applyOrdering failed: \(error)") + warning(.storage, "Saving profile order failed", error: error) } } } @@ -205,7 +205,7 @@ extension AdaptProfile { profile.name = trimmed try context.save() } catch { - debug(.coreData, "AdaptProfileProvider.rename failed: \(error)") + warning(.storage, "Renaming profile failed", error: error) } } } @@ -217,7 +217,7 @@ extension AdaptProfile { let request = ProfileStored.fetch(.profileByID(id), fetchLimit: 1) guard let profile = try context.fetch(request).first else { return } if profile.isActive { - debug(.coreData, "AdaptProfileProvider.delete: refusing to delete active profile") + warning(.settings, "Active profile cannot be deleted") return } // Cascade: any ProfileScheduleStored referencing this profile loses its target @@ -228,10 +228,7 @@ extension AdaptProfile { context.delete(schedule) } if !orphanedSchedules.isEmpty { - debug( - .coreData, - "AdaptProfileProvider.delete: cascaded \(orphanedSchedules.count) schedule(s) for profile \(id)" - ) + info(.settings, "Deleted profile removed \(orphanedSchedules.count) profile schedules") } context.delete(profile) try context.save() @@ -244,7 +241,7 @@ extension AdaptProfile { } } } catch { - debug(.coreData, "AdaptProfileProvider.delete failed: \(error)") + warning(.storage, "Deleting profile failed", error: error) } } } @@ -383,7 +380,7 @@ extension AdaptProfile { try viewContext.save() return true } catch { - debug(.coreData, "AdaptProfileProvider.activate CoreData flip failed: \(error)") + warning(.storage, "Saving active profile failed", error: error) return false } } @@ -407,7 +404,7 @@ extension AdaptProfile { do { try await apsManager.determineBasalSync() } catch { - debug(.default, "AdaptProfileProvider.activate loop trigger failed: \(error)") + warning(.loop, "Loop run after profile activation failed", error: error) // non-fatal — activation itself succeeded } @@ -438,7 +435,7 @@ extension AdaptProfile { try context.save() return true } catch { - debug(.coreData, "AdaptProfileProvider.updateProfile failed: \(error)") + warning(.storage, "Updating profile failed", error: error) return false } } @@ -506,7 +503,7 @@ extension AdaptProfile { try context.save() return newID } catch { - debug(.coreData, "AdaptProfileProvider.saveNewProfile failed: \(error)") + warning(.storage, "Saving new profile failed", error: error) return nil } } diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift index 2c62b85230..e6d9c01d0d 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift @@ -21,10 +21,11 @@ extension Adjustments.StateModel { guard viewContext.hasChanges else { return } try viewContext.save() + info(.treatments, "Override preset \"\(overrideToEnact.name ?? "Custom")\" started") updateLatestOverrideConfiguration() } catch { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to enact Override Preset") + warning(.treatments, "Starting override preset failed", error: error) } } @@ -75,10 +76,7 @@ extension Adjustments.StateModel { } } } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to disable active overrides: \(error)" - ) + warning(.treatments, "Stopping active overrides failed", error: error) } } @@ -129,6 +127,7 @@ extension Adjustments.StateModel { // Then save and activate a new custom Override try await overrideStorage.storeOverride(override: override) + info(.treatments, "Custom override started at \(Int(overridePercentage))% insulin") // Reset State variables await resetStateVariables() @@ -136,10 +135,7 @@ extension Adjustments.StateModel { // Update View updateLatestOverrideConfiguration() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to save custom override: \(error)" - ) + warning(.treatments, "Starting custom override failed", error: error) } } @@ -192,10 +188,7 @@ extension Adjustments.StateModel { setupOverridePresetsArray() try await nightscoutManager.uploadProfiles() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to save override preset: \(error)" - ) + warning(.treatments, "Saving override preset failed", error: error) } } @@ -208,10 +201,7 @@ extension Adjustments.StateModel { let ids = try await overrideStorage.fetchForOverridePresets() await updateOverridePresetsArray(with: ids) } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to setup override presets: \(error)" - ) + warning(.treatments, "Loading override presets failed", error: error) } } } @@ -224,9 +214,7 @@ extension Adjustments.StateModel { } overridePresets = overrideObjects } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to extract Overrides: \(error)" - ) + warning(.treatments, "Loading override preset objects failed", error: error) } } @@ -237,10 +225,7 @@ extension Adjustments.StateModel { setupOverridePresetsArray() try await nightscoutManager.uploadProfiles() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to delete override preset: \(error)" - ) + warning(.treatments, "Deleting override preset failed", error: error) } } @@ -263,10 +248,7 @@ extension Adjustments.StateModel { try await apsManager.determineBasalSync() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to update override configuration: \(error)" - ) + warning(.treatments, "Updating override state failed", error: error) } } } @@ -282,7 +264,7 @@ extension Adjustments.StateModel { await resetStateVariables() } } catch { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update latest Override configuration") + warning(.treatments, "Loading latest override failed", error: error) } } @@ -306,9 +288,7 @@ extension Adjustments.StateModel { ) } } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to set active Override: \(error)" - ) + warning(.treatments, "Loading active override failed", error: error) } } @@ -333,9 +313,7 @@ extension Adjustments.StateModel { ) } } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to cancel previous Override: \(error)" - ) + warning(.treatments, "Stopping previous override failed", error: error) } } diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift index ca8e44d0fc..aa48ef8a61 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift @@ -19,10 +19,7 @@ extension Adjustments.StateModel { try await apsManager.determineBasalSync() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to load latest temp target configuration with error: \(error)" - ) + warning(.treatments, "Updating temp target state failed", error: error) } } } @@ -38,7 +35,7 @@ extension Adjustments.StateModel { await resetTempTargetState() } } catch { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update latest temp target configuration") + warning(.treatments, "Loading latest temp target failed", error: error) } } @@ -63,9 +60,7 @@ extension Adjustments.StateModel { tempTargetTarget = tempTargetToEdit.target?.decimalValue ?? 0 } } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to set active preset name with error: \(error)" - ) + warning(.treatments, "Loading active temp target failed", error: error) } } @@ -82,10 +77,7 @@ extension Adjustments.StateModel { let tempTargetObjects = await fetchTempTargetObjects(for: ids) await updateFunction(tempTargetObjects) } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to setup temp targets: \(error)" - ) + warning(.treatments, "Loading temp targets failed", error: error) } } } @@ -97,7 +89,7 @@ extension Adjustments.StateModel { try viewContext.existingObject(with: id) as? TempTargetStored } } catch { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch Temp Targets") + warning(.treatments, "Loading temp target objects failed", error: error) return [] } } @@ -143,10 +135,9 @@ extension Adjustments.StateModel { let date = self.date guard date > Date() else { return } - let adjustmentType = halfBasalTarget == settingHalfBasalTarget ? "Standard" : "Custom" - debug( - .default, - "TempTarget: target=\(tempTargetTarget), HBT=\(settingHalfBasalTarget), effectiveHBT=\(halfBasalTarget), percentage=\(Int(percentage))%, adjustmentType=\(adjustmentType)" + info( + .treatments, + "Temp target scheduled: target \(tempTargetTarget) mg/dL for \(tempTargetDuration) min, half basal target \(halfBasalTarget) mg/dL, insulin \(Int(percentage))%" ) let tempTarget = TempTarget( name: tempTargetName, @@ -173,7 +164,7 @@ extension Adjustments.StateModel { do { let ids = try await tempTargetStorage.fetchScheduledTempTarget(for: date) guard let firstID = ids.first else { - debug(.default, "No Temp Target found for the specified date.") + debug(.treatments, "No scheduled temp target found for \(date)") return } await setCurrentTempTarget(from: ids) @@ -191,13 +182,11 @@ extension Adjustments.StateModel { try viewContext.save() isTempTargetEnabled = true } + info(.treatments, "Scheduled temp target started") setupScheduledTempTargetsArray() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to enable scheduled temp target: \(error)" - ) + warning(.treatments, "Starting scheduled temp target failed", error: error) } } @@ -213,10 +202,9 @@ extension Adjustments.StateModel { /// Saves a custom Temp Target and disables existing ones. func saveCustomTempTarget() async throws { await disableAllActiveTempTargets(createTempTargetRunEntry: true) - let adjustmentType = halfBasalTarget == settingHalfBasalTarget ? "Standard" : "Custom" - debug( - .default, - "TempTarget: target=\(tempTargetTarget), HBT=\(settingHalfBasalTarget), effectiveHBT=\(halfBasalTarget), percentage=\(Int(percentage))%, adjustmentType=\(adjustmentType)" + info( + .treatments, + "Temp target started: target \(tempTargetTarget) mg/dL for \(tempTargetDuration) min, half basal target \(halfBasalTarget) mg/dL, insulin \(Int(percentage))%" ) let tempTarget = TempTarget( name: tempTargetName, @@ -240,10 +228,9 @@ extension Adjustments.StateModel { /// Creates a new Temp Target preset. func saveTempTargetPreset() async throws { - let adjustmentType = halfBasalTarget == settingHalfBasalTarget ? "Standard" : "Custom" debug( - .default, - "TempTarget: target=\(tempTargetTarget), HBT=\(settingHalfBasalTarget), effectiveHBT=\(halfBasalTarget), percentage=\(Int(percentage))%, adjustmentType=\(adjustmentType)" + .treatments, + "Temp target preset saved: target \(tempTargetTarget) mg/dL for \(tempTargetDuration) min, half basal target \(halfBasalTarget) mg/dL, insulin \(Int(percentage))%" ) let tempTarget = TempTarget( name: tempTargetName, @@ -277,6 +264,7 @@ extension Adjustments.StateModel { if viewContext.hasChanges { try viewContext.save() } + info(.treatments, "Temp target preset \"\(tempTargetToEnact.name ?? "Custom")\" started") updateLatestTempTargetConfiguration() @@ -294,7 +282,7 @@ extension Adjustments.StateModel { ) tempTargetStorage.saveTempTargetsToStorage([tempTarget]) } catch { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to enact TempTarget Preset") + warning(.treatments, "Starting temp target preset failed", error: error) } } @@ -347,10 +335,7 @@ extension Adjustments.StateModel { } } } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to disable active temp targets: \(error)" - ) + warning(.treatments, "Stopping active temp targets failed", error: error) } } @@ -382,9 +367,7 @@ extension Adjustments.StateModel { ) } } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to cancel previous override with error: \(error)" - ) + warning(.treatments, "Stopping previous temp target failed", error: error) } } diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift index 0831966b8d..7973d22909 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift @@ -199,9 +199,7 @@ extension Adjustments { setupOverridePresetsArray() try await nightscoutManager.uploadProfiles() } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Override Presets order or upload profiles" - ) + warning(.treatments, "Saving override preset order or uploading profiles failed", error: error) } } } @@ -217,7 +215,7 @@ extension Adjustments { try viewContext.save() setupTempTargetPresetsArray() } catch { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Temp Target Presets order") + warning(.treatments, "Saving temp target preset order failed", error: error) } } } diff --git a/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift index 2a12cd7019..8b0e8b5d75 100644 --- a/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift +++ b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift @@ -254,6 +254,7 @@ extension Adjustments { Task { // Save cancelled Override in OverrideRunStored Entity // Cancel ALL active Override + info(.treatments, "Override stopped") await state.disableAllActiveOverrides(createOverrideRunEntry: true) } } @@ -268,6 +269,7 @@ extension Adjustments { Task { // Save cancelled Temp Targets in TempTargetRunStored Entity // Cancel ALL active Temp Targets + info(.treatments, "Temp target stopped") await state.disableAllActiveTempTargets(createTempTargetRunEntry: true) // Update View state.updateLatestTempTargetConfiguration() diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift index c2705013dd..ca8a2e4993 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift @@ -580,13 +580,10 @@ struct EditOverrideForm: View { do { try await state.nightscoutManager.uploadProfiles() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) failed to upload profiles to Nightscout: \(error)" - ) + warning(.nightscout, "Uploading profile after override edit failed", error: error) } } catch { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to edit Override") + warning(.treatments, "Saving edited override failed", error: error) } } }, label: { diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift index f35a74cae2..a71b5ed764 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift @@ -301,7 +301,7 @@ struct AddTempTargetForm: View { try await state.invokeSaveOfCustomTempTargets() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) failed to save custom temp target: \(error)") + warning(.treatments, "Saving temp target failed", error: error) } } }, label: { @@ -322,7 +322,7 @@ struct AddTempTargetForm: View { try await state.saveTempTargetPreset() dismiss() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) failed to save temp target preset: \(error)") + warning(.treatments, "Saving temp target preset failed", error: error) } } }, label: { @@ -365,7 +365,7 @@ struct AddTempTargetForm: View { guard let targetValue = NSDecimalNumber(decimal: target).doubleValue as Double?, let stepValue = NSDecimalNumber(decimal: step).doubleValue as Double? else { - print("Failed to unwrap target or step as NSDecimalNumber") + warning(.treatments, "Rounding temp target to step failed") return target } diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift index f7737a132f..e468e69f23 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift @@ -335,9 +335,9 @@ struct EditTempTargetForm: View { Spacer() Button(action: { saveChanges() - debug( - .default, - "TempTarget: target=\(target), HBT=\(state.settingHalfBasalTarget), effectiveHBT=\(String(describing: halfBasalTarget)), percentage=\(Int(percentage))%, adjustmentType=\(tempTargetSensitivityAdjustmentType.rawValue)" + info( + .treatments, + "Temp target edited: target \(target) mg/dL, half basal target \(halfBasalTarget.map { "\($0) mg/dL" } ?? "default"), insulin \(Int(percentage))%" ) do { guard let moc = tempTarget.managedObjectContext else { return } @@ -379,7 +379,7 @@ struct EditTempTargetForm: View { hasChanges = false presentationMode.wrappedValue.dismiss() } catch { - debugPrint("Failed to Edit Temp Target") + warning(.treatments, "Saving edited temp target failed", error: error) } }, label: { Text("Save") diff --git a/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift b/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift index ca15d2d466..8c9ca59689 100644 --- a/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift +++ b/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift @@ -119,7 +119,7 @@ extension AppDiagnostics { if let url = URL(string: "https://github.com/nightscout/Trio/blob/dev/PRIVACY_POLICY.md") { openURL(url) } else { - debug(.default, "Invalid URL! Could not gracefully unwrap privacy policy link!") + fault(.ui, "Privacy policy URL is invalid") } } } diff --git a/Trio/Sources/Modules/AutoISFHistory/AutoISFHistoryStateModel.swift b/Trio/Sources/Modules/AutoISFHistory/AutoISFHistoryStateModel.swift index f6646e26db..88025be8fd 100644 --- a/Trio/Sources/Modules/AutoISFHistory/AutoISFHistoryStateModel.swift +++ b/Trio/Sources/Modules/AutoISFHistory/AutoISFHistoryStateModel.swift @@ -84,7 +84,7 @@ extension AutoISFHistory { } } } catch { - debugPrint("Error fetching auto ISF records: \(error.localizedDescription)") + warning(.storage, "Fetching autoISF records failed", error: error) return [] } } @@ -93,7 +93,7 @@ extension AutoISFHistory { do { autoISFEntries = try await fetchedAutoISF() } catch { - debugPrint("Error creating auto ISF entries: \(error.localizedDescription)") + warning(.storage, "Loading autoISF history failed", error: error) autoISFEntries = [] } } diff --git a/Trio/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift b/Trio/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift index 20721ffb44..954086f2fa 100644 --- a/Trio/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift +++ b/Trio/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift @@ -47,10 +47,7 @@ extension AutosensSettings { ) await updateDeterminationsArray(with: ids) } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Error fetching determination IDs: \(error)" - ) + warning(.ui, "Fetching determinations for autosens screen failed", error: error) } } } @@ -63,9 +60,7 @@ extension AutosensSettings { determinationsFromPersistence = objects } catch { - debugPrint( - "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the glucose array: \(error)" - ) + warning(.ui, "Loading determinations for autosens screen failed", error: error) } } } diff --git a/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift b/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift index 234002b4c5..183f641e7f 100644 --- a/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift +++ b/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift @@ -40,6 +40,7 @@ extension AutotuneConfig { promise(.success(true)) } catch { + warning(.settings, "Rebuilding profiles after Autotune change failed", error: error) promise(.success(false)) } } @@ -59,7 +60,7 @@ extension AutotuneConfig { lastAutotuneDate = Date() } } catch { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to run Autotune") + warning(.settings, "Running Autotune failed", error: error) } } } @@ -70,6 +71,7 @@ extension AutotuneConfig { do { _ = try await apsManager.makeProfiles() } catch { + warning(.settings, "Rebuilding profiles after deleting Autotune failed", error: error) return } } @@ -86,7 +88,7 @@ extension AutotuneConfig { } guard let pump = apsManager.pumpManager else { scope.basalProfile = basals - debug(.service, "Basals have been replaced with Autotuned Basals by user.") + info(.settings, "Basal profile replaced with Autotune basals") return } let syncValues = basals.map { @@ -96,9 +98,9 @@ extension AutotuneConfig { switch result { case .success: self.scope.basalProfile = basals - debug(.service, "Basals saved to pump!") - case .failure: - debug(.service, "Basals couldn't be save to pump") + info(.settings, "Basal profile replaced with Autotune basals and saved to pump") + case let .failure(syncError): + error(.settings, "Saving Autotune basals to pump failed", error: syncError) } } } diff --git a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift index c6feac3cb9..3f6bef9f54 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift @@ -23,7 +23,7 @@ extension BasalProfileEditor { func saveProfile(_ profile: [BasalProfileEntry]) -> AnyPublisher { guard let pump = deviceManager?.pumpManager else { - debugPrint("\(DebuggingIdentifiers.failed) No pump found; cannot save basal profile!") + warning(.settings, "Basal profile not saved, no pump connected") return Fail(error: NSError()).eraseToAnyPublisher() } diff --git a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift index d812ac4264..9c73e89570 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift @@ -204,6 +204,9 @@ extension BasalProfileEditor { self.roundedRateIndices.removeAll() self.roundingHint = false self.originalRates.removeAll() + info(.settings, "Basal profile saved with \(profile.count) entries") + // Successfully saved and synced + self.initialItems = self.items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) } DispatchQueue.main.async { [weak self] in self?.broadcaster.notify(BasalProfileObserver.self, on: .main) { @@ -214,23 +217,22 @@ extension BasalProfileEditor { Task.detached(priority: .low) { [weak self] in guard let self else { return } do { - debug(.nightscout, "Attempting to upload basal rates to Nightscout") try await self.nightscout.uploadProfiles() } catch { - debug(.default, "Failed to upload basal rates to Nightscout: \(error)") + warning(.nightscout, "Uploading profile after basal change failed", error: error) } } Task.detached(priority: .low) { [weak self] in await self?.tidepoolManager.uploadSettings() } - case .failure: + case let .failure(saveError): + error(.settings, "Saving basal profile failed", error: saveError) // Handle the error, show error message self.showAlert = true } } receiveValue: { // Handle any successful value if needed - print("We were successful") } .store(in: &lifetime) } diff --git a/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift b/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift index ed77d180a5..fd955a92e0 100644 --- a/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift +++ b/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift @@ -44,6 +44,7 @@ extension CGMSettings { @Injected() var broadcaster: Broadcaster! @Injected() var nightscoutManager: NightscoutManager! @Injected() var bluetoothManager: BluetoothStateManager! + @Injected() var logExport: LogExport! @Published var units: GlucoseUnits = .mgdL @Published var shouldDisplayCGMSetupSheet: Bool = false @@ -156,7 +157,7 @@ extension CGMSettings.StateModel: CompletionDelegate { extension CGMSettings.StateModel: CGMManagerOnboardingDelegate { func cgmManagerOnboarding(didCreateCGMManager manager: LoopKitUI.CGMManagerUI) { // cgmCurrent should have been set in addCGM - debug(.service, "didCreateCGMManager called \(cgmCurrent)") + info(.cgm, "CGM \(cgmCurrent.displayName) added") settingsManager.settings.cgm = cgmCurrent.type settingsManager.settings.cgmPluginIdentifier = cgmCurrent.id fetchGlucoseManager.updateGlucoseSource( diff --git a/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift b/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift index ae06845a82..ca3650ceb4 100644 --- a/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift +++ b/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift @@ -193,6 +193,20 @@ extension CGMSettings { }.listRowBackground(Color.chart) } + if state.cgmCurrent.type == .plugin, + state.fetchGlucoseManager?.cgmManager != nil, + state.cgmCurrent.id == state.fetchGlucoseManager?.cgmGlucosePluginId + { + Section { + LogShareRow( + title: "Share CGM Logs", + footnote: "Creates one file you can send when asking for help.", + kinds: [.cgm], + logExport: state.logExport + ) + }.listRowBackground(Color.chart) + } + smoothGlucoseSection smoothingAlgorithmSection diff --git a/Trio/Sources/Modules/CGMSettings/View/CGMSetupView.swift b/Trio/Sources/Modules/CGMSettings/View/CGMSetupView.swift index 0194c0f3c9..c5a0b1ce15 100644 --- a/Trio/Sources/Modules/CGMSettings/View/CGMSetupView.swift +++ b/Trio/Sources/Modules/CGMSettings/View/CGMSetupView.swift @@ -49,7 +49,7 @@ extension CGMSettings { setupViewControllerUI.completionDelegate = completionDelegate return setupViewControllerUI case let .createdAndOnboarded(cgmManagerUI): - debug(.default, "CGM manager created and onboarded") + debug(.cgm, "CGM setup finished") setupDelegate?.cgmManagerOnboarding(didCreateCGMManager: cgmManagerUI) return UIViewController() case .none: diff --git a/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift b/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift index 8f966e661d..bb2e17ae84 100644 --- a/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift +++ b/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift @@ -461,7 +461,7 @@ extension CGMSettings { Slider(value: $workInterval, in: 60 ... 300, step: 30) .accentColor(.accentColor) .onChange(of: workInterval) { _, newValue in - print("Setting work interval to: \(newValue) seconds") + debug(.cgm, "Glucose simulator interval set to \(Int(newValue)) s") UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_WorkInterval") } .padding(.vertical) diff --git a/Trio/Sources/Modules/CSFEditor/CSFEditorStateModel.swift b/Trio/Sources/Modules/CSFEditor/CSFEditorStateModel.swift index 81f452933e..dd8af90900 100644 --- a/Trio/Sources/Modules/CSFEditor/CSFEditorStateModel.swift +++ b/Trio/Sources/Modules/CSFEditor/CSFEditorStateModel.swift @@ -106,13 +106,10 @@ extension CSFEditor { Task.detached(priority: .low) { do { - debug(.nightscout, "Attempting to upload CSF to Nightscout") + debug(.nightscout, "Uploading CSF profile to Nightscout") try await self.nightscout.uploadProfiles() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to upload CSF to Nightscout: \(error)" - ) + warning(.nightscout, "Uploading CSF profile to Nightscout failed", error: error) } } } diff --git a/Trio/Sources/Modules/Calibrations/CalibrationsStateModel.swift b/Trio/Sources/Modules/Calibrations/CalibrationsStateModel.swift index 1e234621e5..c86d08cf32 100644 --- a/Trio/Sources/Modules/Calibrations/CalibrationsStateModel.swift +++ b/Trio/Sources/Modules/Calibrations/CalibrationsStateModel.swift @@ -78,13 +78,14 @@ extension Calibrations { let calibration = Calibration(x: Double(unfiltered), y: Double(glucose)) calibrationService.addCalibration(calibration) + info(.cgm, "Calibration added: BG \(glucose) mg/dL") } else { - debug(.service, "Glucose is stale for calibration") + debug(.cgm, "Calibration not added, no recent glucose") issueStaleGlucoseAlert() return } } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to add calibration: \(error)") + warning(.cgm, "Adding calibration failed", error: error) } } diff --git a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift index 8a464bb35e..baf17a9d89 100644 --- a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift +++ b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift @@ -94,6 +94,7 @@ extension CarbRatioEditor { } let profile = CarbRatios(units: .grams, schedule: schedule) provider.saveProfile(profile) + info(.settings, "Carb ratio schedule saved with \(schedule.count) entries") initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) } DispatchQueue.main.async { @@ -104,10 +105,9 @@ extension CarbRatioEditor { Task.detached(priority: .low) { do { - debug(.nightscout, "Attempting to upload CRs to Nightscout") try await self.nightscout.uploadProfiles() } catch { - debug(.default, "Failed to upload CRs to Nightscout: \(error)") + warning(.nightscout, "Uploading profile after carb ratio change failed", error: error) } } diff --git a/Trio/Sources/Modules/ContactImage/ContactImageStateModel.swift b/Trio/Sources/Modules/ContactImage/ContactImageStateModel.swift index a792f8cad4..cd798c249b 100644 --- a/Trio/Sources/Modules/ContactImage/ContactImageStateModel.swift +++ b/Trio/Sources/Modules/ContactImage/ContactImageStateModel.swift @@ -65,7 +65,7 @@ extension ContactImage { // Contact was deleted in iOS, remove from Core Data if let objectID = entry.managedObjectID { await contactImageStorage.deleteContactImageEntry(objectID) - debugPrint("Removed orphaned contact entry: \(entry.name)") + debug(.ui, "Removed contact image entry whose contact was deleted") } } } @@ -82,13 +82,13 @@ extension ContactImage { // 1. Check for contact access permissions. let hasAccess = await contactImageManager.requestAccess() guard hasAccess else { - debugPrint("\(DebuggingIdentifiers.failed) No access to contacts.") + warning(.ui, "Contact image not created, no access to contacts") return } // 2. Create the contact and retrieve its `identifier`. guard let contactId = await contactImageManager.createContact(name: name) else { - debugPrint("\(DebuggingIdentifiers.failed) Failed to create contact.") + warning(.ui, "Creating contact for contact image failed") return } @@ -116,16 +116,16 @@ extension ContactImage { /// - Parameter entry: The ContactImageEntry representing the contact to be deleted. func deleteContact(entry: ContactImageEntry) async { guard let contactId = entry.contactId else { - debugPrint("\(DebuggingIdentifiers.failed) Contact does not have a valid ID.") + warning(.ui, "Contact image entry has no contact ID") return } // 1. Attempt to delete the contact from Apple Contacts. let contactDeleted = await contactImageManager.deleteContact(withIdentifier: contactId) if contactDeleted { - debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully deleted from Apple Contacts: \(contactId)") + debug(.ui, "Contact for contact image deleted") } else { - debugPrint("\(DebuggingIdentifiers.failed) Failed to delete contact from Apple Contacts. Check if it exists.") + warning(.ui, "Deleting contact for contact image failed") } // 2. Delete the entry from Core Data. @@ -146,7 +146,7 @@ extension ContactImage { /// - entry: The ContactImageEntry to be updated. func updateContact(with entry: ContactImageEntry) async { guard let contactId = entry.contactId else { - debugPrint("\(DebuggingIdentifiers.failed) Contact does not have a valid ID.") + warning(.ui, "Contact image entry has no contact ID") return } @@ -160,7 +160,7 @@ extension ContactImage { .updateContact(withIdentifier: contactId, newName: entry.name) // TODO: - Probably not needed anymore guard contactUpdated else { - debugPrint("\(DebuggingIdentifiers.failed) Failed to update contact.") + warning(.ui, "Updating contact for contact image failed") return } diff --git a/Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift b/Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift index 585358a950..c44516f2e0 100644 --- a/Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift +++ b/Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift @@ -74,7 +74,7 @@ extension DynamicSettings { self.hasValidTDD = hasValidTDD } } catch { - debug(.coreData, "Error when fetching TDD for validity checking: \(error)") + warning(.settings, "Checking total daily dose history for dynamic ISF failed", error: error) await MainActor.run { hasValidTDD = false } diff --git a/Trio/Sources/Modules/Export/ExportStateModel.swift b/Trio/Sources/Modules/Export/ExportStateModel.swift index b9304eb414..740f3d0d5a 100644 --- a/Trio/Sources/Modules/Export/ExportStateModel.swift +++ b/Trio/Sources/Modules/Export/ExportStateModel.swift @@ -135,21 +135,15 @@ extension Export { categories: Set? = nil, format: ExportFormat? = nil ) async -> Result { - debug(.default, "🔄 EXPORT: Starting settings export...") + info(.settings, "Settings export started") let categoriesToExport = categories ?? selectedCategories let exportFormat = format ?? selectedFormat debug( - .default, - "🔄 EXPORT: Exporting categories: \(categoriesToExport.map(\.rawValue).joined(separator: ", ")) in \(exportFormat.rawValue) format" + .settings, + "Exporting categories \(categoriesToExport.map(\.rawValue).joined(separator: ", ")) as \(exportFormat.rawValue)" ) - debug( - .default, - "🔄 EXPORT: CoreData stack status - persistentContainer: \(CoreDataStack.shared.persistentContainer.description)" - ) - debug(.default, "🔄 EXPORT: ViewContext status: \(CoreDataStack.shared.persistentContainer.viewContext.description)") - let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" let timestamp = formatter.string(from: Date()) @@ -160,7 +154,6 @@ extension Export { return .failure(.documentsDirectoryNotFound) } let fileURL = documentsDirectory.appendingPathComponent(fileName) - debug(.default, "Export file path: \(fileURL.path)") // Data structure to hold export data struct ExportSetting { @@ -183,7 +176,6 @@ extension Export { let trioSettings = settingsManager.settings let preferences = settingsManager.preferences - debug(.default, "🔄 EXPORT: Settings managers initialized") // Helper function to add a setting func addSetting(category: String, subcategory: String = "", name: String, value: String, unit: String = "") { @@ -1289,9 +1281,8 @@ extension Export { let presetsCategory = String(localized: "Temp Target Presets") // Temp Target Presets (from Core Data) - debug(.default, "🔄 EXPORT: Fetching temp target presets...") let tempTargetPresetIDs = (try? await tempTargetsStorage.fetchForTempTargetPresets()) ?? [] - debug(.default, "🔄 EXPORT: Found \(tempTargetPresetIDs.count) temp target preset IDs") + debug(.settings, "Found \(tempTargetPresetIDs.count) temp target presets to export") if !tempTargetPresetIDs.isEmpty { let tempTargetSubcategory = String(localized: "Temp Target Presets") @@ -1305,7 +1296,7 @@ extension Export { halfBasalTarget: Decimal? )? in guard let preset = try? viewContext.existingObject(with: id) as? TempTargetStored else { - debug(.default, "Could not retrieve temp target with ID: \(id)") + debug(.settings, "Temp target preset could not be loaded, skipping it") return nil } return ( @@ -1317,7 +1308,6 @@ extension Export { } } - debug(.default, "Successfully extracted \(presetData.count) temp target presets") for preset in presetData { if let target = preset.target { @@ -1361,8 +1351,6 @@ extension Export { // Override Presets (from Core Data) do { - debug(.default, "🔄 EXPORT: Fetching override presets...") - debug(.default, "🔄 EXPORT: OverrideStorage instance: \(overrideStorage)") // Ensure Core Data is fully initialized - wait longer on first run var retryCount = 0 @@ -1370,28 +1358,24 @@ extension Export { while retryCount < 3 { do { - debug(.default, "🔄 EXPORT: Attempt \(retryCount + 1) to fetch override presets...") overridePresetIDs = try await overrideStorage.fetchForOverridePresets() - debug(.default, "✅ EXPORT: Successfully fetched override presets on attempt \(retryCount + 1)") break // Success, exit retry loop } catch { - debug(.default, "❌ EXPORT: Attempt \(retryCount + 1) failed: \(error.localizedDescription)") - debug(.default, "❌ EXPORT: Full error: \(error)") + warning(.settings, "Fetching override presets failed on attempt \(retryCount + 1)", error: error) retryCount += 1 if retryCount < 3 { // Wait progressively longer between retries - debug(.default, "🔄 EXPORT: Waiting \(retryCount * 200)ms before retry...") do { try await Task.sleep(nanoseconds: UInt64(retryCount * 200_000_000)) // 0.2s, 0.4s } catch { - debug(.default, "⚠️ EXPORT: Sleep interrupted: \(error)") + warning(.settings, "Waiting to retry the override preset fetch was interrupted", error: error) // Sleep interrupted, continue } } } } - debug(.default, "🔄 EXPORT: Found \(overridePresetIDs.count) override preset IDs") + debug(.settings, "Found \(overridePresetIDs.count) override presets to export") if !overridePresetIDs.isEmpty { let overrideSubcategory = String(localized: "Override Presets") // Convert NSManagedObjectIDs to actual OverrideStored objects and extract their data within the Core Data context @@ -1420,7 +1404,7 @@ extension Export { )? in do { guard let preset = try viewContext.existingObject(with: id) as? OverrideStored else { - debug(.default, "Could not retrieve override with ID: \(id)") + debug(.settings, "Override preset could not be loaded, skipping it") return nil } return ( @@ -1441,13 +1425,12 @@ extension Export { uamMinutes: preset.uamMinutes?.decimalValue ) } catch { - debug(.default, "Error accessing override preset: \(error)") + warning(.settings, "Loading override preset failed", error: error) return nil } } } - debug(.default, "Successfully extracted \(presetData.count) override presets") for preset in presetData { addSetting( @@ -1573,7 +1556,7 @@ extension Export { } } } catch { - debug(.default, "Failed to fetch override presets: \(error)") + warning(.settings, "Fetching override presets failed", error: error) } } @@ -1583,7 +1566,6 @@ extension Export { // Meal Presets (from Core Data) do { - debug(.default, "Fetching meal presets...") let viewContext = CoreDataStack.shared.persistentContainer.viewContext let mealPresetData = try await viewContext.perform { @@ -1600,7 +1582,7 @@ extension Export { } } - debug(.default, "Found \(mealPresetData.count) meal presets") + debug(.settings, "Found \(mealPresetData.count) meal presets to export") if !mealPresetData.isEmpty { let mealSubcategory = String(localized: "Meal Presets") @@ -1637,7 +1619,7 @@ extension Export { } } } catch { - debug(.default, "Failed to fetch meal presets: \(error)") + warning(.settings, "Fetching meal presets failed", error: error) } } @@ -1739,47 +1721,40 @@ extension Export { content = String(data: jsonDataEncoded, encoding: .utf8) ?? "{}" } - debug( - .default, - "📝 EXPORT: Writing \(exportFormat.rawValue) content (\(content.count) characters) to file: \(fileURL.path)" - ) - debug(.default, "📝 EXPORT: Temporary directory: \(FileManager.default.temporaryDirectory.path)") - debug(.default, "📝 EXPORT: File URL: \(fileURL)") + debug(.settings, "Writing \(exportFormat.rawValue) settings export, \(content.count) characters") try content.write(to: fileURL, atomically: true, encoding: .utf8) - debug(.default, "✅ EXPORT: Content written to file successfully") // Set file attributes for better sharing compatibility try fileManager.setAttributes([ .posixPermissions: 0o644, .extensionHidden: false ], ofItemAtPath: fileURL.path) - debug(.default, "✅ EXPORT: File attributes set successfully") // Verify file was written successfully let fileExists = fileManager.fileExists(atPath: fileURL.path) let fileSize = try? fileManager.attributesOfItem(atPath: fileURL.path)[.size] as? Int ?? 0 - debug(.default, "📊 EXPORT: File verification - Exists: \(fileExists), Size: \(fileSize ?? 0) bytes") if !fileExists { - debug(.default, "❌ EXPORT: CRITICAL - File does not exist after writing!") + error(.settings, "Settings export file is missing after writing") return .failure(.unknown("File was not created successfully")) } if (fileSize ?? 0) == 0 { - debug(.default, "❌ EXPORT: CRITICAL - File exists but has 0 bytes!") + error(.settings, "Settings export file is empty after writing") return .failure(.unknown("File was created but is empty")) } if !fileExists { - debug(.default, "File was not created at expected location: \(fileURL.path)") + error(.settings, "Settings export file is missing after writing") return .failure(.unknown("File was not created successfully")) } + info(.settings, "Settings export written, \(fileSize ?? 0) bytes") return .success(fileURL) - } catch { - debug(.default, "Failed to write settings export file: \(error)") - return .failure(.fileWriteError(error)) + } catch let writeError { + error(.settings, "Writing settings export file failed", error: writeError) + return .failure(.fileWriteError(writeError)) } } diff --git a/Trio/Sources/Modules/Export/View/ExportRootView.swift b/Trio/Sources/Modules/Export/View/ExportRootView.swift index c79ada1b68..940fec2f00 100644 --- a/Trio/Sources/Modules/Export/View/ExportRootView.swift +++ b/Trio/Sources/Modules/Export/View/ExportRootView.swift @@ -165,52 +165,45 @@ extension Export { } else { Button("Export") { Task { - print("🚀 UI: Export button tapped") // Start loading spinner state.isExporting = true - print("🚀 UI: Loading spinner started") switch await state.exportSelectedSettings() { case let .success(fileURL): - print("✅ UI: Export returned success with URL: \(fileURL)") if FileManager.default.fileExists(atPath: fileURL.path) { do { let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) let fileSize = attributes[.size] as? Int ?? 0 - print("📊 UI: Export file size: \(fileSize) bytes at \(fileURL.path)") if fileSize > 0 { - print("✅ UI: File validation passed, setting up share sheet") exportedFileURL = fileURL // Stop spinner on successful export state.isExporting = false - print("🔄 UI: Loading spinner stopped") showSettingsExport = true - print("📤 UI: Share sheet triggered") } else { - print("❌ UI: File is empty") + error(.settings, "Settings export file is empty") exportErrorMessage = "Export file is empty (0 bytes)" showExportError = true // Stop spinner on error state.isExporting = false } - } catch { - print("❌ UI: Could not verify file attributes: \(error)") - exportErrorMessage = "Could not verify file attributes: \(error.localizedDescription)" + } catch let attributesError { + error(.settings, "Reading settings export file attributes failed", error: attributesError) + exportErrorMessage = "Could not verify file attributes: \(attributesError.localizedDescription)" showExportError = true // Stop spinner on error state.isExporting = false } } else { - print("❌ UI: File does not exist at expected path: \(fileURL.path)") + error(.settings, "Settings export file not found after export") exportErrorMessage = "Export file was created but could not be found at: \(fileURL.path)" showExportError = true // Stop spinner on error state.isExporting = false } - case let .failure(error): - print("❌ UI: Export failed with error: \(error)") - exportErrorMessage = error.localizedDescription + case let .failure(exportError): + error(.settings, "Settings export failed", error: exportError) + exportErrorMessage = exportError.localizedDescription showExportError = true // Stop spinner on error state.isExporting = false diff --git a/Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift b/Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift index 3004c796ba..5412ae060b 100644 --- a/Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift +++ b/Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift @@ -266,7 +266,7 @@ private struct TonePickerRow: View { player = p playing = filename } catch { - debug(.service, "Audio preview failed for \(filename): \(error)") + warning(.alerts, "Alarm sound preview failed for \(filename)", error: error) } } diff --git a/Trio/Sources/Modules/HealthKit/HealthKitStateModel.swift b/Trio/Sources/Modules/HealthKit/HealthKitStateModel.swift index 534119ee42..7234be3048 100644 --- a/Trio/Sources/Modules/HealthKit/HealthKitStateModel.swift +++ b/Trio/Sources/Modules/HealthKit/HealthKitStateModel.swift @@ -35,12 +35,12 @@ extension AppleHealthKit { } if permissionGranted { - debug(.service, "Permission granted for HealthKitManager") + info(.healthKit, "Apple Health permission granted") } else { - warning(.service, "Permission not granted for HealthKitManager") + info(.healthKit, "Apple Health permission not granted") } } catch { - warning(.service, "Error requesting permission for HealthKitManager", error: error) + warning(.healthKit, "Requesting Apple Health permission failed", error: error) } } } diff --git a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift index e51b02be9b..05ec1a066f 100644 --- a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift +++ b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift @@ -36,12 +36,13 @@ extension History.StateModel { ) _ = try await carbEntryMutationService.replaceMeal(rootObjectID: treatmentObjectID, with: newEntry) + info(.treatments, "Meal edited: carbs \(newCarbs) g, fat \(newFat) g, protein \(newProtein) g") // Perform a determine basal sync to update cob try await apsManager.determineBasalSync() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) failed to update entry: \(error)") + warning(.treatments, "Editing meal failed", error: error) } } } @@ -70,7 +71,7 @@ extension History.StateModel { date: entryDate ) } catch { - debugPrint("\(DebuggingIdentifiers.failed) Failed to load entry: \(error)") + warning(.treatments, "Loading meal for editing failed", error: error) return nil } } @@ -134,11 +135,11 @@ extension History.StateModel { request.fetchLimit = 1 let originalEntry = try context.fetch(request).first - debugPrint("FPU fetch result: \(originalEntry != nil ? "Entry found" : "No entry found")") + debug(.treatments, "Original meal for fat and protein entry \(originalEntry != nil ? "found" : "not found")") return originalEntry?.objectID } catch let error as NSError { - debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch original FPU entry: \(error.userInfo)") + warning(.treatments, "Fetching original meal for fat and protein entry failed", error: error) return nil } } @@ -171,13 +172,11 @@ extension History.StateModel { request.fetchLimit = 1 let correspondingCarbEntry = try context.fetch(request).first - debugPrint( - "Corresponding carb entry fetch result: \(correspondingCarbEntry != nil ? "Entry found" : "No entry found")" - ) + debug(.treatments, "Matching carb entry \(correspondingCarbEntry != nil ? "found" : "not found")") return correspondingCarbEntry?.objectID } catch let error as NSError { - debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch corresponding carb entry: \(error.userInfo)") + warning(.treatments, "Fetching matching carb entry failed", error: error) return nil } } diff --git a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Carbs.swift b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Carbs.swift index 831496852d..ab4c0709f8 100644 --- a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Carbs.swift +++ b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Carbs.swift @@ -17,7 +17,7 @@ extension History.StateModel { try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: isFpuOrComplexMeal) } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete carbs: \(error)") + warning(.treatments, "Deleting meal failed", error: error) await MainActor.run { carbEntryDeleted = false waitForSuggestion = false @@ -37,9 +37,10 @@ extension History.StateModel { _ = try await carbEntryMutationService.deleteMeal(rootObjectID: rootObjectID) } catch let error as CarbEntryMutationError { // Rows without a resolvable meal root are removed locally only, so they leave COB. - debug(.default, "\(DebuggingIdentifiers.failed) Meal root unavailable (\(error)), deleting local rows only") + warning(.treatments, "Meal root not found, deleting local entries only", error: error) await carbsStorage.deleteCarbsEntryStored(treatmentObjectID) } + info(.treatments, "Meal deleted") try await apsManager.determineBasalSync() } diff --git a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Glucose.swift b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Glucose.swift index 904b401e92..30c01613d6 100644 --- a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Glucose.swift +++ b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Glucose.swift @@ -28,7 +28,7 @@ extension History.StateModel { let result = try taskContext.existingObject(with: treatmentObjectID) as? GlucoseStored guard let glucoseToDelete = result else { - debugPrint("Data Table State: \(#function) \(DebuggingIdentifiers.failed) glucose not found in core data") + warning(.treatments, "Glucose entry to delete not found") return } @@ -42,13 +42,9 @@ extension History.StateModel { self.provider.deleteGlucoseFromHealth(withSyncID: id) } - debugPrint( - "\(#file) \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from remote service(s) (Nightscout, Apple Health, Tidepool)" - ) + debug(.treatments, "Glucose deletion sent to Nightscout and Apple Health") } catch { - debugPrint( - "\(#file) \(#function) \(DebuggingIdentifiers.failed) error while deleting glucose remote service(s) (Nightscout, Apple Health, Tidepool) with error: \(error)" - ) + warning(.treatments, "Deleting glucose from remote services failed", error: error) } } } diff --git a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Insulin.swift b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Insulin.swift index d8755ec231..5fa6024492 100644 --- a/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Insulin.swift +++ b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+Insulin.swift @@ -9,7 +9,7 @@ extension History.StateModel { do { try await invokeInsulinDeletion(treatmentObjectID) } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete insulin entry: \(error)") + warning(.treatments, "Insulin deletion task failed", error: error) } } } @@ -19,7 +19,7 @@ extension History.StateModel { let authenticated = try await unlockmanager.unlock() guard authenticated else { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Authentication Error") + debug(.treatments, "Insulin deletion not authenticated") return } @@ -42,9 +42,7 @@ extension History.StateModel { // Perform a determine basal sync to update iob try await apsManager.determineBasalSync() } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error while Insulin Deletion Task: \(error)" - ) + warning(.treatments, "Deleting insulin entry failed", error: error) await MainActor.run { insulinEntryDeleted = false waitForSuggestion = false @@ -61,7 +59,7 @@ extension History.StateModel { do { guard let treatmentToDelete = try taskContext.existingObject(with: treatmentObjectID) as? PumpEventStored else { - debug(.default, "Could not cast the object to PumpEventStored") + warning(.treatments, "Insulin entry to delete not found") return } @@ -76,9 +74,9 @@ extension History.StateModel { taskContext.delete(treatmentToDelete) try taskContext.save() - debug(.default, "Successfully deleted the treatment object.") + info(.treatments, "Insulin entry deleted") } catch { - debug(.default, "Failed to delete the treatment object: \(error)") + warning(.treatments, "Deleting insulin entry from storage failed", error: error) } } } diff --git a/Trio/Sources/Modules/History/View/HistoryRootView.swift b/Trio/Sources/Modules/History/View/HistoryRootView.swift index 189c92587b..389c87bf5a 100644 --- a/Trio/Sources/Modules/History/View/HistoryRootView.swift +++ b/Trio/Sources/Modules/History/View/HistoryRootView.swift @@ -371,7 +371,6 @@ extension History { // Convert Pump Events to TreatmentItem with filtering let treatments = pumpEventStored.compactMap { event -> TreatmentItem? in guard let id = event.objectID as NSManagedObjectID? else { - print("🚨 PumpEventStored has nil objectID") // Debugging return nil } @@ -411,7 +410,6 @@ extension History { // Convert Carb Entries to TreatmentItem with filtering let meals = carbEntryStored.compactMap { meal -> TreatmentItem? in guard let id = meal.objectID as NSManagedObjectID? else { - print("🚨 CarbEntryStored has nil objectID") // Debugging return nil } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+CGM.swift b/Trio/Sources/Modules/Home/HomeStateModel+CGM.swift index 5b4c9e79f2..3e287634a5 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+CGM.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+CGM.swift @@ -41,7 +41,7 @@ import LoopKitUI extension Home.StateModel: CompletionDelegate { /// This completion handler is called by both the CGM and the pump func completionNotifyingDidComplete(_ notifying: CompletionNotifying) { - debug(.service, "Completion fired by: \(type(of: notifying))") + debug(.ui, "Device setup finished by \(type(of: notifying))") Task { // this sleep is because this event and cgmManagerWantsDeletion // are called in parallel. diff --git a/Trio/Sources/Modules/Home/HomeStateModel+QuickPickTreatments.swift b/Trio/Sources/Modules/Home/HomeStateModel+QuickPickTreatments.swift index 3d17c7dbc5..01f9fbc0fb 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+QuickPickTreatments.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+QuickPickTreatments.swift @@ -86,7 +86,7 @@ private func fetchQuickPickSuggestions( return topQuickPickSuggestions(from: samples, roundingScale: roundingScale, roundingMode: roundingMode, now: Date()) } } catch { - debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch quick-pick suggestions for \(type): \(error)") + warning(.ui, "Fetching quick pick suggestions for \(type) failed", error: error) return [] } } @@ -160,9 +160,10 @@ extension Home.StateModel { let cappedCarbs = min(carbAmount, settingsManager.settings.maxCarbs) let carbsToStore = [carbsStorage.makeCarbEntry(carbs: cappedCarbs, date: Date())] try await carbsStorage.storeCarbs(carbsToStore, areFetchedFromRemote: false) + info(.treatments, "Quick pick carbs saved: \(cappedCarbs) g") return .succeeded } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Quick-pick treatment failed to save carbs: \(error)") + warning(.treatments, "Saving quick pick carbs failed", error: error) return .failed } } @@ -182,6 +183,7 @@ extension Home.StateModel { ) do { guard try await unlockmanager.unlock() else { return (.failed, nil) } + info(.bolus, "Quick pick bolus of \(delivery) U requested") var bolusSucceeded = false var pumpMessage: String? await apsManager.enactBolus(amount: delivery, isSMB: false) { success, message in @@ -190,7 +192,7 @@ extension Home.StateModel { } return bolusSucceeded ? (.succeeded, nil) : (.failed, pumpMessage) } catch { - debug(.bolusState, "Quick-pick treatment bolus authentication error: \(error)") + warning(.bolus, "Authentication for quick pick bolus failed", error: error) return (.failed, nil) } } @@ -215,7 +217,7 @@ extension Home.StateModel { do { try await apsManager.determineBasalSync() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Quick-pick treatment determine basal sync failed: \(error)") + warning(.treatments, "Determination after quick pick carbs failed", error: error) } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/BatterySetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/BatterySetup.swift index 741a833993..9ea7d8dcda 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/BatterySetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/BatterySetup.swift @@ -13,7 +13,7 @@ extension Home.StateModel { try batteryController.performFetch() updateBatteryFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform battery fetch: \(error)") + warning(.ui, "Fetching pump battery for home screen failed", error: error) } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/CarbSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/CarbSetup.swift index 3db06068aa..e98a0d778b 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/CarbSetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/CarbSetup.swift @@ -15,7 +15,7 @@ extension Home.StateModel { try carbsController.performFetch() updateCarbsFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform carbs fetch: \(error)") + warning(.ui, "Fetching carbs for home screen failed", error: error) } } @@ -37,7 +37,7 @@ extension Home.StateModel { try fpuController.performFetch() updateFPUsFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform FPU fetch: \(error)") + warning(.ui, "Fetching fat and protein entries for home screen failed", error: error) } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/CurrentTDDSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/CurrentTDDSetup.swift index e4c56af113..386e417498 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/CurrentTDDSetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/CurrentTDDSetup.swift @@ -13,7 +13,7 @@ extension Home.StateModel { try tddController.performFetch() updateTDDFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform TDD fetch: \(error)") + warning(.ui, "Fetching total daily dose for home screen failed", error: error) } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift index cc230154df..94ed180133 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift @@ -20,7 +20,7 @@ extension Home.StateModel { await self.updateForecastData() } } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform enacted determination fetch: \(error)") + warning(.ui, "Fetching enacted determinations for home screen failed", error: error) } } @@ -53,7 +53,7 @@ extension Home.StateModel { try suggestedDeterminationController.performFetch() updateSuggestedDeterminationFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform suggested determination fetch: \(error)") + warning(.storage, "Fetching the suggested determination failed", error: error) } } @@ -75,7 +75,7 @@ extension Home.StateModel { try determinationController.performFetch() updateDeterminationsFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform determination fetch: \(error)") + warning(.ui, "Fetching determinations for home screen failed", error: error) } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift index 3a2c745733..ef169737cb 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift @@ -11,7 +11,7 @@ extension Home.StateModel { guard let determination = await viewContext.perform({ self.enactedAndNonEnactedDeterminations.first }) else { - debug(.default, "No determination found for forecast preprocessing") + debug(.ui, "No determination yet, forecast not shown") return [] } @@ -24,10 +24,7 @@ extension Home.StateModel { in: taskContext ) } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to preprocess forecast data: \(error)" - ) + warning(.ui, "Preparing forecast for home screen failed", error: error) return [] } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift index a86d0923c5..c046b32a6c 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift @@ -13,7 +13,7 @@ extension Home.StateModel { try glucoseController.performFetch() updateGlucoseFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform glucose fetch: \(error)") + warning(.ui, "Fetching glucose for home screen failed", error: error) } } @@ -98,7 +98,7 @@ extension Home.StateModel { self.extendedStatsPanel = (range: range, stats: stats) self.extendedStatsPanelRefreshedAt = Date() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to compute home stats panel: \(error)") + warning(.ui, "Computing home stats panel failed", error: error) } } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseTargetSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseTargetSetup.swift index 92bd8dcab7..fbcdd480f5 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseTargetSetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseTargetSetup.swift @@ -20,7 +20,7 @@ extension Home.StateModel { // Ensure there are targets to process guard !rawTargets.targets.isEmpty else { - debugPrint("\(DebuggingIdentifiers.failed) Warning: No targets to process in rawTargets.") + debug(.ui, "No glucose targets to show on home screen") return [] } @@ -41,7 +41,7 @@ extension Home.StateModel { // Validate target index to ensure safety guard targetIndex < targets.count else { - debugPrint("\(DebuggingIdentifiers.failed) Error: Invalid target index \(targetIndex).") + fault(.ui, "Skipped glucose target with invalid index \(targetIndex)") continue } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift index 3e6237aa34..f83e2fb5db 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift @@ -15,7 +15,7 @@ extension Home.StateModel { try overrideController.performFetch() updateOverridesFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform override fetch: \(error)") + warning(.ui, "Fetching overrides for home screen failed", error: error) } } @@ -37,7 +37,7 @@ extension Home.StateModel { try overrideRunController.performFetch() updateOverrideRunsFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform override run fetch: \(error)") + warning(.ui, "Fetching override history for home screen failed", error: error) } } @@ -62,7 +62,7 @@ extension Home.StateModel { Foundation.NotificationCenter.default.post(name: .didUpdateOverrideConfiguration, object: nil) } catch let error as NSError { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to cancel Profile with error: \(error)") + warning(.treatments, "Cancelling override failed", error: error) } } @@ -81,9 +81,7 @@ extension Home.StateModel { guard viewContext.hasChanges else { return } try viewContext.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save an Override to the OverrideRunStored entity with error: \(error)" - ) + warning(.treatments, "Saving override history entry failed", error: error) } } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/ProfileExpirationSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/ProfileExpirationSetup.swift index fc99a5cbd0..ccc851c093 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/ProfileExpirationSetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/ProfileExpirationSetup.swift @@ -116,9 +116,9 @@ extension Home.StateModel { ) UNUserNotificationCenter.current().add(request) { error in if let error = error { - debug(.service, "ProfileExpiration: failed to post reverted notification: \(error)") + warning(.alerts, "Posting profile reverted notification failed", error: error) } else { - debug(.service, "ProfileExpiration: reverted notification queued (id=\(request.identifier))") + debug(.alerts, "Profile reverted notification queued") } } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift index 911cdc5429..1ec236a080 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift @@ -18,7 +18,7 @@ extension Home.StateModel { try insulinController.performFetch() updateInsulinFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform insulin fetch: \(error)") + warning(.ui, "Fetching insulin for home screen failed", error: error) } } @@ -50,7 +50,7 @@ extension Home.StateModel { try lastBolusController.performFetch() updateLastBolusFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform last bolus fetch: \(error)") + warning(.ui, "Fetching last bolus for home screen failed", error: error) } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/TempTargetSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/TempTargetSetup.swift index d6fa59172b..6d01d7c7c1 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/TempTargetSetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/TempTargetSetup.swift @@ -15,7 +15,7 @@ extension Home.StateModel { try tempTargetController.performFetch() updateTempTargetsFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform temp target fetch: \(error)") + warning(.ui, "Fetching temp targets for home screen failed", error: error) } } @@ -38,7 +38,7 @@ extension Home.StateModel { try tempTargetRunController.performFetch() updateTempTargetRunsFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform temp target run fetch: \(error)") + warning(.ui, "Fetching temp target history for home screen failed", error: error) } } @@ -68,7 +68,7 @@ extension Home.StateModel { Foundation.NotificationCenter.default.post(name: .didUpdateTempTargetConfiguration, object: nil) } catch let error as NSError { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to cancel Temp Target with error: \(error)") + warning(.treatments, "Cancelling temp target failed", error: error) } } @@ -86,7 +86,7 @@ extension Home.StateModel { guard viewContext.hasChanges else { return } try viewContext.save() } catch let error as NSError { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Temp Target with error: \(error)") + warning(.treatments, "Saving temp target history entry failed", error: error) } } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift index d74f183e2b..e7bb4392ae 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -497,7 +497,7 @@ extension Home { try controller.performFetch() republish() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to re-anchor fetch window for \(T.self): \(error)") + warning(.ui, "Moving home fetch window for \(T.self) failed", error: error) } } @@ -710,9 +710,6 @@ extension Home { .receive(on: DispatchQueue.main) .map { [weak self] error in self?.errorDate = error == nil ? nil : Date() - if let error = error { - debug(.default, "APSManager lastError: \(String(describing: error))") - } return error?.localizedDescription } .weakAssign(to: \.errorMessage, on: self) @@ -942,7 +939,7 @@ extension Home { // Only proceed if we have a pump manager /// fixes UI bug where reservoir is at 200 U although there is no pump if apsManager.pumpManager == nil { - debug(.service, "Skipping reservoir setup because pump manager is nil") + debug(.pump, "Reservoir setup skipped, no pump connected") await MainActor.run { self.reservoir = nil } @@ -1134,8 +1131,7 @@ extension Home.StateModel: maxIOB = settingsManager.preferences.maxIOB let oldBolusIncrement = bolusIncrement bolusIncrement = settingsManager.preferences.bolusIncrement - debug(.service, "Update triggered") - debug(.service, "bolusIncrement updated from preferences: \(oldBolusIncrement) → \(bolusIncrement)") + debug(.settings, "Bolus increment updated from \(oldBolusIncrement) U to \(bolusIncrement) U") } func pumpSettingsDidChange(_: PumpSettings) { diff --git a/Trio/Sources/Modules/Home/View/HomeRootView.swift b/Trio/Sources/Modules/Home/View/HomeRootView.swift index 62e583d8ef..0662e07d63 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView.swift @@ -153,7 +153,7 @@ extension Home { if notificationsDisabled != $0 { notificationsDisabled = $0 if notificationsDisabled { - debug(.default, "notificationsDisabled") + debug(.alerts, "Notifications are disabled in iOS settings") } } } @@ -374,12 +374,13 @@ extension Home { } .tint(Color.tabBar) - treatmentButton - // the iOS 26 bar ignores the safe area: on every iPhone its 62pt - // platter floats 21pt above the screen edge; the button is 46pt - .padding(.bottom, 21 + (62 - 46) / 2) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .ignoresSafeArea(.container, edges: .bottom) + // fixed distance from the physical screen bottom; immune to + // safe-area changes (keyboard, accessories) + GeometryReader { geo in + treatmentButton + .position(treatmentButtonPosition(in: geo)) + } + .ignoresSafeArea(.all, edges: .bottom) } .ignoresSafeArea(.keyboard, edges: .bottom) .blur(radius: state.waitForSuggestion ? 8 : 0) @@ -401,6 +402,20 @@ extension Home { } } + /// System bars laid out vertically (the tab bar on iPhone Duo) add side safe-area + /// insets instead of a bottom one; the button then takes the rail's dead slot + /// rather than floating over the content. Trio's iPhone layout is otherwise + /// portrait-only, so other iPhones have no side insets and keep the bottom bar. + private func treatmentButtonPosition(in geo: GeometryProxy) -> CGPoint { + let insets = geo.safeAreaInsets + guard UIDevice.current.userInterfaceIdiom == .phone, insets.leading + insets.trailing > 0 else { + return CGPoint(x: geo.size.width / 2, y: geo.size.height - 52) + } + // rail centre is 48 pt in from the screen edge, the dead slot 156 pt up + let x = insets.trailing > 0 ? geo.size.width + insets.trailing - 48 : 48 - insets.leading + return CGPoint(x: x, y: geo.size.height - 156) + } + private var treatmentButton: some View { Image(.taiCircledNoBackground) .resizable() diff --git a/Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift b/Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift index ae1ccbb654..eb2acc87a9 100644 --- a/Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift +++ b/Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift @@ -116,6 +116,7 @@ extension ISFEditor { sensitivities: sensitivities ) provider.saveProfile(profile) + info(.settings, "Insulin sensitivity schedule saved with \(sensitivities.count) entries") initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) } DispatchQueue.main.async { @@ -126,13 +127,9 @@ extension ISFEditor { Task.detached(priority: .low) { do { - debug(.nightscout, "Attempting to upload ISF to Nightscout") try await self.nightscout.uploadProfiles() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Faile to upload ISF to Nightscout: \(error)" - ) + warning(.nightscout, "Uploading profile after insulin sensitivity change failed", error: error) } } diff --git a/Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift b/Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift index 352cd702be..ce938e30ae 100644 --- a/Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift +++ b/Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift @@ -124,7 +124,7 @@ struct MainMigrationErrorView_Previews: PreviewProvider { "Failed to import carb history.", "Failed to import algorithm data." ], - onConfirm: { print("Proceed") } + onConfirm: { debug(.ui, "Migration error preview confirmed") } ) } } diff --git a/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift b/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift index 8bc9d1009f..b5eba4e481 100644 --- a/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift +++ b/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift @@ -56,19 +56,16 @@ extension NightscoutConfig { .sink { [weak self] enabled in guard let self = self else { return } if enabled { - debug(.nightscout, "Upload has been enabled by the user.") + info(.nightscout, "Upload enabled") Task { do { try await self.nightscoutManager.uploadProfiles() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) failed to upload profiles: \(error)" - ) + warning(.nightscout, "Profile upload failed", error: error) } } } else { - debug(.nightscout, "Upload has been disabled by the user.") + info(.nightscout, "Upload disabled") } } .store(in: &lifetime) @@ -152,14 +149,14 @@ extension NightscoutConfig { await self.healthKitManager.uploadGlucose() } } catch let error as CoreDataError { - debug(.nightscout, "Core Data error while storing backfilled glucose: \(error)") + warning(.storage, "Failed to store backfilled glucose", error: error) message = "Error: \(error.localizedDescription)" } catch { - debug(.nightscout, "Unexpected error while storing backfilled glucose: \(error)") + warning(.storage, "Failed to store backfilled glucose", error: error) message = "Error: \(error.localizedDescription)" } } else { - debug(.nightscout, "No glucose values found or fetched to backfill.") + debug(.nightscout, "No glucose to backfill") } await MainActor.run { diff --git a/Trio/Sources/Modules/Onboarding/OnboardingStateModel+Nightscout.swift b/Trio/Sources/Modules/Onboarding/OnboardingStateModel+Nightscout.swift index 078529d12f..7165634832 100644 --- a/Trio/Sources/Modules/Onboarding/OnboardingStateModel+Nightscout.swift +++ b/Trio/Sources/Modules/Onboarding/OnboardingStateModel+Nightscout.swift @@ -164,7 +164,7 @@ extension Onboarding.StateModel { await MainActor.run { self.nightscoutImportError = NightscoutImportError(message: error.localizedDescription) self.nightscoutImportStatus = .failed - debug(.service, "Settings import failed with error: \(error)") + warning(.settings, "Importing settings from Nightscout failed", error: error) } } } diff --git a/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift b/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift index 734135674c..43e4df60a2 100644 --- a/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift +++ b/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift @@ -49,7 +49,7 @@ extension Onboarding { // Expect exactly 2 entries: "logs" and the preferences file guard contents.count == 2 else { - debug(.default, "Trio install is not fresh; returning user.") + debug(.settings, "Existing install") return false } @@ -58,12 +58,12 @@ extension Onboarding { let actualSet = Set(contents) let isFreshInstall = expectedSet == actualSet - debug(.default, "Trio install is fresh; new user.") + debug(.settings, isFreshInstall ? "Fresh install" : "Existing install") return isFreshInstall } catch { - debug(.default, "Cannot determine Initial Build State. Failed to read documents directory: \(error)") + warning(.settings, "Reading documents directory to detect a fresh install failed", error: error) return false } } @@ -314,7 +314,7 @@ extension Onboarding { let newTimeIndex = closestIndex(for: originalTime, in: basalProfileTimeValues) if safeRateIndex != item.rateIndex { - debug(.default, "⚠️ rateIndex \(item.rateIndex) out of bounds; clamped to \(safeRateIndex)") + warning(.settings, "Basal rate index \(item.rateIndex) out of range, clamped to \(safeRateIndex)") } return BasalProfileEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex) diff --git a/Trio/Sources/Modules/ProfileScheduler/ProfileSchedulerProvider.swift b/Trio/Sources/Modules/ProfileScheduler/ProfileSchedulerProvider.swift index 4d02d05a45..8c02e3cc2b 100644 --- a/Trio/Sources/Modules/ProfileScheduler/ProfileSchedulerProvider.swift +++ b/Trio/Sources/Modules/ProfileScheduler/ProfileSchedulerProvider.swift @@ -90,7 +90,7 @@ extension ProfileScheduler { try context.save() return id } catch { - debug(.coreData, "ProfileSchedulerProvider.saveNewSchedule failed: \(error)") + warning(.storage, "Saving new profile schedule failed", error: error) return nil } } diff --git a/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift b/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift index 69ed66d843..b67789f79f 100644 --- a/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift +++ b/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift @@ -23,6 +23,7 @@ extension PumpConfig { @Published var allowDilution: Bool = false @Published var hideInsulinBadge: Bool = false @Injected() var bluetoothManager: BluetoothStateManager! + @Injected() var logExport: LogExport! var pumpSettings: PumpSettings { provider.settings() @@ -85,10 +86,7 @@ extension PumpConfig { debug(.nightscout, "Attempting to upload DIA to Nightscout") try await self.nightscout.uploadProfiles() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) failed to upload DIA to Nightscout: \(error.localizedDescription)" - ) + warning(.nightscout, "Uploading DIA to Nightscout failed", error: error) } } diff --git a/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift b/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift index 97034f4c92..da704fe1e0 100644 --- a/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift +++ b/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift @@ -295,6 +295,17 @@ extension PumpConfig { ) } ) + + if state.pumpState != nil, state.provider?.apsManager?.pumpManager != nil { + Section { + LogShareRow( + title: "Share Pump Logs", + footnote: "Creates one file you can send when asking for help.", + kinds: [.pump], + logExport: state.logExport + ) + }.listRowBackground(Color.chart) + } } .listSectionSpacing(sectionSpacing) .tint(Color.tabBar) diff --git a/Trio/Sources/Modules/PumpConfig/View/PumpSetupView.swift b/Trio/Sources/Modules/PumpConfig/View/PumpSetupView.swift index 511d03193c..0e1ad1ab23 100644 --- a/Trio/Sources/Modules/PumpConfig/View/PumpSetupView.swift +++ b/Trio/Sources/Modules/PumpConfig/View/PumpSetupView.swift @@ -40,7 +40,7 @@ extension PumpConfig { setupViewControllerUI.completionDelegate = completionDelegate return setupViewControllerUI case let .createdAndOnboarded(pumpManagerUI): - debug(.default, "Pump manager created and onboarded") + debug(.pump, "Pump setup finished") setupDelegate?.pumpManagerOnboarding(didCreatePumpManager: pumpManagerUI) var vc = pumpManagerUI.settingsViewController( bluetoothProvider: bluetoothManager, diff --git a/Trio/Sources/Modules/Settings/SettingsStateModel.swift b/Trio/Sources/Modules/Settings/SettingsStateModel.swift index 58bf84c50f..7354643ce4 100644 --- a/Trio/Sources/Modules/Settings/SettingsStateModel.swift +++ b/Trio/Sources/Modules/Settings/SettingsStateModel.swift @@ -8,7 +8,7 @@ import TidepoolServiceKit extension Settings { final class StateModel: BaseStateModel { @Injected() private var broadcaster: Broadcaster! - @Injected() private var fileManager: FileManager! + @Injected() var logExport: LogExport! @Injected() private var nightscoutManager: NightscoutManager! @Injected() var pluginManager: PluginManager! @Injected() var fetchCgmManager: FetchGlucoseManager! @@ -44,166 +44,16 @@ extension Settings { serviceUIType = TidepoolService.self as? ServiceUI.Type } - func logItems() -> [URL] { - do { - // Create a directory for our zip file in Documents - let exportsDirectoryURL = try Disk.AppDirectoryURL.logExports() - - // Create directory if it doesn't exist - if !fileManager.fileExists(atPath: exportsDirectoryURL.path) { - try fileManager.createDirectory(at: exportsDirectoryURL, withIntermediateDirectories: true) - } - - // Create a unique filename with timestamp using static formatter - let timestamp = Formatter.iso8601.string(from: Date()).replacingOccurrences(of: ":", with: "-") - let zipFileURL = exportsDirectoryURL.appendingPathComponent("Trio-Logs-\(timestamp).zip") - - // Create a temporary staging directory - let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()) - let stagingDirURL = temporaryDirectoryURL.appendingPathComponent( - "staging-\(UUID().uuidString)", - isDirectory: true - ) - - // Wrap the entire staging flow with proper cleanup - do { - try fileManager.createDirectory(at: stagingDirURL, withIntermediateDirectories: true) - - // Collect all the log files - var stagingFileURLs: [URL] = [] - var sourceFileURLs: [URL] = [] // Track original source files for fallback - let logNames = SimpleLogReporter.getAllLogNames() - - // Copy both standard and watch log files to staging - try copyLogs( - logNames: logNames, - to: stagingDirURL, - stagingFileURLs: &stagingFileURLs, - sourceFileURLs: &sourceFileURLs, - isWatchLogs: false - ) - - try copyLogs( - logNames: logNames, - to: stagingDirURL, - stagingFileURLs: &stagingFileURLs, - sourceFileURLs: &sourceFileURLs, - isWatchLogs: true - ) - - // If no files to share, return empty array - if stagingFileURLs.isEmpty { - debug(.service, "No log files found to share") - // Clean up empty staging directory - try? fileManager.removeItem(at: stagingDirURL) - return [] - } - - // Create the zip file using the Archive Utility - if createZipArchive(from: stagingDirURL, to: zipFileURL) { - // Clean up staging directory after successful zip creation - try? fileManager.removeItem(at: stagingDirURL) - // Return the zip file URL for sharing - return [zipFileURL] - } else { - debug(.service, "Failed to create zip archive, returning all staging files") - // Return all staging files (don't clean up staging yet) - // Note: Staging cleanup will happen when these files are no longer needed - return stagingFileURLs - } - - } catch { - // Clean up staging directory on any error - try? fileManager.removeItem(at: stagingDirURL) - debug(.service, "Error in staging/zip process: \(error.localizedDescription)") - // Re-throw to be caught by outer catch block for fallback - throw error - } - - } catch { - debug(.service, "Error preparing logs for sharing: \(error.localizedDescription)") - - // Show alert to user about the error - DispatchQueue.main.async { - let alert = UIAlertController( - title: "Log Export Error", - message: "Failed to prepare logs for sharing. Using current log file as fallback.\n\nError: \(error.localizedDescription)", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - - // Present on the topmost view controller - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first(where: { $0.isKeyWindow }), - let rootViewController = window.rootViewController - { - var topController = rootViewController - while let presentedViewController = topController.presentedViewController { - topController = presentedViewController - } - topController.present(alert, animated: true) - } - } - - // Fallback to just the current log as a last resort - let currentLogFileURL = SimpleLogReporter.logFileURL(name: SimpleLogReporter.currentLogName()) - if fileManager.fileExists(atPath: currentLogFileURL.path) { - return [currentLogFileURL] - } - // If all else fails, return empty array - return [] - } } - - // Helper function to copy log files to staging directory - private func copyLogs( - logNames: [String], - to stagingDirURL: URL, - stagingFileURLs: inout [URL], - sourceFileURLs: inout [URL], - isWatchLogs: Bool - ) throws { - for logName in logNames { - let sourceURL: URL - let filename: String - - if isWatchLogs { - sourceURL = SimpleLogReporter.watchLogFileURL(name: logName) - filename = Disk.AppFilenames.watchLogFile(name: logName) - } else { - sourceURL = SimpleLogReporter.logFileURL(name: logName) - filename = Disk.AppFilenames.logFile(name: logName) - } - - if fileManager.fileExists(atPath: sourceURL.path) { - let destURL = stagingDirURL.appendingPathComponent(filename) - try fileManager.copyItem(at: sourceURL, to: destURL) - stagingFileURLs.append(destURL) - sourceFileURLs.append(sourceURL) // Track original source for fallback - } + var logKinds: [LogExport.Kind] { + var kinds: [LogExport.Kind] = [.all] + // Read during the first render, before the resolver is set + if provider?.deviceManager?.pumpManager != nil { + kinds.append(.pump) } - } - - // Helper function to create a zip archive using NSFileCoordinator - private func createZipArchive(from sourceURL: URL, to destinationURL: URL) -> Bool { - let coordinator = NSFileCoordinator() - var success = false - var coordinatorError: NSError? - - coordinator.coordinate(readingItemAt: sourceURL, options: [.forUploading], error: &coordinatorError) { zippedURL in - do { - // NSFileCoordinator provides the zipped URL for us - try fileManager.copyItem(at: zippedURL, to: destinationURL) - success = true - } catch { - debug(.service, "Error creating zip archive: \(error.localizedDescription)") - } + if let fetchCgmManager, fetchCgmManager.cgmGlucoseSourceType == .plugin, fetchCgmManager.cgmManager != nil { + kinds.append(.cgm) } - - if coordinatorError != nil { - debug(.service, "NSFileCoordinator error: \(String(describing: coordinatorError))") - } - - return success && fileManager.fileExists(atPath: destinationURL.path) + return kinds } // Commenting this out for now, as not needed and possibly dangerous for users to be able to nuke their pump pairing informations via the debug menu @@ -239,13 +89,13 @@ extension Settings.StateModel: SettingsObserver { extension Settings.StateModel: ServiceOnboardingDelegate { func serviceOnboarding(didCreateService service: Service) { - debug(.nightscout, "Service with identifier \(service.pluginIdentifier) created") + info(.settings, "Service \(service.pluginIdentifier) added") provider.tidepoolManager.addTidepoolService(service: service) } func serviceOnboarding(didOnboardService service: Service) { precondition(service.isOnboarded) - debug(.nightscout, "Service with identifier \(service.pluginIdentifier) onboarded") + debug(.settings, "Service \(service.pluginIdentifier) onboarded") } } diff --git a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift index 6435dfba06..95fd36e389 100644 --- a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift +++ b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift @@ -18,7 +18,6 @@ extension Settings { let resolver: Resolver @StateObject var state = StateModel() - @State private var showShareSheet = false @State private var searchText: String = "" @State var hintDetent = PresentationDetent.large @State private var hintPayload: HintPayload? @@ -298,88 +297,14 @@ extension Settings { .listRowBackground(Color.chart) Section( - header: Text("Support & Community"), content: { - Button { - showShareSheet.toggle() - } label: { - HStack { - Text("Share Logs") - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.footnote) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - if let url = URL(string: "https://github.com/nightscout/Trio/issues/new/choose") { - UIApplication.shared.open(url) - } - } label: { - HStack { - Text("Submit Ticket on GitHub") - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.footnote) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - if let url = URL(string: "https://discord.triodocs.org") { - UIApplication.shared.open(url) - } - } label: { - HStack { - Text("Trio Discord") - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.footnote) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - if let url = - URL(string: "https://discordapp.com/channels/953929437894803478/1025731124615458848") - { - UIApplication.shared.open(url) - } - } label: { - HStack { - Text("Tai Discord") - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.footnote) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - if let url = URL(string: "https://facebook.triodocs.org") { - UIApplication.shared.open(url) - } - } label: { - HStack { - Text("Trio Facebook") - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.footnote) - } + NavigationLink(destination: ReportProblemView(state: state)) { + Text("Report a Problem") } - .frame(maxWidth: .infinity, alignment: .leading) - } + ReportLinkRow(title: "Ask on Discord", url: "https://discord.triodocs.org") + ReportLinkRow(title: "Ask on Facebook", url: "https://facebook.triodocs.org") + }, + header: { Text("Support & Community") } ).listRowBackground(Color.chart) if !releaseNotesService.releases.isEmpty { @@ -486,9 +411,6 @@ extension Settings { sheetTitle: String(localized: "Help", comment: "Help sheet title") ) } - .sheet(isPresented: $showShareSheet) { - ShareSheet(activityItems: state.logItems()) - } .onAppear(perform: configureView) .task { await releaseNotesService.load() diff --git a/Trio/Sources/Modules/Settings/View/Subviews/ReportProblemView.swift b/Trio/Sources/Modules/Settings/View/Subviews/ReportProblemView.swift new file mode 100644 index 0000000000..b28806d580 --- /dev/null +++ b/Trio/Sources/Modules/Settings/View/Subviews/ReportProblemView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct ReportProblemView: View { + @ObservedObject var state: Settings.StateModel + + @Environment(\.colorScheme) private var colorScheme + @Environment(AppState.self) private var appState + + var body: some View { + List { + Section { + LogShareRow( + title: "Share Trio Logs", + footnote: "Save the file or send it to someone helping you on Discord or Facebook. Contains glucose, insulin and carbs; passwords and serial numbers are removed.", + kinds: state.logKinds, + logExport: state.logExport + ) + } + .listRowBackground(Color.chart) + + Section { + ReportLinkRow( + title: "Open a GitHub Issue", + footnote: "Public. Only for bugs you can describe without personal or health data.", + url: "https://github.com/nightscout/Trio/issues/new/choose" + ) + } + .listRowBackground(Color.chart) + } + .scrollContentBackground(.hidden) + .background(appState.trioBackgroundColor(for: colorScheme)) + .navigationTitle("Report a Problem") + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct ReportOptionLabel: View { + let title: LocalizedStringKey + var footnote: LocalizedStringKey? + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .foregroundColor(.primary) + if let footnote { + Text(footnote) + .font(.footnote) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityElement(children: .combine) + } +} + +struct ReportLinkRow: View { + let title: LocalizedStringKey + var footnote: LocalizedStringKey? + let url: String + var hint: LocalizedStringKey? + + @Environment(\.openURL) private var openURL + + var body: some View { + Button { + if let url = URL(string: url) { + openURL(url) + } + } label: { + HStack { + ReportOptionLabel(title: title, footnote: footnote) + Spacer() + Image(systemName: "arrow.up.right") + .foregroundColor(.secondary) + .font(.footnote) + .accessibilityHidden(true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityAddTraits(.isLink) + .accessibilityHint(hint.map { Text($0) } ?? Text(verbatim: "")) + } +} diff --git a/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift b/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift index e88673e839..4e6f6e449b 100644 --- a/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift +++ b/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift @@ -84,17 +84,12 @@ extension SettingsExport { func exportSettings( categories: Set? = nil ) async -> Result { - debug(.default, "🔄 EXPORT: Starting settings export...") - await MainActor.run { isExporting = true } defer { Task { @MainActor in isExporting = false } } let categoriesToExport = categories ?? selectedCategories - debug( - .default, - "🔄 EXPORT: Exporting categories: \(categoriesToExport.map(\.rawValue).joined(separator: ", ")) in .CSV format" - ) + debug(.settings, "Exporting settings categories: \(categoriesToExport.map(\.rawValue).joined(separator: ", "))") let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" @@ -106,13 +101,11 @@ extension SettingsExport { return .failure(.documentsDirectoryNotFound) } let fileURL = documentsDirectory.appendingPathComponent(fileName) - debug(.default, "Export file path: \(fileURL.path)") var exportSettings: [ExportSetting] = [] let trioSettings = settingsManager.settings let preferences = settingsManager.preferences - debug(.default, "🔄 EXPORT: Settings managers initialized") // Helper function to add a setting /// Marks a stored value the active dosing mode overrides, so an export reads true. @@ -1260,10 +1253,8 @@ extension SettingsExport { // Temp Target Presets if categoriesToExport.contains(.tempTargetPresets) { let category = String(localized: "Temp Target Presets") - debug(.default, "🔄 EXPORT: Fetching temp target presets...") let tempTargetPresetIDs = (try? await tempTargetsStorage.fetchForTempTargetPresets()) ?? [] - debug(.default, "🔄 EXPORT: Found \(tempTargetPresetIDs.count) temp target preset IDs") if !tempTargetPresetIDs.isEmpty { do { @@ -1324,7 +1315,7 @@ extension SettingsExport { } exportSettings.append(contentsOf: tempTargetPresets) - debug(.default, "✅ EXPORT: Added \(tempTargetPresets.count) temp target preset rows") + debug(.settings, "Exported \(tempTargetPresets.count) temp target preset rows") } catch { // STRICT: surface the real issue return .failure(.unknown("Failed to extract Temp Targets: \(error.localizedDescription)")) @@ -1335,11 +1326,9 @@ extension SettingsExport { // Override Presets if categoriesToExport.contains(.overridePresets) { let category = String(localized: "Override Presets") - debug(.default, "🔄 EXPORT: Fetching override presets...") do { let overridePresetIDs = try await overrideStorage.fetchForOverridePresets() - debug(.default, "🔄 EXPORT: Found \(overridePresetIDs.count) override preset IDs") if !overridePresetIDs.isEmpty { let overridePresets: [ExportSetting] = try await viewContext.perform { @@ -1475,7 +1464,7 @@ extension SettingsExport { } exportSettings.append(contentsOf: overridePresets) - debug(.default, "✅ EXPORT: Added \(overridePresets.count) override preset rows") + debug(.settings, "Exported \(overridePresets.count) override preset rows") } } catch { return .failure(.unknown("Failed to fetch override presets: \(error.localizedDescription)")) @@ -1488,8 +1477,6 @@ extension SettingsExport { // Meal Presets (from Core Data) do { - debug(.default, "Fetching meal presets...") - let mealPresetData = try await viewContext.perform { let request: NSFetchRequest = MealPresetStored.fetchRequest() let mealPresets = try self.viewContext.fetch(request) @@ -1504,7 +1491,7 @@ extension SettingsExport { } } - debug(.default, "Found \(mealPresetData.count) meal presets") + debug(.settings, "Exporting \(mealPresetData.count) meal presets") if !mealPresetData.isEmpty { for mealPreset in mealPresetData { @@ -1540,7 +1527,7 @@ extension SettingsExport { } } } catch { - debug(.default, "Failed to fetch meal presets: \(error)") + warning(.settings, "Fetching meal presets for export failed", error: error) } } @@ -1563,43 +1550,35 @@ extension SettingsExport { } content = csvContent - debug( - .default, - "📝 EXPORT: Writing .CSV content (\(content.count) characters) to file: \(fileURL.path)" - ) - debug(.default, "📝 EXPORT: Temporary directory: \(FileManager.default.temporaryDirectory.path)") - debug(.default, "📝 EXPORT: File URL: \(fileURL)") + debug(.settings, "Writing settings export (\(content.count) characters)") try content.write(to: fileURL, atomically: true, encoding: .utf8) - debug(.default, "✅ EXPORT: Content written to file successfully") // Set file attributes for better sharing compatibility try fileManager.setAttributes([ .posixPermissions: 0o644, .extensionHidden: false ], ofItemAtPath: fileURL.path) - debug(.default, "✅ EXPORT: File attributes set successfully") // Verify file was written successfully let fileExists = fileManager.fileExists(atPath: fileURL.path) let fileAttributes = try? fileManager.attributesOfItem(atPath: fileURL.path) let fileSize = (fileAttributes?[.size] as? NSNumber)?.intValue ?? 0 - debug(.default, "📊 EXPORT: File verification - Exists: \(fileExists), Size: \(fileSize) bytes") - if !fileExists { - debug(.default, "❌ EXPORT: CRITICAL - File does not exist after writing!") + error(.settings, "Settings export file missing after write") return .failure(.unknown("File was not created successfully")) } if fileSize == 0 { - debug(.default, "❌ EXPORT: CRITICAL - File exists but has 0 bytes!") + error(.settings, "Settings export file is empty after write") return .failure(.unknown("File was created but is empty")) } + info(.settings, "Exported settings (\(fileSize) bytes)") return .success(fileURL) } catch { - debug(.default, "Failed to write settings export file: \(error)") + warning(.settings, "Writing settings export file failed", error: error) return .failure(.fileWriteError(error)) } } diff --git a/Trio/Sources/Modules/Stat/StatStateModel+Setup/AreaChartSetup.swift b/Trio/Sources/Modules/Stat/StatStateModel+Setup/AreaChartSetup.swift index 4638738b93..1862ceb2d6 100644 --- a/Trio/Sources/Modules/Stat/StatStateModel+Setup/AreaChartSetup.swift +++ b/Trio/Sources/Modules/Stat/StatStateModel+Setup/AreaChartSetup.swift @@ -83,7 +83,7 @@ extension Stat.StateModel { do { return try taskContext.existingObject(with: id) as? GlucoseStored } catch { - debugPrint("\(DebuggingIdentifiers.failed) Error fetching glucose: \(error)") + warning(.ui, "Loading glucose for statistics chart failed", error: error) return nil } } diff --git a/Trio/Sources/Modules/Stat/StatStateModel+Setup/BolusStatsSetup.swift b/Trio/Sources/Modules/Stat/StatStateModel+Setup/BolusStatsSetup.swift index 5b19c8b409..72294ae2a0 100644 --- a/Trio/Sources/Modules/Stat/StatStateModel+Setup/BolusStatsSetup.swift +++ b/Trio/Sources/Modules/Stat/StatStateModel+Setup/BolusStatsSetup.swift @@ -34,7 +34,7 @@ extension Stat.StateModel { // Initially calculate and cache daily averages await calculateAndCacheBolusAveragesAndTotals() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) failed to setup bolus stats: \(error)") + warning(.ui, "Fetching bolus statistics failed", error: error) } } } diff --git a/Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift b/Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift index 7afd52fd54..80ceef1325 100644 --- a/Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift +++ b/Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift @@ -70,7 +70,7 @@ extension Stat.StateModel { self.loopStats = stats } } catch { - debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch loop stats: \(error)") + warning(.ui, "Fetching loop statistics failed", error: error) } } } @@ -138,7 +138,7 @@ extension Stat.StateModel { do { return try viewContext.existingObject(with: id) as? LoopStatRecord } catch { - debugPrint("\(DebuggingIdentifiers.failed) Error fetching loop stat: \(error)") + warning(.ui, "Loading loop statistic failed", error: error) return nil } } diff --git a/Trio/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift b/Trio/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift index bcd0903bfa..8ab6860e54 100644 --- a/Trio/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift +++ b/Trio/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift @@ -34,7 +34,7 @@ extension Stat.StateModel { // Initially calculate and cache per-day totals await calculateAndCacheDailyTotals() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch meal stats: \(error)") + warning(.ui, "Fetching meal statistics failed", error: error) } } } diff --git a/Trio/Sources/Modules/Stat/StatStateModel+Setup/StackedChartSetup.swift b/Trio/Sources/Modules/Stat/StatStateModel+Setup/StackedChartSetup.swift index f19be29a6c..d05ae34bb3 100644 --- a/Trio/Sources/Modules/Stat/StatStateModel+Setup/StackedChartSetup.swift +++ b/Trio/Sources/Modules/Stat/StatStateModel+Setup/StackedChartSetup.swift @@ -72,7 +72,7 @@ extension Stat.StateModel { do { return try taskContext.existingObject(with: id) as? GlucoseStored } catch { - debugPrint("\(DebuggingIdentifiers.failed) Error fetching glucose: \(error)") + warning(.ui, "Loading glucose for statistics chart failed", error: error) return nil } } diff --git a/Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift b/Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift index 91607d1cb8..1e242263c2 100644 --- a/Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift +++ b/Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift @@ -29,7 +29,7 @@ extension Stat.StateModel { // Initially calculate and cache daily averages await calculateAndCacheTDDAverages() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) failed fetching TDD stats: \(error)") + warning(.ui, "Fetching total daily dose statistics failed", error: error) } } } diff --git a/Trio/Sources/Modules/Stat/StatStateModel.swift b/Trio/Sources/Modules/Stat/StatStateModel.swift index 696f4c9eb3..e52b153545 100644 --- a/Trio/Sources/Modules/Stat/StatStateModel.swift +++ b/Trio/Sources/Modules/Stat/StatStateModel.swift @@ -175,7 +175,7 @@ extension Stat { return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID } } } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Error fetching glucose for stats: \(error)") + warning(.ui, "Fetching glucose for statistics failed", error: error) return [] } } @@ -187,9 +187,7 @@ extension Stat { } glucoseFromPersistence = glucoseObjects } catch { - debugPrint( - "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the glucose array: \(error)" - ) + warning(.ui, "Loading glucose for statistics failed", error: error) } } @@ -200,9 +198,7 @@ extension Stat { } glucoseReadings = glucoseObjects } catch { - debugPrint( - "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the all glucose array: \(error.localizedDescription)" - ) + warning(.ui, "Loading all glucose readings for statistics failed", error: error) } } } diff --git a/Trio/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift b/Trio/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift index 0d73b2bfdd..1e1b785e03 100644 --- a/Trio/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift +++ b/Trio/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift @@ -95,6 +95,7 @@ extension TargetsEditor { } let profile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets) provider.saveProfile(profile) + info(.settings, "Glucose target schedule saved with \(targets.count) entries") initialItems = items.map { Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) } DispatchQueue.main.async { @@ -105,13 +106,9 @@ extension TargetsEditor { Task.detached(priority: .low) { do { - debug(.nightscout, "Attempting to upload targets to Nightscout") try await self.nightscout.uploadProfiles() } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) failed to upload targets to Nightscout: \(error)" - ) + warning(.nightscout, "Uploading profile after glucose target change failed", error: error) } } diff --git a/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift b/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift index c82c95e3a7..e8049279d3 100644 --- a/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift +++ b/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift @@ -214,15 +214,10 @@ extension Treatments { return } - debug(.bolusState, "subscribe fired") setupBolusStateConcurrently() subscribeToBolusProgress() } - deinit { - debug(.bolusState, "StateModel deinit called") - } - private var hasCleanedUp = false /// In-flight work started by this instance; cancelled in `cleanupTreatmentState()`. @@ -248,12 +243,9 @@ extension Treatments { broadcaster?.unregister(DeterminationObserver.self, observer: self) broadcaster?.unregister(BolusFailureObserver.self, observer: self) - - debug(.bolusState, "StateModel cleanup() finished") } private func setupBolusStateConcurrently() { - debug(.bolusState, "Setting up bolus state concurrently...") setupTask = Task { // Load settings and observers first so the determination controller's initial // population (which runs calculateInsulin) sees correct values. @@ -273,7 +265,7 @@ extension Treatments { try await group.waitForAll() } } catch let error as NSError { - debug(.default, "Failed to setup bolus state concurrently: \(error)") + warning(.bolus, "Setting up bolus screen failed", error: error) } // viewContext-bound FRCs: guard and wiring share one main-actor slice. @@ -396,7 +388,7 @@ extension Treatments { for (index, entry) in entries.enumerated() { guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else { - debug(.default, "Invalid entry start time: \(entry.start)") + warning(.bolus, "Skipped therapy setting entry with invalid start time \(entry.start)") continue } @@ -502,7 +494,6 @@ extension Treatments { func invokeTreatmentsTask() { Task { - debug(.bolusState, "invokeTreatmentsTask fired") await MainActor.run { self.addButtonPressed = true } @@ -542,8 +533,6 @@ extension Treatments { // MARK: - Insulin private func handleInsulin(isExternal: Bool) async { - debug(.bolusState, "handleInsulin fired") - if !isExternal { await addPumpInsulin() } else { @@ -651,10 +640,11 @@ extension Treatments { await MainActor.run { self.isAwaitingDeterminationResult = true } + info(.bolus, "Bolus of \(maxAmount) U requested") await apsManager.enactBolus(amount: maxAmount, isSMB: false, callback: nil) } } catch { - debug(.bolusState, "Authentication error for pump bolus: \(error)") + warning(.bolus, "Authentication for bolus failed", error: error) await MainActor.run { self.isAwaitingDeterminationResult = false @@ -685,11 +675,12 @@ extension Treatments { } // store external dose to pump history await pumpHistoryStorage.storeExternalInsulinEvent(amount: amount, timestamp: date) + info(.bolus, "External insulin of \(amount) U logged") // perform determine basal sync try await apsManager.determineBasalSync() } } catch { - debug(.bolusState, "authentication error for external insulin: \(error)") + warning(.bolus, "Authentication for external insulin failed", error: error) await MainActor.run { self.isAwaitingDeterminationResult = false self.showDeterminationFailureAlert = true @@ -724,6 +715,7 @@ extension Treatments { fpuID: fat > 0 || protein > 0 ? UUID().uuidString : nil )] try await carbsStorage.storeCarbs(carbsToStore, areFetchedFromRemote: false) + info(.treatments, "Meal saved: carbs \(carbs) g, fat \(fat) g, protein \(protein) g") // only perform determine basal sync if the user doesn't use the pump bolus, otherwise the enact bolus func in the APSManger does a sync if amount <= 0 { @@ -733,7 +725,7 @@ extension Treatments { try await apsManager.determineBasalSync() } } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to save carbs: \(error)") + warning(.treatments, "Saving meal failed", error: error) } } @@ -747,7 +739,7 @@ extension Treatments { guard viewContext.hasChanges else { return } try viewContext.save() } catch { - print(error.localizedDescription) + warning(.treatments, "Deleting meal preset failed", error: error) } carbs = 0 fat = 0 @@ -781,13 +773,9 @@ extension Treatments { extension Treatments.StateModel: DeterminationObserver, BolusFailureObserver { func determinationDidUpdate(_: Determination) { - guard isActive else { - debug(.bolusState, "skipping determinationDidUpdate; view not active") - return - } + guard isActive else { return } DispatchQueue.main.async { - debug(.bolusState, "determinationDidUpdate fired") self.isAwaitingDeterminationResult = false if self.addButtonPressed { self.hideModal() @@ -799,7 +787,6 @@ extension Treatments.StateModel: DeterminationObserver, BolusFailureObserver { DispatchQueue.main.async { // A dismissed instance may still observe until dealloc — don't hide an unrelated modal. guard self.isActive else { return } - debug(.bolusState, "bolusDidFail fired") self.isAwaitingDeterminationResult = false if self.addButtonPressed { self.hideModal() @@ -825,7 +812,7 @@ extension Treatments.StateModel { try glucoseController.performFetch() updateGlucoseFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform glucose fetch: \(error)") + warning(.bolus, "Fetching glucose for bolus screen failed", error: error) } } @@ -875,7 +862,7 @@ extension Treatments.StateModel { updateDeterminationFromController() scheduleInsulinAndForecastUpdate() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform determination fetch: \(error)") + warning(.bolus, "Fetching determination for bolus screen failed", error: error) } } @@ -969,19 +956,14 @@ extension Treatments.StateModel { extension Treatments.StateModel { @MainActor func updateForecasts(with forecastData: Determination? = nil) async { - guard isActive else { - return - debug(.bolusState, "updateForecasts not fired") - } + guard isActive else { return } - debug(.bolusState, "updateForecasts fired") if let forecastData = forecastData { simulatedDetermination = forecastData - debugPrint("\(DebuggingIdentifiers.failed) minPredBG: \(minPredBG)") + debug(.bolus, "Forecast taken from latest determination, min predicted BG \(minPredBG) mg/dL") } else { let simulated = await Task { [self] in - debug(.bolusState, "calling simulateDetermineBasal to get forecast data") - return await apsManager.simulateDetermineBasal( + await apsManager.simulateDetermineBasal( simulatedCarbsAmount: carbs, simulatedBolusAmount: amount, simulatedCarbsDate: date @@ -996,7 +978,7 @@ extension Treatments.StateModel { if let simDetermination = simulated { evBG = Decimal(simDetermination.eventualBG ?? 0) minPredBG = simDetermination.minPredBG ?? 0 - debugPrint("\(DebuggingIdentifiers.inProgress) minPredBG: \(minPredBG)") + debug(.bolus, "Forecast simulated, min predicted BG \(minPredBG) mg/dL") } } @@ -1066,7 +1048,7 @@ extension Treatments.StateModel { try lastBolusController.performFetch() updateLastBolusFromController() } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform last bolus fetch: \(error)") + warning(.bolus, "Fetching last bolus for bolus screen failed", error: error) } } diff --git a/Trio/Sources/Modules/Treatments/View/MealPreset/MealPresetView.swift b/Trio/Sources/Modules/Treatments/View/MealPreset/MealPresetView.swift index d652850772..107831befd 100644 --- a/Trio/Sources/Modules/Treatments/View/MealPreset/MealPresetView.swift +++ b/Trio/Sources/Modules/Treatments/View/MealPreset/MealPresetView.swift @@ -349,7 +349,7 @@ struct MealPresetView: View { try moc.save() showAddNewPresetSheet.toggle() } catch let error as NSError { - debugPrint("\(DebuggingIdentifiers.failed) Failed to save Meal Preset with error: \(error.userInfo)") + warning(.treatments, "Saving meal preset failed", error: error) } } } diff --git a/Trio/Sources/Services/Adjustments/AdjustmentManager.swift b/Trio/Sources/Services/Adjustments/AdjustmentManager.swift index 1fe7e3f44f..3bde436dea 100644 --- a/Trio/Sources/Services/Adjustments/AdjustmentManager.swift +++ b/Trio/Sources/Services/Adjustments/AdjustmentManager.swift @@ -192,7 +192,7 @@ final class BaseAdjustmentManager: AdjustmentManager, Injectable, @unchecked Sen ) async throws -> AdjustmentOutcome { let outcome = try await serializer.run { try await self.commitOverride(activating: preset, authorize: authorize) } let action = preset == nil ? "cancelled" : "activated" - debug(.service, "Override \(action) from \(source.rawValue): \(outcome.logDescription)") + info(.treatments, "Override \(action) from \(source.rawValue): \(outcome.logDescription)") await applyOverrideSideEffects(source: source, waitForUpload: waitForUpload) return outcome } @@ -298,7 +298,7 @@ final class BaseAdjustmentManager: AdjustmentManager, Injectable, @unchecked Sen try await self.commitTempTarget(activating: preset, authorize: authorize) } let action = preset == nil ? "cancelled" : "activated" - debug(.service, "Temp target \(action) from \(source.rawValue): \(outcome.logDescription)") + info(.treatments, "Temp target \(action) from \(source.rawValue): \(outcome.logDescription)") await applyTempTargetSideEffects(orefTarget: orefTarget, source: source, waitForUpload: waitForUpload) return outcome } @@ -405,7 +405,7 @@ final class BaseAdjustmentManager: AdjustmentManager, Injectable, @unchecked Sen do { try await apsManager.determineBasalSync() } catch { - debug(.service, "Determination after adjustment change failed: \(error)") + warning(.treatments, "Refreshing determination after adjustment change failed", error: error) } } } diff --git a/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift b/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift index c5e02f7aa8..11cbcc3146 100644 --- a/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift +++ b/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift @@ -1,6 +1,5 @@ import Foundation import LoopKit -import os.log #if canImport(AlarmKit) import ActivityKit @@ -22,8 +21,6 @@ import os.log /// Ported from Loop's `CriticalAlertAlarmScheduler`. @MainActor final class CriticalAlertAlarmScheduler { - private let log = OSLog(subsystem: "org.nightscout.trio", category: "CriticalAlertAlarmScheduler") - /// Maps an issued alert to the AlarmKit alarm scheduled for it, so the /// alarm can be cancelled when the alert is acknowledged or retracted. private var alarmsByAlert: [Alert.Identifier: UUID] = [:] @@ -47,13 +44,8 @@ final class CriticalAlertAlarmScheduler { guard AlarmManager.shared.authorizationState == .notDetermined else { return } do { _ = try await AlarmManager.shared.requestAuthorization() - } catch { - os_log( - "AlarmKit authorization request failed: %{public}@", - log: OSLog(subsystem: "org.nightscout.trio", category: "CriticalAlertAlarmScheduler"), - type: .error, - String(describing: error) - ) + } catch let authorizationError { + warning(.alerts, "AlarmKit authorization request failed", error: authorizationError) } #endif } @@ -99,19 +91,16 @@ final class CriticalAlertAlarmScheduler { let id = UUID() alarmsByAlert[alert.identifier] = id - let log = self.log let identifier = alert.identifier Task { [weak self] in do { _ = try await AlarmManager.shared.schedule(id: id, configuration: configuration) - os_log( - "Scheduled AlarmKit alarm %{public}@ for %{public}@", - log: log, type: .info, id.uuidString, identifier.value - ) - } catch { - os_log( - "Failed to schedule AlarmKit alarm for %{public}@: %{public}@ — falling back to audio", - log: log, type: .error, identifier.value, String(describing: error) + info(.alerts, "AlarmKit alarm scheduled for \(identifier.value)") + } catch let scheduleError { + warning( + .alerts, + "Scheduling AlarmKit alarm for \(identifier.value) failed, falling back to audio", + error: scheduleError ) self?.alarmsByAlert.removeValue(forKey: identifier) onScheduleFailure() @@ -131,10 +120,7 @@ final class CriticalAlertAlarmScheduler { // The alarm may be ringing or merely scheduled; try both and ignore errors. try? AlarmManager.shared.stop(id: id) try? AlarmManager.shared.cancel(id: id) - os_log( - "Cancelled AlarmKit alarm %{public}@ for %{public}@", - log: log, type: .info, id.uuidString, identifier.value - ) + debug(.alerts, "AlarmKit alarm cancelled for \(identifier.value)") #endif } } diff --git a/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift b/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift index e3965f2425..896e1b0922 100644 --- a/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift +++ b/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift @@ -1,13 +1,10 @@ import AudioToolbox import AVFoundation import MediaPlayer -import os.log import UIKit @MainActor final class CriticalAlertAudioPlayer { - private let log = OSLog(subsystem: "org.nightscout.Trio", category: "CriticalAlertAudioPlayer") - private var player: AVAudioPlayer? private var vibrationTimer: Timer? @@ -37,12 +34,7 @@ final class CriticalAlertAudioPlayer { let url = Bundle.main.url(forResource: resource, withExtension: ext) ?? Bundle.main.url(forResource: "critical", withExtension: "caf") guard let url else { - os_log( - "Neither %{public}@ nor critical.caf found in main bundle; audio fallback unavailable", - log: log, - type: .error, - soundName - ) + error(.alerts, "Critical alert sound \(soundName) and fallback sound missing, audio fallback unavailable") return } @@ -65,14 +57,14 @@ final class CriticalAlertAudioPlayer { p.volume = 1.0 p.prepareToPlay() guard p.play() else { - os_log("AVAudioPlayer.play() returned false for %{public}@", log: log, type: .error, url.lastPathComponent) + error(.alerts, "Critical alert audio did not start for \(url.lastPathComponent)") try? session.setActive(false, options: [.notifyOthersOnDeactivation]) return } player = p - os_log("Started critical-alert audio playback (duration=%{public}.2fs)", log: log, type: .info, p.duration) - } catch { - os_log("Failed to start audio playback: %{public}@", log: log, type: .error, String(describing: error)) + info(.alerts, "Critical alert audio started, sound length \(String(format: "%.2f", p.duration)) s") + } catch let playbackError { + error(.alerts, "Critical alert audio playback failed", error: playbackError) } } @@ -92,7 +84,7 @@ final class CriticalAlertAudioPlayer { vibrationTimer = nil volumeBooster.restore() try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) - os_log("Stopped critical-alert audio playback", log: log, type: .info) + debug(.alerts, "Critical alert audio stopped") } } diff --git a/Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift b/Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift index 389d8f3e3d..513ee3f384 100644 --- a/Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift +++ b/Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift @@ -426,7 +426,7 @@ final class GlucoseAlertCoordinator: Injectable { return Decimal(latest.glucose) } } catch { - debug(.service, "GlucoseAlertCoordinator: glucose fetch failed: \(error)") + warning(.alerts, "Fetching latest glucose for glucose alerts failed", error: error) return nil } } diff --git a/Trio/Sources/Services/Alerts/TrioAlertManager.swift b/Trio/Sources/Services/Alerts/TrioAlertManager.swift index c5081de4ee..93042db1c1 100644 --- a/Trio/Sources/Services/Alerts/TrioAlertManager.swift +++ b/Trio/Sources/Services/Alerts/TrioAlertManager.swift @@ -204,10 +204,7 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable { // MARK: - Issue / Retract func issueAlert(_ alert: Alert) { - debug( - .service, - "TrioAlertManager.issueAlert \(alert.identifier.value) level=\(alert.interruptionLevel)" - ) + info(.alerts, "Alert \(alert.identifier.value) issued, level \(alert.interruptionLevel)") let now = Date() let fireDate: Date @@ -231,16 +228,13 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable { // Per-tier snooze. Critical tier ignores snooze. if tier != .critical, DeviceAlertsStore.shared.isTierSnoozed(tier, at: fireDate) { - debug(.service, "TrioAlertManager dropped \(alert.identifier.value): tier \(tier) snoozed") + debug(.alerts, "Alert \(alert.identifier.value) dropped, tier \(tier) snoozed") return } // nil = every variant in the tier disabled, user opted out. let isNight = GlucoseAlertsStore.shared.configuration.isNight(at: now) guard let config = DeviceAlertsStore.shared.config(for: tier, at: now, isNight: isNight) else { - debug( - .service, - "TrioAlertManager dropped \(alert.identifier.value): all variants in tier \(tier) disabled" - ) + debug(.alerts, "Alert \(alert.identifier.value) dropped, all variants in tier \(tier) disabled") // nothing will ever be shown to acknowledge, so clear it on the // device too, otherwise e.g. an Omnipod keeps beeping acknowledgeOnDevice(identifier: alert.identifier) @@ -255,11 +249,11 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable { // suppressed entirely while muted (no modal, no UN sound, no critical // audio fallback). if effective.interruptionLevel != .critical, muter.shouldMute(at: fireDate) { - debug(.service, "TrioAlertManager muted \(effective.identifier.value) (snooze window active)") + debug(.alerts, "Alert \(effective.identifier.value) muted, snooze window active") return } guard throttler.shouldDeliver(effective) else { - debug(.service, "TrioAlertManager throttled \(effective.identifier.value)") + debug(.alerts, "Alert \(effective.identifier.value) throttled") return } queue.async { @@ -343,7 +337,7 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable { isMuted: muted ) { case let .replay(alert): - debug(.service, "TrioAlertManager replaying \(alert.identifier.value)") + debug(.alerts, "Alert \(alert.identifier.value) replayed after launch") queue.async { self.liveAlerts[alert.identifier] = alert } modalScheduler.schedule(alert) case .acknowledgeSilently: @@ -384,7 +378,7 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable { else { return } responder.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) { error in if let error = error { - debug(.service, "AlertManager ack failed for \(identifier.value): \(error)") + warning(.alerts, "Acknowledging alert \(identifier.value) on device failed", error: error) } } } diff --git a/Trio/Sources/Services/Alerts/TrioUserNotificationAlertScheduler.swift b/Trio/Sources/Services/Alerts/TrioUserNotificationAlertScheduler.swift index 93d375890f..a2352f0ba8 100644 --- a/Trio/Sources/Services/Alerts/TrioUserNotificationAlertScheduler.swift +++ b/Trio/Sources/Services/Alerts/TrioUserNotificationAlertScheduler.swift @@ -22,7 +22,7 @@ final class TrioUserNotificationAlertScheduler { let request = makeRequest(alert: alert, muted: muted, soundURL: soundURL, silenced: silenced) notificationCenter.add(request) { error in if let error = error { - debug(.service, "UserNotificationAlertScheduler failed: \(error.localizedDescription)") + warning(.alerts, "Scheduling alert notification \(alert.identifier.value) failed", error: error) } } } diff --git a/Trio/Sources/Services/AppVersionChecker/AppVersionChecker.swift b/Trio/Sources/Services/AppVersionChecker/AppVersionChecker.swift index 719996a0b3..411b65ddb5 100644 --- a/Trio/Sources/Services/AppVersionChecker/AppVersionChecker.swift +++ b/Trio/Sources/Services/AppVersionChecker/AppVersionChecker.swift @@ -227,16 +227,7 @@ import UIKit let configContents = versionData.flatMap { String(data: $0, encoding: .utf8) } let fetchedDevVersion = configContents.flatMap { self.parseDevVersionFromConfig(contents: $0) } - #if DEBUG - print("AppVersionChecker.fetchDevVersion: Current dev version: \(currentVersion)") - print("AppVersionChecker.fetchDevVersion: Fetched dev version: \(fetchedDevVersion ?? "nil")") - if let contents = configContents { - let lines = contents.split(separator: "\n") - for line in lines where line.contains("VERSION") { - print("AppVersionChecker.fetchDevVersion: Config line: \(line)") - } - } - #endif + debug(.lifecycle, "Dev version check: running \(currentVersion), latest \(fetchedDevVersion ?? "unknown")") // Determine if the fetched dev version is newer than the current version let isNewer = fetchedDevVersion.map { diff --git a/Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift b/Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift index e6d1d6a60f..116e900713 100644 --- a/Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift +++ b/Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift @@ -99,7 +99,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable { for (index, entry) in entries.enumerated() { guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else { - debug(.default, "Invalid entry start time: \(entry.start)") + warning(.bolus, "Skipped therapy setting entry with invalid start time \(entry.start)") continue } @@ -369,10 +369,6 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable { lastLoopDate: lastLoopDate ) } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Error preparing calculation input: \(error)" - ) // Return default values in case of error throw error } @@ -383,62 +379,51 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable { /// - Returns: CalculationResult with detailed breakdown of the calculation func calculateInsulin(input: CalculationInput) async -> CalculationResult { debug( - .service, - "Concentration U\(Int(truncating: NSDecimalNumber(decimal: insulinConcentration * 100))), Bolus increment: \(bolusIncrement)" + .bolus, + "Concentration U\(Int(truncating: NSDecimalNumber(decimal: insulinConcentration * 100))), bolus increment \(bolusIncrement) U" ) // insulin needed for the current blood glucose let targetDifference = input.currentBG - input.target - debug(.default, "Target difference: \(targetDifference)") let targetDifferenceInsulin = targetDifference / input.isf - debug(.default, "Target difference insulin: \(targetDifferenceInsulin)") // more or less insulin because of bg trend in the last 15 minutes let fifteenMinutesInsulin = input.deltaBG / input.isf - debug(.default, "15min insulin: \(fifteenMinutesInsulin)") // determine whole COB for which we want to dose insulin for and then determine insulin for wholeCOB // we need to take backdated carbs into account - so we are using a freshly created (simulated) COB if carbs are backdated, otherwise it will default to the mostRecentDeterminations' COB value (as before) let wholeCob = min(Decimal(input.cob) + input.carbs, input.maxCOB) let wholeCobInsulin = wholeCob / input.carbRatio - debug(.default, "Whole COB: \(wholeCob), COB insulin: \(wholeCobInsulin)") // determine how much the calculator reduces/ increases the bolus because of IOB let iobInsulinReduction = (-1) * input.iob - debug(.default, "IOB reduction: \(iobInsulinReduction)") // adding everything together // add a calc for the case that no fifteenMinInsulin is available let wholeCalc: Decimal if input.deltaBG != 0 { wholeCalc = (targetDifferenceInsulin + iobInsulinReduction + wholeCobInsulin + fifteenMinutesInsulin) - debug(.default, "Whole calc (with delta): \(wholeCalc)") } else { // add (rare) case that no glucose value is available -> maybe display warning? // if no bg is available, ?? sets its value to 0 if input.currentBG == 0 { wholeCalc = (iobInsulinReduction + wholeCobInsulin) - debug(.default, "Whole calc (no BG): \(wholeCalc)") } else { wholeCalc = (targetDifferenceInsulin + iobInsulinReduction + wholeCobInsulin) - debug(.default, "Whole calc (no delta): \(wholeCalc)") } } // apply custom factor at the end of the calculations // apply custom factor if reduced bolus toggle in bolus calc config settings is on and the box for reduced bolus is checked (in RootView) var factoredInsulin = wholeCalc - debug(.default, "Initial factored insulin: \(factoredInsulin)") // Apply Recommended Bolus Percentage (input.fraction) and if selected apply Reduced Bolus Percentage (input.fattyMealFactor) // If factoredInsulin is negative, though, don't apply either if factoredInsulin > 0 { factoredInsulin *= input.fraction - debug(.default, "After fraction (\(input.fraction)): \(factoredInsulin)") if input.useFattyMealCorrectionFactor { factoredInsulin *= input.fattyMealFactor - debug(.default, "After reduced bolus factor (\(input.fattyMealFactor)): \(factoredInsulin)") } } @@ -447,36 +432,35 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable { if input.useSuperBolus { superBolusInsulin = input.sweetMealFactor * input.basal factoredInsulin += superBolusInsulin - debug(.default, "After super bolus (\(superBolusInsulin)): \(factoredInsulin)") } // the final result for recommended insulin amount var insulinCalculated: Decimal let isLoopStale = Date().timeIntervalSince(input.lastLoopDate) > 15 * 60 - debug(.default, "Loop stale: \(isLoopStale), currentBG: \(input.currentBG), minPredBG: \(input.minPredBG)") // don't recommend insulin when current glucose or minPredBG is < 54 or last sucessful loop was over 15 minutes ago if input.currentBG < 54 || input.minPredBG < 54 || isLoopStale { insulinCalculated = 0 - debug(.default, "Insulin set to 0 due to safety check - BG < 54 or stale loop") + debug(.bolus, "Recommendation set to 0 U: BG or min predicted BG below 54 mg/dL, or last loop over 15 min ago") } else { // no negative insulinCalculated insulinCalculated = max(factoredInsulin, 0) - debug(.default, "After max(0): \(insulinCalculated)") // don't exceed maxBolus insulinCalculated = min(insulinCalculated, input.maxBolus) - debug(.default, "After maxBolus (\(input.maxBolus)): \(insulinCalculated)") // don't exceed maxIOB insulinCalculated = min(insulinCalculated, input.maxIOB - input.iob) - debug(.default, "After maxIOB check (\(input.maxIOB) - \(input.iob)): \(insulinCalculated)") // round calculated recommendation to allowed bolus increment insulinCalculated = apsManager.roundBolus(amount: insulinCalculated) - debug(.default, "Final rounded insulin: \(insulinCalculated)") } + debug( + .bolus, + "Recommendation \(insulinCalculated) U: BG \(input.currentBG) mg/dL, delta \(input.deltaBG) mg/dL, target \(input.target) mg/dL, ISF \(input.isf) mg/dL/U, CR \(input.carbRatio) g/U, COB \(wholeCob) g, IOB \(input.iob) U, unadjusted \(wholeCalc.rounded(toPlaces: 2)) U, adjusted \(factoredInsulin.rounded(toPlaces: 2)) U" + ) + return CalculationResult( insulinCalculated: insulinCalculated, factoredInsulin: factoredInsulin, @@ -520,11 +504,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable { ) let result = await calculateInsulin(input: input) return result - } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Error in bolus calculation: \(error)" - ) + } catch let calculationError { + error(.bolus, "Bolus calculation failed, recommending 0 U", error: calculationError) // Return safe default values return CalculationResult( insulinCalculated: 0, diff --git a/Trio/Sources/Services/Calendar/CalendarManager.swift b/Trio/Sources/Services/Calendar/CalendarManager.swift index 972258ca61..c616e04346 100644 --- a/Trio/Sources/Services/Calendar/CalendarManager.swift +++ b/Trio/Sources/Services/Calendar/CalendarManager.swift @@ -118,16 +118,14 @@ final class BaseCalendarManager: CalendarManager, Injectable { if #available(iOS 17.0, *) { EKEventStore().requestFullAccessToEvents { granted, error in if let error = error { - print("Calendar access not granted: \(error)") - warning(.service, "Calendar access not granted", error: error) + warning(.ui, "Calendar access not granted", error: error) } continuation.resume(returning: granted) } } else { EKEventStore().requestAccess(to: .event) { granted, error in if let error = error { - print("Calendar access not granted: \(error)") - warning(.service, "Calendar access not granted", error: error) + warning(.ui, "Calendar access not granted", error: error) } continuation.resume(returning: granted) } @@ -135,8 +133,7 @@ final class BaseCalendarManager: CalendarManager, Injectable { #else EKEventStore().requestAccess(to: .event) { granted, error in if let error = error { - print("Calendar access not granted: \(error)") - warning(.service, "Calendar access not granted", error: error) + warning(.ui, "Calendar access not granted", error: error) } continuation.resume(returning: granted) } @@ -155,8 +152,7 @@ final class BaseCalendarManager: CalendarManager, Injectable { return await withCheckedContinuation { continuation in EKEventStore().requestFullAccessToEvents { granted, error in if let error = error { - print("Calendar access not upgraded: \(error)") - warning(.service, "Calendar access not upgraded", error: error) + warning(.ui, "Calendar access not upgraded to full access", error: error) } continuation.resume(returning: granted) } @@ -166,7 +162,7 @@ final class BaseCalendarManager: CalendarManager, Injectable { } #endif @unknown default: - warning(.service, "Unknown calendar access status") + warning(.ui, "Unknown calendar access status") return false } } @@ -310,9 +306,7 @@ final class BaseCalendarManager: CalendarManager, Injectable { previousDeterminationId = determinationId } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to create calendar event: \(error)" - ) + warning(.ui, "Creating calendar event failed", error: error) } } @@ -335,7 +329,7 @@ final class BaseCalendarManager: CalendarManager, Injectable { do { try eventStore.remove(event, span: .thisEvent) } catch { - warning(.service, "Cannot remove calendar events", error: error) + warning(.ui, "Removing calendar event failed", error: error) } } } diff --git a/Trio/Sources/Services/ContactImage/ContactImageManager.swift b/Trio/Sources/Services/ContactImage/ContactImageManager.swift index d33305e9b3..91d1959a16 100644 --- a/Trio/Sources/Services/ContactImage/ContactImageManager.swift +++ b/Trio/Sources/Services/ContactImage/ContactImageManager.swift @@ -153,7 +153,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { for (index, entry) in entries.enumerated() { guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else { - debug(.default, "Invalid entry start time: \(entry.start)") + warning(.ui, "Skipped glucose target with invalid start time \(entry.start)") continue } @@ -273,6 +273,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { delegate?.contactImageManagerDidUpdateState(state) } } catch { + warning(.ui, "Updating contact image state failed", error: error) // Still notify delegate with current state, even if there was an error delegate?.contactImageManagerDidUpdateState(state) } @@ -297,7 +298,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { func setImageForContact(contactId: String) async { guard let contactEntry = await contactImageStorage.fetchContactImageEntries().first(where: { $0.contactId == contactId }) else { - debugPrint("\(DebuggingIdentifiers.failed) No matching ContactImageEntry found for contact ID: \(contactId)") + warning(.ui, "No contact image entry found for contact") return } @@ -315,7 +316,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { ) guard let contact = contacts.first else { - debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(contactId) not found.") + warning(.ui, "Contact for contact image not found") return } @@ -327,9 +328,9 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { try contactStore.execute(saveRequest) - debugPrint("\(DebuggingIdentifiers.succeeded) Image successfully set for contact ID: \(contactId)") + debug(.ui, "Contact image set") } catch { - debugPrint("\(DebuggingIdentifiers.failed) Failed to set image for contact ID \(contactId): \(error)") + warning(.ui, "Setting contact image failed", error: error) } } @@ -359,9 +360,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { // Ensure the contact exists in the CNContactStore guard let contact = contacts.first else { - debugPrint( - "\(DebuggingIdentifiers.failed) Contact with ID \(contactId) and name \(contactEntry.name) not found." - ) + warning(.ui, "Contact for contact image not found") continue } @@ -376,9 +375,9 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { // Execute the save request to persist the changes try contactStore.execute(saveRequest) - debugPrint("\(DebuggingIdentifiers.succeeded) Updated contact image for \(contactId)") + debug(.ui, "Contact image updated") } catch { - debugPrint("\(DebuggingIdentifiers.failed) Failed to update contact image for \(contactId): \(error)") + warning(.ui, "Updating contact image failed", error: error) } } } @@ -400,7 +399,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { // If contact exists, return its identifier if let existingContact = existingContacts.first { - debugPrint("Found existing contact with name: \(name)") + debug(.ui, "Reusing existing contact with the same name") return existingContact.identifier } @@ -420,13 +419,13 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { ) guard let createdContact = newContacts.first else { - debugPrint("\(DebuggingIdentifiers.failed) Contact creation failed: No contact found after save.") + warning(.ui, "Contact not found after creating it") return nil } return createdContact.identifier } catch { - debugPrint("\(DebuggingIdentifiers.failed) Error creating/finding contact: \(error)") + warning(.ui, "Creating contact failed", error: error) return nil } } @@ -441,7 +440,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keys) return !contacts.isEmpty } catch { - debugPrint("\(DebuggingIdentifiers.failed) Error validating contact: \(error)") + warning(.ui, "Checking whether contact exists failed", error: error) return false } } @@ -459,7 +458,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { ) guard let contact = contacts.first else { - debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.") + debug(.ui, "Contact to delete not found") return false } @@ -469,10 +468,10 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { deleteRequest.delete(mutableContact) try contactStore.execute(deleteRequest) - debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully deleted: \(identifier)") + debug(.ui, "Contact deleted") return true } catch { - debugPrint("\(DebuggingIdentifiers.failed) Error deleting contact: \(error)") + warning(.ui, "Deleting contact failed", error: error) return false } } @@ -496,7 +495,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { ) guard let contact = contacts.first else { - debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.") + warning(.ui, "Contact to update not found") return false } @@ -508,10 +507,10 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { updateRequest.update(mutableContact) try contactStore.execute(updateRequest) - debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully updated: \(identifier)") + debug(.ui, "Contact updated") return true } catch { - debugPrint("\(DebuggingIdentifiers.failed) Error updating contact: \(error)") + warning(.ui, "Updating contact failed", error: error) return false } } diff --git a/Trio/Sources/Services/HealthKit/HealthKitManager.swift b/Trio/Sources/Services/HealthKit/HealthKitManager.swift index c6fb6ab3d0..19ae6d48e7 100644 --- a/Trio/Sources/Services/HealthKit/HealthKitManager.swift +++ b/Trio/Sources/Services/HealthKit/HealthKitManager.swift @@ -127,7 +127,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { guard isAvailableOnCurrentDevice, AppleHealthConfig.healthBGObject != nil else { return } - debug(.service, "HealthKitManager did create") + debug(.healthKit, "HealthKit manager created") } private func registerUploadControllers() { @@ -148,7 +148,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { try self.carbsUploadController.performFetch() try self.insulinUploadController.performFetch() } catch { - debug(.service, "\(DebuggingIdentifiers.failed) Failed to set up HealthKit upload controllers: \(error)") + warning(.healthKit, "Setting up Apple Health upload controllers failed", error: error) } } } @@ -196,10 +196,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { let manualGlucose = try await glucoseStorage.getManualGlucoseNotYetUploadedToHealth() await uploadGlucose(manualGlucose) } catch { - debug( - .service, - "\(DebuggingIdentifiers.failed) Error fetching glucose for health upload: \(error)" - ) + warning(.healthKit, "Fetching glucose for Apple Health upload failed", error: error) } } @@ -230,19 +227,19 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { } guard glucoseSamples.isNotEmpty else { - debug(.service, "No glucose samples available for upload.") + debug(.healthKit, "No glucose samples to upload") return } // Attempt to save the blood glucose samples to Apple Health try await healthKitStore.save(glucoseSamples) - debug(.service, "Successfully stored \(glucoseSamples.count) blood glucose samples in HealthKit.") + debug(.healthKit, "Uploaded \(glucoseSamples.count) glucose samples to Apple Health") // After successful upload, update the isUploadedToHealth flag in Core Data await updateGlucoseAsUploaded(glucose) } catch { - debug(.service, "Failed to upload glucose samples to HealthKit: \(error)") + warning(.healthKit, "Uploading glucose samples to Apple Health failed", error: error) } } @@ -262,10 +259,8 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { guard context.hasChanges else { return } try context.save() - } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)" - ) + } catch { + warning(.healthKit, "Marking glucose as uploaded to Apple Health failed", error: error) } } } @@ -277,10 +272,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { let carbs = try await carbsStorage.getCarbsNotYetUploadedToHealth() await uploadCarbs(carbs) } catch { - debug( - .service, - "\(DebuggingIdentifiers.failed) Error fetching carbs for health upload: \(error)" - ) + warning(.healthKit, "Fetching carbs for Apple Health upload failed", error: error) } } @@ -354,18 +346,18 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { // Attempt to save the samples to Apple Health guard samples.isNotEmpty else { - debug(.service, "No samples available for upload.") + debug(.healthKit, "No carb samples to upload") return } try await healthKitStore.save(samples) - debug(.service, "Successfully stored \(samples.count) carb samples in HealthKit.") + debug(.healthKit, "Uploaded \(samples.count) carb samples to Apple Health") // After successful upload, update the isUploadedToHealth flag in Core Data await updateCarbsAsUploaded(carbs) } catch { - debug(.service, "Failed to upload carb samples to HealthKit: \(error)") + warning(.healthKit, "Uploading carb samples to Apple Health failed", error: error) } } @@ -385,10 +377,8 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { guard context.hasChanges else { return } try context.save() - } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)" - ) + } catch { + warning(.healthKit, "Marking carbs as uploaded to Apple Health failed", error: error) } } } @@ -400,10 +390,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { let events = try await pumpHistoryStorage.getPumpHistoryNotYetUploadedToHealth() await uploadInsulin(events) } catch { - debug( - .service, - "\(DebuggingIdentifiers.failed) Error fetching insulin events for health upload: \(error)" - ) + warning(.healthKit, "Fetching insulin for Apple Health upload failed", error: error) } } @@ -440,7 +427,6 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { case .bolus: // For bolus events, create a HealthKit sample directly if let sample = self.createSample(for: event, sampleType: sampleType) { - debug(.service, "Created HealthKit sample for bolus entry: \(sample)") insulinSamples.append(sample) } case .tempBasal: @@ -477,7 +463,6 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { ) if let sample = self.createSample(for: newEvent, sampleType: sampleType) { - debug(.service, "Created HealthKit sample for initial temp basal entry: \(sample)") insulinSamples.append(sample) } } @@ -490,18 +475,18 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { do { guard insulinSamples.isNotEmpty else { - debug(.service, "No insulin samples available for upload.") + debug(.healthKit, "No insulin samples to upload") return } try await healthKitStore.save(insulinSamples) - debug(.service, "Successfully stored \(insulinSamples.count) insulin samples in HealthKit.") + debug(.healthKit, "Uploaded \(insulinSamples.count) insulin samples to Apple Health") await updateInsulinAsUploaded(insulinEvents) } catch { - debug(.service, "Failed to upload insulin samples to HealthKit: \(error)") + warning(.healthKit, "Uploading insulin samples to Apple Health failed", error: error) } } catch { - debug(.service, "\(DebuggingIdentifiers.failed) Error fetching temp basal entries: \(error)") + warning(.healthKit, "Fetching temp basals for Apple Health upload failed", error: error) } } @@ -624,8 +609,6 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { ] ) - debug(.service, "Created HealthKit sample for adjusted temp basal entry: \(sample)") - // Create and return the HealthKit sample for the adjusted event return sample } @@ -650,10 +633,8 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { guard context.hasChanges else { return } try context.save() - } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)" - ) + } catch { + warning(.healthKit, "Marking insulin as uploaded to Apple Health failed", error: error) } } } @@ -674,9 +655,9 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { do { try await deleteObjects(of: sampleType, predicate: predicate) - debug(.service, "Successfully deleted glucose sample with syncID: \(syncID)") + debug(.healthKit, "Deleted glucose sample \(syncID) from Apple Health") } catch { - warning(.service, "Failed to delete glucose sample with syncID: \(syncID)", error: error) + warning(.healthKit, "Deleting glucose sample \(syncID) from Apple Health failed", error: error) } } @@ -691,9 +672,9 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { do { try await deleteObjects(of: sampleType, predicate: predicate) - debug(.service, "Successfully deleted \(sampleType) with syncID: \(id)") + debug(.healthKit, "Deleted carb sample \(id) from Apple Health") } catch { - warning(.service, "Failed to delete carbs sample with syncID: \(id)", error: error) + warning(.healthKit, "Deleting carb sample \(id) from Apple Health failed", error: error) } } @@ -702,7 +683,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { let sampleType = AppleHealthConfig.healthInsulinObject, checkWriteToHealthPermissions(objectTypeToHealthStore: sampleType) else { - debug(.service, "HealthKit permissions are not available for insulin deletion.") + debug(.healthKit, "No Apple Health write permission, skipping insulin deletion") return } @@ -714,9 +695,9 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { do { try await deleteObjects(of: sampleType, predicate: predicate) - debug(.service, "Successfully deleted insulin sample with syncID: \(syncID)") + debug(.healthKit, "Deleted insulin sample \(syncID) from Apple Health") } catch { - warning(.service, "Failed to delete insulin sample with syncID: \(syncID)", error: error) + warning(.healthKit, "Deleting insulin sample \(syncID) from Apple Health failed", error: error) } } diff --git a/Trio/Sources/Services/LiveActivity/LiveActivityManager.swift b/Trio/Sources/Services/LiveActivity/LiveActivityManager.swift index 72d9f25ed7..e0ec991151 100644 --- a/Trio/Sources/Services/LiveActivity/LiveActivityManager.swift +++ b/Trio/Sources/Services/LiveActivity/LiveActivityManager.swift @@ -166,10 +166,7 @@ final class LiveActivityData: ObservableObject { do { data.determination = try await fetchAndMapDetermination() } catch { - debug( - .default, - "[LiveActivityManager] \(DebuggingIdentifiers.failed) failed to fetch and map determination: \(error)" - ) + warning(.liveActivity, "Fetching determination for Live Activity failed", error: error) } } @@ -178,7 +175,7 @@ final class LiveActivityData: ObservableObject { do { data.override = try await fetchAndMapOverride() } catch { - debug(.default, "[LiveActivityManager] \(DebuggingIdentifiers.failed) failed to fetch and map override: \(error)") + warning(.liveActivity, "Fetching override for Live Activity failed", error: error) } } @@ -187,7 +184,7 @@ final class LiveActivityData: ObservableObject { do { data.tempTarget = try await fetchAndMapTempTarget() } catch { - debug(.default, "[LiveActivityManager] \(DebuggingIdentifiers.failed) failed to fetch and map temp target: \(error)") + warning(.liveActivity, "Fetching temp target for Live Activity failed", error: error) } } @@ -204,10 +201,7 @@ final class LiveActivityData: ObservableObject { do { data.glucoseFromPersistence = try await fetchAndMapGlucose() } catch { - debug( - .default, - "[LiveActivityManager] \(DebuggingIdentifiers.failed) failed to fetch glucose with error: \(error)" - ) + warning(.liveActivity, "Fetching glucose for Live Activity failed", error: error) } } @@ -254,7 +248,7 @@ final class LiveActivityData: ObservableObject { } if let currentActivity { - debug(.default, "[LiveActivityManager] Restored live activity: \(currentActivity.activity.id)") + debug(.liveActivity, "Restored Live Activity \(currentActivity.activity.id)") } } @@ -267,11 +261,11 @@ final class LiveActivityData: ObservableObject { if let currentActivity { if currentActivity.needsRecreation(), UIApplication.shared.applicationState == .active { - debug(.default, "[LiveActivityManager] Ending current activity for recreation: \(currentActivity.activity.id)") + debug(.liveActivity, "Recreating Live Activity \(currentActivity.activity.id)") await endActivity() // After endActivity(), currentActivity is guaranteed to be nil // No recursive task, but explicitly restart - debug(.default, "[LiveActivityManager] Re-pushing update after recreation.") + debug(.liveActivity, "Pushing update after recreation") await pushUpdate(state) } else { let content = ActivityContent( @@ -280,10 +274,10 @@ final class LiveActivityData: ObservableObject { ) // Before the update, check if currentActivity is still valid if let stillCurrent = self.currentActivity, stillCurrent.activity.id == currentActivity.activity.id { - debug(.default, "[LiveActivityManager] Updating current activity: \(stillCurrent.activity.id)") + debug(.liveActivity, "Updating Live Activity \(stillCurrent.activity.id)") await stillCurrent.activity.update(content) } else { - debug(.default, "[LiveActivityManager] Skipped update: currentActivity changed during pushUpdate.") + debug(.liveActivity, "Update skipped, activity changed during push") } } } else { @@ -338,7 +332,7 @@ final class LiveActivityData: ObservableObject { pushType: nil ) currentActivity = ActiveActivity(activity: activity) - debug(.default, "[LiveActivityManager] Created new activity: \(activity.id)") + info(.liveActivity, "Started Live Activity \(activity.id)") // Update the newly created activity with actual data let updateContent = ActivityContent( @@ -346,12 +340,9 @@ final class LiveActivityData: ObservableObject { staleDate: Date.now.addingTimeInterval(5 * 60) ) await activity.update(updateContent) - debug(.default, "[LiveActivityManager] Set initial content for new activity: \(activity.id)") + debug(.liveActivity, "Set initial content for Live Activity \(activity.id)") } catch { - debug( - .default, - "[LiveActivityManager]: Error creating new activity: \(error)" - ) + warning(.liveActivity, "Starting Live Activity failed, will retry on next update", error: error) // Reset currentActivity on error to allow retry on next update currentActivity = nil } @@ -360,20 +351,20 @@ final class LiveActivityData: ObservableObject { /// Ends the current live activity and ensures that all unknown activities are terminated. private func endActivity() async { - debug(.default, "[LiveActivityManager] Ending all live activities...") + debug(.liveActivity, "Ending all Live Activities") if let currentActivity { - debug(.default, "[LiveActivityManager] Ending current activity: \(currentActivity.activity.id)") + debug(.liveActivity, "Ending Live Activity \(currentActivity.activity.id)") await currentActivity.activity.end(nil, dismissalPolicy: .immediate) self.currentActivity = nil } for unknownActivity in Activity.activities { - debug(.default, "[LiveActivityManager] Ending unknown activity: \(unknownActivity.id)") + debug(.liveActivity, "Ending untracked Live Activity \(unknownActivity.id)") await unknownActivity.end(nil, dismissalPolicy: .immediate) } - debug(.default, "[LiveActivityManager] All live activities ended.") + info(.liveActivity, "All Live Activities ended") } /// Restarts the live activity from a Live Activity Intent. @@ -387,30 +378,30 @@ final class LiveActivityData: ObservableObject { while (currentActivity != nil && currentActivity!.activity.activityState != .ended) || Activity .activities.contains(where: { $0.activityState != .ended }) { - debug(.default, "[LiveActivityManager] Waiting for Live Activity to end...") + debug(.liveActivity, "Waiting for Live Activity to end") try? await Task.sleep(nanoseconds: 200_000_000) // 0.2s sleep } // Add additional delay to ensure iOS has fully cleaned up the previous activity - debug(.default, "[LiveActivityManager] Waiting additional time for iOS to clean up...") + debug(.liveActivity, "Waiting for iOS to clean up the ended Live Activity") try? await Task.sleep(nanoseconds: 1_000_000_000) // 1s additional delay await pushCurrentContent() - debug(.default, "[LiveActivityManager] Restarted Live Activity from LiveActivityIntent (via iOS Shortcut)") + info(.liveActivity, "Restarted Live Activity from shortcut") } } @available(iOS 16.2, *) extension LiveActivityManager { @MainActor func pushCurrentContent() async { guard let glucose = data.glucoseFromPersistence, let bg = glucose.first else { - debug(.default, "[LiveActivityManager] pushCurrentContent: no current glucose data available") + debug(.liveActivity, "Live Activity not updated, no glucose available") return } let prevGlucose = data.glucoseFromPersistence?.dropFirst().first guard let determination = data.determination else { - debug(.default, "[LiveActivityManager] pushCurrentContent: no determination available") + debug(.liveActivity, "Live Activity not updated, no determination available") return } diff --git a/Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift b/Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift index aca1dc4768..8986754111 100644 --- a/Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift +++ b/Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift @@ -71,7 +71,7 @@ extension BaseNightscoutManager { try self.carbEntryUploadController.performFetch() try self.glucoseUploadController.performFetch() } catch { - debug(.nightscout, "\(DebuggingIdentifiers.failed) Failed to set up Nightscout upload controllers: \(error)") + warning(.nightscout, "Failed to set up upload controllers", error: error) } } } diff --git a/Trio/Sources/Services/Network/Nightscout/NightscoutAPI.swift b/Trio/Sources/Services/Network/Nightscout/NightscoutAPI.swift index b27af99a1d..8bfffd7e10 100644 --- a/Trio/Sources/Services/Network/Nightscout/NightscoutAPI.swift +++ b/Trio/Sources/Services/Network/Nightscout/NightscoutAPI.swift @@ -100,7 +100,7 @@ extension NightscoutAPI { return reading } } catch { - warning(.nightscout, "Glucose fetching error: \(error)") + warning(.nightscout, "Glucose fetch failed", error: error) return [] } } @@ -143,18 +143,13 @@ extension NightscoutAPI { request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret") } - do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else { - throw URLError(.badServerResponse) - } - let carbs = try JSONCoding.decoder.decode([CarbsEntry].self, from: data) - return carbs - } catch { - warning(.nightscout, "Carbs fetching error: \(error)") - throw error + guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) } + let carbs = try JSONCoding.decoder.decode([CarbsEntry].self, from: data) + return carbs } func deleteCarbs(withId id: String) async throws { @@ -280,19 +275,14 @@ extension NightscoutAPI { request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret") } - do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else { - throw URLError(.badServerResponse) - } + let (data, response) = try await URLSession.shared.data(for: request) - let tempTargets = try JSONCoding.decoder.decode([TempTarget].self, from: data) - return tempTargets - } catch { - warning(.nightscout, "TempTarget fetching error: \(error)") - throw error + guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) } + + let tempTargets = try JSONCoding.decoder.decode([TempTarget].self, from: data) + return tempTargets } func uploadTreatments(_ treatments: [NightscoutTreatment]) async throws { @@ -320,7 +310,7 @@ extension NightscoutAPI { // debugPrint("Payload treatments size: \(encodedBody.count) bytes") // debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload") } catch { - debugPrint("Error encoding payload: \(error)") + debug(.nightscout, "Encoding treatments for upload failed") throw error } request.httpMethod = "POST" @@ -355,7 +345,7 @@ extension NightscoutAPI { // debugPrint("Payload glucose size: \(encodedBody.count) bytes") // debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload") } catch { - debugPrint("Error encoding payload: \(error)") + debug(.nightscout, "Encoding glucose for upload failed") throw error } request.httpMethod = "POST" @@ -391,7 +381,7 @@ extension NightscoutAPI { // debugPrint("Payload status size: \(encodedBody.count) bytes") // debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload") } catch { - debugPrint("Error encoding payload: \(error)") + debug(.nightscout, "Encoding device status for upload failed") throw error } @@ -429,7 +419,7 @@ extension NightscoutAPI { // debugPrint("Payload profile upload size: \(encodedBody.count) bytes") // debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload") } catch { - debugPrint("Error encoding payload: \(error)") + debug(.nightscout, "Encoding profile for upload failed") throw error } request.httpMethod = "POST" @@ -469,7 +459,7 @@ extension NightscoutAPI { if let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) { } else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - debug(.nightscout, "Failed to delete override with created_at: \(createdAt). HTTP status code: \(statusCode)") + debug(.nightscout, "Override deletion failed with HTTP \(statusCode)") throw URLError(.badServerResponse) } } @@ -494,7 +484,7 @@ extension NightscoutAPI { // debugPrint("Payload glucose size: \(encodedBody.count) bytes") // debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload") } catch { - debugPrint("Error encoding payload: \(error)") + debug(.nightscout, "Encoding overrides for upload failed") throw error } request.httpMethod = "POST" @@ -528,31 +518,26 @@ extension NightscoutAPI { request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret") } - do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else { - throw URLError(.badServerResponse) - } - - guard let mimeType = httpResponse.mimeType, mimeType == "application/json" else { - throw URLError(.unsupportedURL) - } + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } - let jsonDecoder = JSONCoding.decoder - let fetchedProfileStore = try jsonDecoder.decode([FetchedNightscoutProfileStore].self, from: data) - guard let fetchedProfile = fetchedProfileStore.first?.store["default"] else { - throw NSError( - domain: "ImportError", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Can't find the default Nightscout Profile."] - ) - } + guard let mimeType = httpResponse.mimeType, mimeType == "application/json" else { + throw URLError(.unsupportedURL) + } - return fetchedProfile - } catch { - warning(.nightscout, "Could not fetch Nightscout Profile! Error: \(error)") - throw error + let jsonDecoder = JSONCoding.decoder + let fetchedProfileStore = try jsonDecoder.decode([FetchedNightscoutProfileStore].self, from: data) + guard let fetchedProfile = fetchedProfileStore.first?.store["default"] else { + throw NSError( + domain: "ImportError", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Can't find the default Nightscout Profile."] + ) } + + return fetchedProfile } } diff --git a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift index a39dba37d2..2b1e1a5607 100644 --- a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift +++ b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift @@ -69,7 +69,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { case .glucose: await performUploadGlucose() case .deviceStatus: do { try await performUploadDeviceStatus() } - catch { debug(.nightscout, "deviceStatus upload failed: \(error)") } + catch { warning(.nightscout, "Device status upload failed", error: error) } } } @@ -289,17 +289,14 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { self.lastEnactedDetermination = await determinationStorage .getOrefDeterminationNotYetUploadedToNightscout(lastEnactedDeterminationID) } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) failed to fetch last enacted determination: \(error)" - ) + warning(.storage, "Failed to fetch last enacted determination", error: error) } } } private func subscribe() { _ = reachabilityManager.startListening(onQueue: processQueue) { status in - debug(.nightscout, "Network status: \(status)") + debug(.nightscout, "Network reachability changed: \(status)") } } @@ -380,7 +377,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { } return glucose } catch { - print(error.localizedDescription) + warning(.nightscout, "Glucose fetch failed", error: error) return [] } } @@ -417,7 +414,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { let carbs = try await nightscout.fetchCarbs(sinceDate: since) return carbs } catch { - debug(.nightscout, "Error fetching carbs: \(error)") + warning(.nightscout, "Carbs fetch failed", error: error) return [] } } @@ -432,7 +429,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { let tempTargets = try await nightscout.fetchTempTargets(sinceDate: since) return tempTargets } catch { - debug(.nightscout, "Error fetching temp targets: \(error)") + warning(.nightscout, "Temp targets fetch failed", error: error) return [] } } @@ -442,13 +439,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { do { try await nightscout.deleteCarbs(withId: id) - debug(.nightscout, "Carbs deleted") + debug(.nightscout, "Carb entry deleted") return true } catch { - debug( - .nightscout, - "\(DebuggingIdentifiers.failed) Failed to delete Carbs from Nightscout with error: \(error)" - ) + warning(.nightscout, "Carb entry deletion failed", error: error) return false } } @@ -458,12 +452,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { do { try await nightscout.deleteInsulin(withId: id) - debug(.nightscout, "Insulin deleted") + debug(.nightscout, "Insulin entry deleted") } catch { - debug( - .nightscout, - "\(DebuggingIdentifiers.failed) Failed to delete Insulin from Nightscout with error: \(error)" - ) + warning(.nightscout, "Insulin entry deletion failed", error: error) } } @@ -472,12 +463,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { do { try await nightscout.deleteGlucose(withId: id, withDate: date) - debug(.nightscout, "Glucose deleted") + debug(.nightscout, "Glucose entry deleted") } catch { - debug( - .nightscout, - "\(DebuggingIdentifiers.failed) Failed to delete Glucose from Nightscout with error: \(error)" - ) + warning(.nightscout, "Glucose entry deletion failed", error: error) } } @@ -494,9 +482,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { let display: Bool? = last.display if let status { - debugPrint( - "NightscoutManager: \(#function) \(DebuggingIdentifiers.succeeded) setup battery from core data successfully" - ) return Battery( percent: percent, voltage: voltage, @@ -505,14 +490,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { ) } } - debugPrint( - "NightscoutManager: \(#function) \(DebuggingIdentifiers.succeeded) successfully fetched; but no battery data available. Returning fallback default." - ) + debug(.storage, "No pump battery data from the last 30 min") return Battery(percent: nil, voltage: nil, string: BatteryState.error, display: nil) } catch { - debugPrint( - "NightscoutManager: \(#function) \(DebuggingIdentifiers.failed) failed to setup battery from core data" - ) + warning(.storage, "Pump battery fetch failed", error: error) return Battery(percent: nil, voltage: nil, string: BatteryState.error, display: nil) } } @@ -537,7 +518,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { /// - Note: Ensure `nightscoutAPI` is initialized and `isUploadEnabled` is set to `true` before invoking this function. private func performUploadDeviceStatus() async throws { guard let nightscout = nightscoutAPI, isUploadEnabled else { - debug(.nightscout, "NS API not available or upload disabled. Aborting NS Status upload.") + debug(.nightscout, "Device status upload skipped: Nightscout not set up or upload disabled") return } @@ -575,10 +556,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { // Guard to ensure both determinations are not nil guard fetchedEnactedDetermination != nil || fetchedSuggestedDetermination != nil else { - debug( - .nightscout, - "Both fetchedEnactedDetermination and fetchedSuggestedDetermination are nil. Aborting NS Status upload." - ) + debug(.nightscout, "Device status upload skipped: no new determination") return } @@ -657,7 +635,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { dosingMode: settingsManager.settings.dosingMode.rawValue ) -// debug(.nightscout, "To be uploaded openapsStatus: \(openapsStatus)") + debug( + .nightscout, + "Device status ready: enacted \(openapsStatus.enacted != nil), suggested \(openapsStatus.suggested != nil), recommended bolus \(recommendedBolus) U" + ) // Gather all relevant data for NS Status let battery = await fetchedBattery @@ -687,16 +668,16 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { do { try await nightscout.uploadDeviceStatus(status) - debug(.nightscout, "NSDeviceStatus with Determination uploaded") + info(.nightscout, "Device status uploaded") if let enacted = fetchedEnactedDetermination { await updateOrefDeterminationAsUploaded([enacted]) - debug(.nightscout, "Flagged last fetched enacted determination as uploaded") + debug(.nightscout, "Enacted determination marked as uploaded") } if let suggested = fetchedSuggestedDetermination { await updateOrefDeterminationAsUploaded([suggested]) - debug(.nightscout, "Flagged last fetched suggested determination as uploaded") + debug(.nightscout, "Suggested determination marked as uploaded") } if let lastEnactedDetermination = fetchedEnactedDetermination { @@ -707,7 +688,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { self.lastSuggestedDetermination = lastSuggestedDetermination } } catch { - debug(.nightscout, String(describing: error)) + warning(.nightscout, "Device status upload failed", error: error) } } @@ -728,9 +709,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)" - ) + warning(.storage, "Failed to mark determinations as uploaded to Nightscout", error: error) } } } @@ -742,20 +721,20 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self ) else { - debug(.nightscout, "NightscoutManager uploadProfile: error loading insulinSensitivities") + warning(.nightscout, "Profile upload skipped: insulin sensitivities could not be loaded") return } guard let targets = await storage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self) else { - debug(.nightscout, "NightscoutManager uploadProfile: error loading bgTargets") + warning(.nightscout, "Profile upload skipped: glucose targets could not be loaded") return } guard let carbRatios = await storage.retrieveAsync(OpenAPS.Settings.carbRatios, as: CarbRatios.self) else { - debug(.nightscout, "NightscoutManager uploadProfile: error loading carbRatios") + warning(.nightscout, "Profile upload skipped: carb ratios could not be loaded") return } guard let basalProfile = await storage.retrieveAsync(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self) else { - debug(.nightscout, "NightscoutManager uploadProfile: error loading basalProfile") + warning(.nightscout, "Profile upload skipped: basal profile could not be loaded") return } @@ -857,9 +836,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { guard let nightscout = nightscoutAPI, isNetworkReachable else { if !isNetworkReachable { - debug(.nightscout, "Network issues; aborting upload") + debug(.nightscout, "Profile upload skipped: network unreachable") + } else { + debug(.nightscout, "Profile upload skipped: Nightscout not set up") } - debug(.nightscout, "Nightscout API service not available; aborting upload") return } @@ -868,26 +848,26 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { BuildDetails.shared.recordUploadedExpireDate(expireDate: expireDate) BuildDetails.shared.recordUploadedRemoteCommands(remoteCommands) - debug(.nightscout, "Profile uploaded") + info(.nightscout, "Profile uploaded") } catch { - debug(.nightscout, "NightscoutManager uploadProfile: \(error)") + warning(.nightscout, "Profile upload failed", error: error) throw error } } else { - debug(.nightscout, "Upload to NS disabled; aborting profile uploaded") + debug(.nightscout, "Profile upload skipped: upload disabled") } } func importSettings() async -> ScheduledNightscoutProfile? { guard let nightscout = nightscoutAPI else { - debug(.nightscout, "NS API not available. Aborting NS Status upload.") + debug(.nightscout, "Settings import skipped: Nightscout not set up") return nil } do { return try await nightscout.importSettings() } catch { - debug(.nightscout, String(describing: error)) + warning(.nightscout, "Settings import failed", error: error) return nil } } @@ -897,10 +877,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { try await uploadGlucose(glucoseStorage.getGlucoseNotYetUploadedToNightscout()) try await uploadNonCoreDataTreatments(glucoseStorage.getCGMStateNotYetUploadedToNightscout()) } catch { - debug( - .nightscout, - "\(DebuggingIdentifiers.failed) failed to upload glucose with error: \(error)" - ) + warning(.nightscout, "Failed to load pending glucose for upload", error: error) } } @@ -908,10 +885,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { do { try await uploadPumpHistory(pumpHistoryStorage.getPumpHistoryNotYetUploadedToNightscout()) } catch { - debug( - .nightscout, - "\(DebuggingIdentifiers.failed) failed to upload pump history with error: \(error)" - ) + warning(.nightscout, "Failed to load pending pump history for upload", error: error) } } @@ -924,10 +898,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { try await uploadCarbs(carbsStorage.getCarbsNotYetUploadedToNightscout()) try await uploadCarbs(carbsStorage.getFPUsNotYetUploadedToNightscout()) } catch { - debug( - .nightscout, - "\(DebuggingIdentifiers.failed) failed to upload carbs with error: \(error)" - ) + warning(.nightscout, "Failed to load pending carbs for upload", error: error) } } @@ -940,10 +911,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { try await uploadOverrides(overridesStorage.getOverridesNotYetUploadedToNightscout()) try await uploadOverrideRuns(overridesStorage.getOverrideRunsNotYetUploadedToNightscout()) } catch { - debug( - .nightscout, - "\(DebuggingIdentifiers.failed) failed to upload overrides with error: \(error)" - ) + warning(.nightscout, "Failed to load pending overrides for upload", error: error) } } @@ -956,10 +924,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { try await uploadTempTargets(await tempTargetsStorage.getTempTargetsNotYetUploadedToNightscout()) try await uploadTempTargetRuns(await tempTargetsStorage.getTempTargetRunsNotYetUploadedToNightscout()) } catch { - debug( - .nightscout, - "\(DebuggingIdentifiers.failed) failed to upload temp targets with error: \(error)" - ) + warning(.nightscout, "Failed to load pending temp targets for upload", error: error) } } @@ -977,9 +942,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { // If successful, update the isUploadedToNS property of the GlucoseStored objects await updateGlucoseAsUploaded(glucose) - debug(.nightscout, "Glucose uploaded") + info(.nightscout, "Uploaded \(glucose.count) glucose values") } catch { - debug(.nightscout, "Upload of glucose failed: \(error)") + warning(.nightscout, "Upload of \(glucose.count) glucose values failed", error: error) } } @@ -1000,9 +965,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)" - ) + warning(.storage, "Failed to mark glucose as uploaded to Nightscout", error: error) } } } @@ -1018,10 +981,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { try await nightscout.uploadTreatments(Array(chunk)) } - debug(.nightscout, "Treatments uploaded") + info(.nightscout, "Uploaded \(treatments.count) treatments") return true } catch { - debug(.nightscout, String(describing: error)) + warning(.nightscout, "Upload of \(treatments.count) treatments failed", error: error) return false } } @@ -1038,9 +1001,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { await updatePumpEventStoredsAsUploaded(treatments) - debug(.nightscout, "Treatments uploaded") + info(.nightscout, "Uploaded \(treatments.count) pump events") } catch { - debug(.nightscout, String(describing: error)) + warning(.nightscout, "Upload of \(treatments.count) pump events failed", error: error) } } @@ -1073,9 +1036,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)" - ) + warning(.storage, "Failed to mark pump events as uploaded to Nightscout", error: error) } } } @@ -1107,9 +1068,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { // If successful, update the isUploadedToNS property of the CarbEntryStored objects await updateCarbsAsUploaded(treatments) - debug(.nightscout, "Treatments uploaded") + info(.nightscout, "Uploaded \(treatments.count) carb entries") } catch { - debug(.nightscout, String(describing: error)) + warning(.nightscout, "Upload of \(treatments.count) carb entries failed", error: error) } } @@ -1130,9 +1091,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)" - ) + warning(.storage, "Failed to mark carb entries as uploaded to Nightscout", error: error) } } } @@ -1168,9 +1127,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { // If successful, update the isUploadedToNS property of the OverrideStored objects await updateOverridesAsUploaded(processedOverrides) - debug(.nightscout, "Overrides uploaded") + info(.nightscout, "Uploaded \(processedOverrides.count) overrides") } catch { - debug(.nightscout, String(describing: error)) + warning(.nightscout, "Upload of \(overrides.count) overrides failed", error: error) } } @@ -1191,9 +1150,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)" - ) + warning(.storage, "Failed to mark overrides as uploaded to Nightscout", error: error) } } } @@ -1226,9 +1183,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { // If successful, update the isUploadedToNS property of the OverrideRunStored objects await updateOverrideRunsAsUploaded(overrideRuns) - debug(.nightscout, "Overrides uploaded") + info(.nightscout, "Uploaded \(processedOverrideRuns.count) override runs") } catch { - debug(.nightscout, String(describing: error)) + warning(.nightscout, "Upload of \(overrideRuns.count) override runs failed", error: error) } } @@ -1249,9 +1206,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)" - ) + warning(.storage, "Failed to mark override runs as uploaded to Nightscout", error: error) } } } @@ -1269,9 +1224,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { // If successful, update the isUploadedToNS property of the TempTargetStored objects await updateTempTargetsAsUploaded(tempTargets) - debug(.nightscout, "Temp Targets uploaded") + info(.nightscout, "Uploaded \(tempTargets.count) temp targets") } catch { - debug(.nightscout, String(describing: error)) + warning(.nightscout, "Upload of \(tempTargets.count) temp targets failed", error: error) } } @@ -1292,9 +1247,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetStored: \(error.userInfo)" - ) + warning(.storage, "Failed to mark temp targets as uploaded to Nightscout", error: error) } } } @@ -1312,9 +1265,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { // If successful, update the isUploadedToNS property of the TempTargetRunStored objects await updateTempTargetRunsAsUploaded(tempTargetRuns) - debug(.nightscout, "Temp Target Runs uploaded") + info(.nightscout, "Uploaded \(tempTargetRuns.count) temp target runs") } catch { - debug(.nightscout, String(describing: error)) + warning(.nightscout, "Upload of \(tempTargetRuns.count) temp target runs failed", error: error) } } @@ -1335,9 +1288,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetRunStored: \(error.userInfo)" - ) + warning(.storage, "Failed to mark temp target runs as uploaded to Nightscout", error: error) } } } diff --git a/Trio/Sources/Services/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index f4e7c608b0..8b033d54c6 100644 --- a/Trio/Sources/Services/Network/TidepoolManager.swift +++ b/Trio/Sources/Services/Network/TidepoolManager.swift @@ -166,7 +166,7 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { try self.carbsUploadController.performFetch() try self.insulinUploadController.performFetch() } catch { - debug(.service, "\(DebuggingIdentifiers.failed) Failed to set up Tidepool upload controllers: \(error)") + warning(.tidepool, "Failed to set up Tidepool upload controllers", error: error) } } @@ -277,7 +277,7 @@ extension BaseTidepoolManager { do { carbs = try await carbsStorage.getCarbsNotYetUploadedToTidepool() } catch { - debug(.service, "\(DebuggingIdentifiers.failed) Failed to fetch carbs for upload: \(error)") + warning(.tidepool, "Failed to load pending carbs for Tidepool upload", error: error) return } guard !carbs.isEmpty else { return } @@ -293,11 +293,11 @@ extension BaseTidepoolManager { switch result { case .success: - debug(.service, "Success synchronizing carbs data. Upload to Tidepool complete.") + info(.tidepool, "Uploaded \(chunk.count) carb entries to Tidepool") // After successful upload, update the isUploadedToTidepool flag in Core Data await updateCarbsAsUploaded(Array(chunk)) case let .failure(error): - debug(.service, "Error synchronizing carbs data with Tidepool: \(String(describing: error))") + warning(.tidepool, "Tidepool upload of \(chunk.count) carb entries failed", error: error) } } } @@ -319,9 +319,7 @@ extension BaseTidepoolManager { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)" - ) + warning(.storage, "Failed to mark carb entries as uploaded to Tidepool", error: error) } } } @@ -354,9 +352,9 @@ extension BaseTidepoolManager { } switch result { case .success: - debug(.service, "Success synchronizing carbs delete data. Upload to Tidepool complete.") + debug(.tidepool, "Carb entry deletion synced to Tidepool") case let .failure(error): - debug(.service, "Error synchronizing carbs delete data with Tidepool: \(String(describing: error))") + warning(.tidepool, "Tidepool carb entry deletion failed", error: error) } } } @@ -381,7 +379,7 @@ extension BaseTidepoolManager { do { events = try await pumpHistoryStorage.getPumpHistoryNotYetUploadedToTidepool() } catch { - debug(.service, "Error fetching pump history: \(error)") + warning(.tidepool, "Failed to load pending pump history for Tidepool upload", error: error) return } guard !events.isEmpty else { return } @@ -442,7 +440,7 @@ extension BaseTidepoolManager { return result } - debug(.service, "Uploading \(insulinDoseEvents.count) dose entries to Tidepool") + debug(.tidepool, "Uploading \(insulinDoseEvents.count) dose entries to Tidepool") let pumpEvents: [PersistedPumpEvent] = events.compactMap { event -> PersistedPumpEvent? in if let pumpEventType = event.type.mapEventTypeToPumpEventType() { @@ -478,13 +476,13 @@ extension BaseTidepoolManager { } switch doseResult { case .success: - debug(.service, "Success synchronizing dose data. Upload to Tidepool complete.") + info(.tidepool, "Uploaded \(insulinDoseEvents.count) dose entries to Tidepool") let insulinEvents = events.filter { $0.type == .tempBasal || $0.type == .tempBasalDuration || $0.type == .bolus } await updateInsulinAsUploaded(insulinEvents) case let .failure(error): - debug(.service, "Error synchronizing dose data with Tidepool: \(String(describing: error))") + warning(.tidepool, "Tidepool upload of \(insulinDoseEvents.count) dose entries failed", error: error) } guard await uploadSerializer.isCurrent(generation) else { return } @@ -494,14 +492,14 @@ extension BaseTidepoolManager { } switch pumpResult { case .success: - debug(.service, "Success synchronizing pump events data. Upload to Tidepool complete.") + info(.tidepool, "Uploaded \(pumpEvents.count) pump events to Tidepool") let pumpEventsToMark = events.filter { $0.type.mapEventTypeToPumpEventType() != nil } await updateInsulinAsUploaded(pumpEventsToMark) case let .failure(error): - debug(.service, "Error synchronizing pump events data: \(String(describing: error))") + warning(.tidepool, "Tidepool upload of \(pumpEvents.count) pump events failed", error: error) } } catch { - debug(.service, "Error fetching temp basal entries: \(error)") + warning(.tidepool, "Failed to prepare dose entries for Tidepool upload", error: error) } } @@ -522,9 +520,7 @@ extension BaseTidepoolManager { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)" - ) + warning(.storage, "Failed to mark pump events as uploaded to Tidepool", error: error) } } } @@ -548,9 +544,9 @@ extension BaseTidepoolManager { } switch result { case .success: - debug(.service, "Success synchronizing Dose delete data") + debug(.tidepool, "Dose deletion synced to Tidepool") case let .failure(error): - debug(.service, "Error synchronizing Dose delete data: \(String(describing: error))") + warning(.tidepool, "Tidepool dose deletion failed", error: error) } } } @@ -671,7 +667,7 @@ extension BaseTidepoolManager { for (index, entry) in basalEntries.enumerated() { guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else { - debug(.default, "Invalid entry start time: \(entry.start)") + warning(.settings, "Basal profile entry has invalid start time \(entry.start)") continue } @@ -727,7 +723,7 @@ extension BaseTidepoolManager { let manualGlucose = try await glucoseStorage.getManualGlucoseNotYetUploadedToTidepool() glucose = cgmGlucose + manualGlucose } catch { - debug(.service, "Error fetching glucose data: \(error)") + warning(.tidepool, "Failed to load pending glucose for Tidepool upload", error: error) return } guard !glucose.isEmpty else { return } @@ -741,11 +737,11 @@ extension BaseTidepoolManager { switch result { case .success: - debug(.service, "Success synchronizing glucose data") + info(.tidepool, "Uploaded \(chunk.count) glucose values to Tidepool") // After successful upload, update the isUploadedToTidepool flag in Core Data await updateGlucoseAsUploaded(Array(chunk)) case let .failure(error): - debug(.service, "Error synchronizing glucose data: \(String(describing: error))") + warning(.tidepool, "Tidepool upload of \(chunk.count) glucose values failed", error: error) } } } @@ -767,9 +763,7 @@ extension BaseTidepoolManager { guard context.hasChanges else { return } try context.save() } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)" - ) + warning(.storage, "Failed to mark glucose as uploaded to Tidepool", error: error) } } } @@ -826,9 +820,9 @@ extension BaseTidepoolManager { } switch result { case .success: - debug(.service, "Settings uploaded to Tidepool (syncId: \(settings.syncIdentifier))") + info(.tidepool, "Settings uploaded to Tidepool") case let .failure(error): - debug(.service, "Failed to upload settings to Tidepool: \(error)") + warning(.tidepool, "Tidepool settings upload failed", error: error) } } } @@ -879,7 +873,7 @@ extension BaseTidepoolManager { ), let bgTargets: BGTargets = storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self) else { - debug(.service, "Failed to load Trio therapy settings for Tidepool upload") + warning(.tidepool, "Tidepool settings upload skipped: therapy settings could not be loaded") return nil } diff --git a/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift b/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift index e1168d5cd8..d0c4d72822 100644 --- a/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift +++ b/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift @@ -89,8 +89,8 @@ actor TidepoolUploadSerializer { let age = Date().timeIntervalSince(start) guard age > watchdogLimit else { return } warning( - .service, - "Tidepool upload chain wedged: '\(headPipeline?.rawValue ?? "?")' has not completed after \(Int(age))s; abandoning chain and starting fresh" + .tidepool, + "Tidepool \(headPipeline?.rawValue ?? "unknown") upload stuck for \(Int(age)) s, restarting upload chain" ) generation += 1 tail = nil @@ -102,10 +102,7 @@ actor TidepoolUploadSerializer { private func begin(_ pipeline: TidepoolUploadPipeline, generation: Int) -> Bool { guard isCurrent(generation) else { if !pipeline.coalesces { - warning( - .service, - "Tidepool '\(pipeline.rawValue)' upload dropped: chain abandoned before it started; payload is not retried" - ) + warning(.tidepool, "Tidepool \(pipeline.rawValue) upload dropped before it started, not retried") } return false } diff --git a/Trio/Sources/Services/Notifications/AlertPermissionsChecker.swift b/Trio/Sources/Services/Notifications/AlertPermissionsChecker.swift index 5a0e79b021..3ec4a18135 100644 --- a/Trio/Sources/Services/Notifications/AlertPermissionsChecker.swift +++ b/Trio/Sources/Services/Notifications/AlertPermissionsChecker.swift @@ -58,6 +58,6 @@ extension AlertPermissionsChecker { private func notificationCenterSettingsChanged(_: Bool) { // TODO: Add processing for other actions in delegate AlertManager, InAppAlertScheduler, etc., from Loop - debug(.default, "notificationCenterSettingsChanged") + debug(.alerts, "Notification settings changed") } } diff --git a/Trio/Sources/Services/ProfileScheduleFirer.swift b/Trio/Sources/Services/ProfileScheduleFirer.swift index bd23cd76aa..8d602d5be2 100644 --- a/Trio/Sources/Services/ProfileScheduleFirer.swift +++ b/Trio/Sources/Services/ProfileScheduleFirer.swift @@ -26,7 +26,6 @@ final class ProfileScheduleFirer { init(resolver: Resolver) { self.resolver = resolver - // debug(.coreData, "ProfileScheduleFirer init — starting sweep loop") startLoop() } @@ -46,7 +45,6 @@ final class ProfileScheduleFirer { private func sweep() async { guard !sweepInFlight else { - // debug(.coreData, "ProfileScheduleFirer: sweep skipped (in flight)") return } sweepInFlight = true @@ -54,7 +52,6 @@ final class ProfileScheduleFirer { let context = coreDataStack.newTaskContext() let now = Date() - // debug(.coreData, "ProfileScheduleFirer: sweep tick @ \(now)") let due: [DueFire] = await context.perform { // Pre-resolve profile names so indefinite-fire notifications can reference the @@ -66,22 +63,17 @@ final class ProfileScheduleFirer { let request = ProfileScheduleStored.fetch(.enabledSchedule) let rows = (try? context.fetch(request)) ?? [] - // debug(.coreData, "ProfileScheduleFirer: \(rows.count) enabled schedules in store") return rows.compactMap { row -> DueFire? in guard let sid = row.id, let pid = row.profileID, let rule = row.rule, let duration = row.duration else { - // debug(.coreData, "ProfileScheduleFirer: row decode failed (id/profile/rule/duration)") return nil } let anchor = row.lastFiredAt ?? row.createdAt ?? .distantPast let nextMaybe = rule.nextFire(after: anchor) - debug( - .coreData, - "ProfileScheduleFirer: schedule \(sid) anchor=\(anchor) next=\(String(describing: nextMaybe)) now=\(now)" - ) + debug(.settings, "Profile schedule next fire \(nextMaybe.map { "\($0)" } ?? "none")") guard let next = nextMaybe, next <= now else { return nil } let isOnce: Bool = { if case .once = rule.repeatRule { return true } @@ -102,20 +94,18 @@ final class ProfileScheduleFirer { } guard !due.isEmpty else { return } - debug(.coreData, "ProfileScheduleFirer: \(due.count) due — firing") + info(.settings, "\(due.count) profile schedules due, activating") let provider = AdaptProfile.Provider(resolver: resolver) for fire in due { switch fire.duration { case let .minutes(m): - // debug(.coreData, "ProfileScheduleFirer: activating \(fire.profileID) for \(m) min") let outcome = await provider.activate( id: fire.profileID, durationMinutes: m, confirmedPumpSync: true ) - // debug(.coreData, "ProfileScheduleFirer: activate outcome = \(outcome)") if outcome != .success { continue } postActivatedNotification(for: fire, minutes: m) case .indefinite, @@ -124,10 +114,7 @@ final class ProfileScheduleFirer { // notification the first time we see this occurrence; skip re-posting on subsequent // sweeps until the user confirms, skips, or a new occurrence comes due. if fire.pendingOccurrence == fire.occurrence { - debug( - .coreData, - "ProfileScheduleFirer: \(fire.scheduleID) indefinite already pending user action" - ) + debug(.settings, "Indefinite profile schedule already waiting for user action") continue } postActivationNotification(for: fire) @@ -147,11 +134,9 @@ final class ProfileScheduleFirer { // Once-schedules are not editable; deleting after fire is cleaner than // leaving a disabled row cluttering the list. context.delete(row) - // debug(.coreData, "ProfileScheduleFirer: deleted one-off \(fire.scheduleID) after fire") } else { row.lastFiredAt = fire.occurrence row.pendingOccurrence = nil - // debug(.coreData, "ProfileScheduleFirer: stamped lastFiredAt=\(fire.occurrence) on \(fire.scheduleID)") } try? context.save() } @@ -196,9 +181,9 @@ final class ProfileScheduleFirer { ) UNUserNotificationCenter.current().add(request) { error in if let error = error { - debug(.service, "ProfileScheduleFirer: failed to post activated notification: \(error)") + warning(.alerts, "Posting profile activated notification failed", error: error) } else { - debug(.service, "ProfileScheduleFirer: activated notification queued (id=\(request.identifier))") + debug(.alerts, "Profile activated notification queued") } } } @@ -253,20 +238,14 @@ final class ProfileScheduleFirer { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in debug( - .service, - "ProfileScheduleFirer: posting notification — authStatus=\(settings.authorizationStatus.rawValue) alertSetting=\(settings.alertSetting.rawValue)" + .alerts, + "Posting profile activation notification, authorization status \(settings.authorizationStatus.rawValue), alert setting \(settings.alertSetting.rawValue)" ) center.add(request) { error in if let error = error { - debug( - .service, - "ProfileScheduleFirer: failed to post activation notification: \(error)" - ) + warning(.alerts, "Posting profile activation notification failed", error: error) } else { - debug( - .service, - "ProfileScheduleFirer: activation notification queued (id=\(request.identifier))" - ) + debug(.alerts, "Profile activation notification queued") } } } diff --git a/Trio/Sources/Services/ReleaseNotes/ReleaseNotesService.swift b/Trio/Sources/Services/ReleaseNotes/ReleaseNotesService.swift index ea1611e848..1cf445ddf5 100644 --- a/Trio/Sources/Services/ReleaseNotes/ReleaseNotesService.swift +++ b/Trio/Sources/Services/ReleaseNotes/ReleaseNotesService.swift @@ -146,7 +146,7 @@ import Foundation do { return try ReleaseNotes.decodeBundled(from: data) } catch { - debug(.default, "Failed to decode bundled release notes: \(error)") + warning(.lifecycle, "Failed to decode bundled release notes", error: error) return [] } } @@ -170,7 +170,7 @@ import Foundation guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - debug(.default, "Release list fetch returned status \(status)") + warning(.lifecycle, "Release list fetch failed with HTTP \(status)") return [] } @@ -184,7 +184,7 @@ import Foundation .version == installedVersion } .sorted { Self.versionComponents($1.version).lexicographicallyPrecedes(Self.versionComponents($0.version)) } } catch { - debug(.default, "Failed to fetch release list: \(error)") + warning(.lifecycle, "Release list fetch failed", error: error) return [] } } diff --git a/Trio/Sources/Services/RemoteControl/APNSJWTClaims.swift b/Trio/Sources/Services/RemoteControl/APNSJWTClaims.swift index f3516da7ff..5bee6bf027 100644 --- a/Trio/Sources/Services/RemoteControl/APNSJWTClaims.swift +++ b/Trio/Sources/Services/RemoteControl/APNSJWTClaims.swift @@ -49,7 +49,7 @@ class APNSJWTManager { return signedJWT } catch { - debug(.remoteControl, "Failed to sign JWT: \(error.localizedDescription)") + warning(.remote, "Failed to sign push response token", error: error) return nil } } diff --git a/Trio/Sources/Services/RemoteControl/RemoteNotificationResponseManager.swift b/Trio/Sources/Services/RemoteControl/RemoteNotificationResponseManager.swift index 8f9e778448..30d46ed633 100644 --- a/Trio/Sources/Services/RemoteControl/RemoteNotificationResponseManager.swift +++ b/Trio/Sources/Services/RemoteControl/RemoteNotificationResponseManager.swift @@ -59,7 +59,7 @@ class RemoteNotificationResponseManager { guard let returnInfo = returnInfo, !returnInfo.deviceToken.isEmpty else { - debug(.remoteControl, "No return notification info provided, skipping response") + debug(.remote, "Response notification skipped: no return info") return } @@ -95,13 +95,12 @@ class RemoteNotificationResponseManager { teamId: returnInfo.teamId, apnsKey: returnInfo.apnsKey ) else { - debug(.remoteControl, "Failed to generate JWT for response notification") return } let host = returnInfo.productionEnvironment ? "api.push.apple.com" : "api.sandbox.push.apple.com" guard let url = URL(string: "https://\(host)/3/device/\(deviceToken)") else { - debug(.remoteControl, "Failed to construct APNs URL") + warning(.remote, "Response notification skipped: invalid push URL") return } @@ -122,13 +121,13 @@ class RemoteNotificationResponseManager { if let httpResponse = response as? HTTPURLResponse { if httpResponse.statusCode == 200 { - debug(.remoteControl, "Response notification sent successfully") + debug(.remote, "Response notification sent") } else { - debug(.remoteControl, "Failed to send response notification: \(httpResponse.statusCode)") + warning(.remote, "Response notification failed with HTTP \(httpResponse.statusCode)") } } } catch { - debug(.remoteControl, "Error sending response notification: \(error.localizedDescription)") + warning(.remote, "Response notification failed", error: error) } } } diff --git a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+APNS.swift b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+APNS.swift index f7098945b6..110bceffcf 100644 --- a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+APNS.swift +++ b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+APNS.swift @@ -10,20 +10,20 @@ extension TrioRemoteControl { if let token = deviceToken, token != previousDeviceToken { UserDefaults.standard.set(token, forKey: "deviceToken") - debug(.remoteControl, "Device token updated: \(token)") + info(.remote, "Push device token updated") shouldUploadProfiles = true } if previousIsAPNSProduction != isAPNSProduction { UserDefaults.standard.set(isAPNSProduction, forKey: "isAPNSProduction") - debug(.remoteControl, "APNS environment changed to: \(isAPNSProduction ? "Production" : "Sandbox")") + info(.remote, "Push environment changed to \(isAPNSProduction ? "production" : "sandbox")") shouldUploadProfiles = true } if shouldUploadProfiles { try await nightscoutManager.uploadProfiles() } else { - debug(.remoteControl, "No changes detected in device token or APNS environment.") + debug(.remote, "Push device token and environment unchanged") } } diff --git a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift index d62920596a..6ed91b970d 100644 --- a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift +++ b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift @@ -21,7 +21,7 @@ extension TrioRemoteControl { return } - debug(.remoteControl, "Enacting bolus command with amount: \(bolusAmount) units.") + debug(.remote, "Enacting remote bolus of \(bolusAmount) U") guard let apsManager = await TrioApp.resolver.resolve(APSManager.self) else { await logError( diff --git a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Helpers.swift b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Helpers.swift index abf906b4bb..9684dacba0 100644 --- a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Helpers.swift +++ b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Helpers.swift @@ -16,7 +16,7 @@ extension TrioRemoteControl { ) } } - debug(.remoteControl, note) + warning(.remote, note) await nightscoutManager.uploadNoteTreatment(note: note) } @@ -26,7 +26,7 @@ extension TrioRemoteControl { customNotificationMessage: String? = nil, ack: RemoteCommandAck? = nil ) async { - debug(.remoteControl, message) + info(.remote, message) if let returnInfo = payload.returnNotification { await RemoteNotificationResponseManager.shared.sendResponseNotification( diff --git a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+MealMutation.swift b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+MealMutation.swift index af6c70eaab..33b5ccddd1 100644 --- a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+MealMutation.swift +++ b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+MealMutation.swift @@ -210,7 +210,7 @@ extension TrioRemoteControl { do { try await apsManager.determineBasalSync() } catch { - debug(.remoteControl, "COB recalculation after meal change failed: \(error.localizedDescription)") + warning(.remote, "COB recalculation after meal change failed", error: error) } } diff --git a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Override.swift b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Override.swift index 2eff57ee9e..331be77b16 100644 --- a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Override.swift +++ b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+Override.swift @@ -4,7 +4,7 @@ extension TrioRemoteControl { @MainActor func handleCancelOverrideCommand(_ payload: CommandPayload) async { do { let outcome = try await adjustmentManager.cancelOverride(source: .remote, waitForUpload: true) - debug(.remoteControl, "Cancelled override \"\(outcome.ended.first?.name ?? "Custom Override")\"") + debug(.remote, "Override \"\(outcome.ended.first?.name ?? "Custom Override")\" cancelled") await logSuccess( "Remote command processed successfully. \(payload.humanReadableDescription())", payload: payload, @@ -18,7 +18,7 @@ extension TrioRemoteControl { customNotificationMessage: "Override canceled" ) } catch { - debug(.remoteControl, "Failed to cancel override: \(error)") + warning(.remote, "Override cancel failed", error: error) await logError("Command failed: \(error.localizedDescription)", payload: payload) } } @@ -36,7 +36,7 @@ extension TrioRemoteControl { waitForUpload: true ) if let ended = outcome.ended.first { - debug(.remoteControl, "Recorded run for replaced override \"\(ended.name ?? "Custom Override")\"") + debug(.remote, "Recorded run for replaced override \"\(ended.name ?? "Custom Override")\"") } await logSuccess( "Remote command processed successfully. \(payload.humanReadableDescription())", diff --git a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+TempTarget.swift b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+TempTarget.swift index fc40e83a30..03b1b9d366 100644 --- a/Trio/Sources/Services/RemoteControl/TrioRemoteControl+TempTarget.swift +++ b/Trio/Sources/Services/RemoteControl/TrioRemoteControl+TempTarget.swift @@ -26,7 +26,7 @@ extension TrioRemoteControl { waitForUpload: true ) if let ended = outcome.ended.first { - debug(.remoteControl, "Recorded run for replaced temp target \"\(ended.name ?? "Temp Target")\"") + debug(.remote, "Recorded run for replaced temp target \"\(ended.name ?? "Temp Target")\"") } await logSuccess( "Remote command processed successfully. \(payload.humanReadableDescription())", @@ -43,7 +43,7 @@ extension TrioRemoteControl { @MainActor func cancelTempTarget(_ payload: CommandPayload) async { do { let outcome = try await adjustmentManager.cancelTempTarget(source: .remote, waitForUpload: true) - debug(.remoteControl, "Cancelled temp target \"\(outcome.ended.first?.name ?? "Temp Target")\"") + debug(.remote, "Temp target \"\(outcome.ended.first?.name ?? "Temp Target")\" cancelled") await logSuccess( "Remote command processed successfully. \(payload.humanReadableDescription())", payload: payload, @@ -57,7 +57,7 @@ extension TrioRemoteControl { customNotificationMessage: "Temp target canceled" ) } catch { - debug(.remoteControl, "Failed to cancel temp target: \(error)") + warning(.remote, "Temp target cancel failed", error: error) await logError("Command failed: \(error.localizedDescription)", payload: payload) } } diff --git a/Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift b/Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift index be4da152f4..1f8c6621e2 100644 --- a/Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift +++ b/Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift @@ -68,10 +68,7 @@ class TrioRemoteControl: Injectable { return } - debug( - .remoteControl, - "Command successfully decrypted and authenticated. Time difference: \(Int(timeDifference)) seconds." - ) + debug(.remote, "Command authenticated, sent \(Int(timeDifference)) s ago") switch commandPayload.commandType { case .bolus: diff --git a/Trio/Sources/Services/SettingsManager/ActiveProfileMirror.swift b/Trio/Sources/Services/SettingsManager/ActiveProfileMirror.swift index b472f4530d..7eb864f3a4 100644 --- a/Trio/Sources/Services/SettingsManager/ActiveProfileMirror.swift +++ b/Trio/Sources/Services/SettingsManager/ActiveProfileMirror.swift @@ -61,7 +61,7 @@ final class ActiveProfileMirror { try context.save() } } catch { - debug(.coreData, "ActiveProfileMirror update failed: \(error)") + warning(.storage, "Updating the active profile copy failed", error: error) } } } diff --git a/Trio/Sources/Services/SettingsManager/ProfileSeeder.swift b/Trio/Sources/Services/SettingsManager/ProfileSeeder.swift index b4399bf9c1..a654beb553 100644 --- a/Trio/Sources/Services/SettingsManager/ProfileSeeder.swift +++ b/Trio/Sources/Services/SettingsManager/ProfileSeeder.swift @@ -60,9 +60,9 @@ enum ProfileSeeder { ) try context.save() - debug(.coreData, "ProfileSeeder: created Default Profile from live settings") + info(.settings, "Default Profile created from current settings") } catch { - debug(.coreData, "ProfileSeeder failed: \(error)") + warning(.storage, "Creating Default Profile failed", error: error) } } } @@ -79,9 +79,9 @@ enum ProfileSeeder { profile.name = "Default Profile" } try context.save() - debug(.coreData, "ProfileSeeder: renamed legacy Default profile(s) to Default Profile") + info(.settings, "Legacy Default profile renamed to Default Profile") } catch { - debug(.coreData, "ProfileSeeder legacy rename failed: \(error)") + warning(.storage, "Renaming legacy Default profile failed", error: error) } } } diff --git a/Trio/Sources/Services/Storage/Cache/UserDefaults+Cache.swift b/Trio/Sources/Services/Storage/Cache/UserDefaults+Cache.swift index 5ec730a3be..ecf7935790 100644 --- a/Trio/Sources/Services/Storage/Cache/UserDefaults+Cache.swift +++ b/Trio/Sources/Services/Storage/Cache/UserDefaults+Cache.swift @@ -13,6 +13,7 @@ extension UserDefaults: Cache { return decoded.v } catch { if reportError { + warning(.storage, "Decoding cached value '\(key)' failed", error: error) assertionFailure("Failed to get persisted value for key: \(key), error: \(error.localizedDescription)") } } @@ -32,6 +33,7 @@ extension UserDefaults: Cache { set(encoded, forKey: key) } catch { if reportError { + warning(.storage, "Encoding cached value '\(key)' failed", error: error) assertionFailure("Failed to set persisted value.for key: \(key), error: \(error.localizedDescription)") } } diff --git a/Trio/Sources/Services/Storage/FileStorage.swift b/Trio/Sources/Services/Storage/FileStorage.swift index 4a34e3db7c..f73465e321 100644 --- a/Trio/Sources/Services/Storage/FileStorage.swift +++ b/Trio/Sources/Services/Storage/FileStorage.swift @@ -30,7 +30,7 @@ final class BaseFileStorage: FileStorage { try Disk.save(value, to: .documents, as: name, encoder: JSONCoding.encoder) } } catch { - debug(.storage, "Failed to save file '\(name)': \(error)") + warning(.storage, "Saving file '\(name)' failed", error: error) } } } @@ -45,7 +45,7 @@ final class BaseFileStorage: FileStorage { try Disk.save(value, to: .documents, as: name, encoder: JSONCoding.encoder) } } catch { - debug(.storage, "Failed to save file '\(name)': \(error)") + warning(.storage, "Saving file '\(name)' failed", error: error) } continuation.resume() } @@ -57,7 +57,7 @@ final class BaseFileStorage: FileStorage { do { return try Disk.retrieve(name, from: .documents, as: type, decoder: JSONCoding.decoder) } catch { - debug(.storage, "Failed to retrieve file '\(name)': \(error)") + logFailure("Reading file '\(name)' failed", error: error) return nil } } @@ -70,7 +70,7 @@ final class BaseFileStorage: FileStorage { let result = try Disk.retrieve(name, from: .documents, as: type, decoder: JSONCoding.decoder) continuation.resume(returning: result) } catch { - debug(.storage, "Failed to retrieve file '\(name)': \(error)") + logFailure("Reading file '\(name)' failed", error: error) continuation.resume(returning: nil) } } @@ -82,12 +82,12 @@ final class BaseFileStorage: FileStorage { do { let data = try Disk.retrieve(name, from: .documents, as: Data.self) guard let string = String(data: data, encoding: .utf8) else { - debug(.storage, "Failed to decode data as UTF-8 string for file '\(name)'") + warning(.storage, "File '\(name)' is not valid UTF-8") return nil } return string } catch { - debug(.storage, "Failed to retrieve file '\(name)': \(error)") + logFailure("Reading file '\(name)' failed", error: error) return nil } } @@ -99,13 +99,13 @@ final class BaseFileStorage: FileStorage { do { let data = try Disk.retrieve(name, from: .documents, as: Data.self) guard let string = String(data: data, encoding: .utf8) else { - debug(.storage, "Failed to decode data as UTF-8 string for file '\(name)'") + warning(.storage, "File '\(name)' is not valid UTF-8") continuation.resume(returning: nil) return } continuation.resume(returning: string) } catch { - debug(.storage, "Failed to retrieve file '\(name)': \(error)") + logFailure("Reading file '\(name)' failed", error: error) continuation.resume(returning: nil) } } @@ -117,7 +117,7 @@ final class BaseFileStorage: FileStorage { do { try Disk.append(newValue, to: name, in: .documents, decoder: JSONCoding.decoder, encoder: JSONCoding.encoder) } catch { - debug(.storage, "Failed to append to file '\(name)': \(error)") + warning(.storage, "Appending to file '\(name)' failed", error: error) } } } @@ -127,7 +127,7 @@ final class BaseFileStorage: FileStorage { do { try Disk.append(newValues, to: name, in: .documents, decoder: JSONCoding.decoder, encoder: JSONCoding.encoder) } catch { - debug(.storage, "Failed to append to file '\(name)': \(error)") + warning(.storage, "Appending to file '\(name)' failed", error: error) } } } @@ -173,7 +173,7 @@ final class BaseFileStorage: FileStorage { do { try Disk.remove(name, from: .documents) } catch { - debug(.storage, "Failed to remove file '\(name)': \(error)") + logFailure("Removing file '\(name)' failed", error: error) } } } @@ -183,7 +183,7 @@ final class BaseFileStorage: FileStorage { do { try Disk.rename(name, in: .documents, to: newName) } catch { - debug(.storage, "Failed to rename file '\(name)' to '\(newName)': \(error)") + warning(.storage, "Renaming file '\(name)' to '\(newName)' failed", error: error) } } } @@ -198,10 +198,20 @@ final class BaseFileStorage: FileStorage { do { return try Disk.url(for: file, in: .documents) } catch { - debug(.storage, "Failed to get URL for file '\(file)': \(error)") + warning(.storage, "Resolving URL for file '\(file)' failed", error: error) return nil } } + + // Missing files are expected here, so they only log at debug + private func logFailure(_ message: String, error: Error) { + let nsError = error as NSError + if nsError.domain == Disk.errorDomain, nsError.code == Disk.ErrorCode.noFileFound.rawValue { + debug(.storage, message) + } else { + warning(.storage, message, error: error) + } + } } extension FileStorage { @@ -216,7 +226,6 @@ extension FileStorage { /// Parses mmol/L settings stored on file to mg/dL if necessary and updates the preferences, settings, insulin sensitivities, and glucose targets. /// - Returns: A boolean indicating whether any settings were parsed and updated. func parseOnFileSettingsToMgdL() -> Bool { - debug(.businessLogic, "Check for mmol/L settings stored on file.") var wasParsed = false // Retrieve and parse preferences (Preferences struct) @@ -233,11 +242,11 @@ extension FileStorage { preferences.enableSMB_high_bg_target != initialSMBTarget || preferences.halfBasalExerciseTarget != initialExerciseTarget { - debug(.businessLogic, "Preferences found in mmol/L. Parsing to mg/dL.") + info(.storage, "Converting stored preferences from mmol/L to mg/dL") save(preferences, as: OpenAPS.Settings.preferences) wasParsed = true } else { - debug(.businessLogic, "Preferences stored in mg/dL; no parsing required.") + debug(.storage, "Stored preferences already in mg/dL") } } @@ -250,11 +259,11 @@ extension FileStorage { settings.low = parseSettingIfMmolL(value: settings.low) if settings.high != initialHigh || settings.low != initialLow { - debug(.businessLogic, "TrioSettings found in mmol/L. Parsing to mg/dL.") + info(.storage, "Converting stored Trio settings from mmol/L to mg/dL") save(settings, as: OpenAPS.Settings.settings) wasParsed = true } else { - debug(.businessLogic, "TrioSettings stored in mg/dL; no parsing required.") + debug(.storage, "Stored Trio settings already in mg/dL") } } @@ -262,7 +271,7 @@ extension FileStorage { if var sensitivities = retrieve(OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self), sensitivities.units == .mmolL || sensitivities.userPreferredUnits == .mmolL { - debug(.businessLogic, "Insulin sensitivities found in mmol/L. Parsing to mg/dL.") + info(.storage, "Converting stored insulin sensitivities from mmol/L to mg/dL") sensitivities.sensitivities = sensitivities.sensitivities.map { isf in InsulinSensitivityEntry( @@ -277,14 +286,14 @@ extension FileStorage { save(sensitivities, as: OpenAPS.Settings.insulinSensitivities) wasParsed = true } else { - debug(.businessLogic, "Insulin sensitivities stored in mg/dL; no parsing required.") + debug(.storage, "Stored insulin sensitivities already in mg/dL") } // Retrieve and parse glucose targets if var glucoseTargets = retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self), glucoseTargets.units == .mmolL || glucoseTargets.userPreferredUnits == .mmolL { - debug(.businessLogic, "Glucose target profile found in mmol/L. Parsing to mg/dL.") + info(.storage, "Converting stored glucose targets from mmol/L to mg/dL") glucoseTargets.targets = glucoseTargets.targets.map { target in BGTargetEntry( @@ -300,7 +309,7 @@ extension FileStorage { save(glucoseTargets, as: OpenAPS.Settings.bgTargets) wasParsed = true } else { - debug(.businessLogic, "Glucose target profile stored in mg/dL; no parsing required.") + debug(.storage, "Stored glucose targets already in mg/dL") } return wasParsed diff --git a/Trio/Sources/Services/Telemetry/CrashReportingGate.swift b/Trio/Sources/Services/Telemetry/CrashReportingGate.swift index b2f1e23cf7..28ae5a3132 100644 --- a/Trio/Sources/Services/Telemetry/CrashReportingGate.swift +++ b/Trio/Sources/Services/Telemetry/CrashReportingGate.swift @@ -86,12 +86,9 @@ enum CrashReportingGate { do { try FileManager.default.removeItem(at: transportStorageURL) - debug(.default, "Purged queued GoogleDataTransport events after diagnostics opt-out.") + debug(.telemetry, "Purged queued crash report events after opt-out") } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) failed to purge queued GoogleDataTransport events: \(error)" - ) + warning(.telemetry, "Failed to purge queued crash report events", error: error) } } } diff --git a/Trio/Sources/Services/Telemetry/TelemetryAttestor.swift b/Trio/Sources/Services/Telemetry/TelemetryAttestor.swift index dbd630f76a..8186667575 100644 --- a/Trio/Sources/Services/Telemetry/TelemetryAttestor.swift +++ b/Trio/Sources/Services/Telemetry/TelemetryAttestor.swift @@ -96,7 +96,7 @@ final class TelemetryAttestor: Injectable { let keyIDPrefix = String(keyID.prefix(8)) debug( .telemetry, - "attestKey input: isSupported=\(service.isSupported) keyID.count=\(keyID.count) keyID.prefix=\(keyIDPrefix) hash.count=\(clientDataHash.count) challenge.count=\(challenge.count) bundle=\(Bundle.main.bundleIdentifier ?? "nil")" + "Attesting key \(keyIDPrefix) (\(keyID.count) chars), supported \(service.isSupported), hash \(clientDataHash.count) bytes, challenge \(challenge.count) chars, bundle \(Bundle.main.bundleIdentifier ?? "unknown")" ) let attestationCBOR: Data @@ -122,14 +122,14 @@ final class TelemetryAttestor: Injectable { .invalidKey: keychain.removeObject(forKey: Self.keyIDStorageKey) let reason = dcError.code == .invalidKey ? "invalidKey" : "invalidInput" - debug(.telemetry, "attestKey \(reason): discarded keyID; will regenerate next cycle") + debug(.telemetry, "App Attest key rejected (\(reason)), discarding it") case .serverUnavailable: - debug(.telemetry, "attestKey serverUnavailable: Apple App Attest backend transient — will retry next cycle") + debug(.telemetry, "App Attest server unavailable, retrying next cycle") default: break } } - debug(.telemetry, "attestKey failed: \(error.localizedDescription)") + debug(.telemetry, "App Attest key attestation failed") throw AttestError.attestationFailed(error) } @@ -167,11 +167,11 @@ final class TelemetryAttestor: Injectable { case 200, 201: keychain.setValue(true, forKey: Self.registeredStorageKey) - debug(.telemetry, "register ok status=\(http.statusCode)") + info(.telemetry, "Registered with telemetry server") case 403: // app_id rejected. Sticky — flag the install and surface to caller. PropertyPersistentFlags.shared.telemetryAttestForbidden = true - debug(.telemetry, "register forbidden — app_id=\(appID) rejected; no further attempts") + warning(.telemetry, "Registration rejected with HTTP 403, no further attempts") throw AttestError.forbidden case 400 ..< 500: throw AttestError.clientError(http.statusCode) @@ -202,7 +202,7 @@ final class TelemetryAttestor: Injectable { keychain.removeObject(forKey: Self.keyIDStorageKey) keychain.removeObject(forKey: Self.registeredStorageKey) PropertyPersistentFlags.shared.telemetryAttestForbidden = false - debug(.telemetry, "reset App Attest state: keyID, registered flag, and forbidden flag cleared") + info(.telemetry, "App Attest state reset") } // MARK: - Per-ping assertion @@ -260,7 +260,7 @@ final class TelemetryAttestor: Injectable { throw AttestError.keyGenerationFailed(error) } keychain.setValue(newKey, forKey: Self.keyIDStorageKey) - debug(.telemetry, "generated new App Attest keyID") + debug(.telemetry, "Generated new App Attest key") return newKey } diff --git a/Trio/Sources/Services/Telemetry/TelemetryClient.swift b/Trio/Sources/Services/Telemetry/TelemetryClient.swift index 41d58db46b..3e173b03f1 100644 --- a/Trio/Sources/Services/Telemetry/TelemetryClient.swift +++ b/Trio/Sources/Services/Telemetry/TelemetryClient.swift @@ -415,14 +415,14 @@ final class TelemetryClient: Injectable { return failed("anonymous_non_http_response") } guard (200 ..< 300).contains(http.statusCode) else { - warning(.telemetry, "anonymous send non-2xx status=\(http.statusCode)") + warning(.telemetry, "Anonymous send failed with HTTP \(http.statusCode)") return failed("anonymous_http_\(http.statusCode)") } PropertyPersistentFlags.shared.telemetryLastFailureReason = nil - debug(.telemetry, "anonymous send ok status=\(http.statusCode)") + info(.telemetry, "Anonymous send succeeded") return true } catch { - warning(.telemetry, "anonymous send request failed", error: error) + warning(.telemetry, "Anonymous send failed", error: error) return failed("anonymous_request_failed") } } @@ -453,17 +453,17 @@ final class TelemetryClient: Injectable { } guard let baseURL = Self.baseURL else { - debug(.telemetry, "skip send: server URL not configured") + debug(.telemetry, "Send skipped: server URL not set") return failed("server_url_not_configured") } let attestor = TelemetryAttestor.shared guard attestor.isSupported else { - debug(.telemetry, "skip send: App Attest unsupported (simulator or older device)") + debug(.telemetry, "Send skipped: App Attest unsupported on this device") return failed("app_attest_unsupported") } guard !attestor.isForbidden else { - debug(.telemetry, "skip send: app_id previously rejected (403)") + debug(.telemetry, "Send skipped: registration was rejected with HTTP 403") return failed("app_attest_forbidden") } @@ -478,17 +478,17 @@ final class TelemetryClient: Injectable { // Already logged + sticky-flagged in registerIfNeeded. return failed("registration_forbidden") } catch { - debug(.telemetry, "register failed: \(error) — will retry next cycle") + warning(.telemetry, "Registration failed, retrying next cycle", error: error) return failed("registration_failed") } let payload = buildPayload() guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else { - debug(.telemetry, "skip send: payload not JSON-serializable") + warning(.telemetry, "Send skipped: payload could not be serialized") return failed("payload_serialization_failed") } guard body.count <= Self.maxPayloadBytes else { - debug(.telemetry, "skip send: payload exceeds \(Self.maxPayloadBytes) bytes (\(body.count))") + warning(.telemetry, "Send skipped: payload is \(body.count) bytes, limit \(Self.maxPayloadBytes)") return failed("payload_too_large") } @@ -500,7 +500,7 @@ final class TelemetryClient: Injectable { context: context ) } catch { - debug(.telemetry, "assertion failed: \(error)") + warning(.telemetry, "App Attest assertion failed", error: error) return failed("assertion_failed") } @@ -526,13 +526,13 @@ final class TelemetryClient: Injectable { do { let (_, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { - debug(.telemetry, "send: non-HTTP response") + warning(.telemetry, "Send failed: response was not HTTP") return failed("non_http_response") } switch http.statusCode { case 200 ..< 300: PropertyPersistentFlags.shared.telemetryLastFailureReason = nil - debug(.telemetry, "send ok status=\(http.statusCode)") + info(.telemetry, "Send succeeded") return true case 401: // Server doesn't recognize our registration (e.g. its registry @@ -540,14 +540,14 @@ final class TelemetryClient: Injectable { // next cycle generates a fresh key and re-attests — `attestKey` // can't be re-run on the existing keyID (one-shot per Apple). attestor.invalidateRegistration() - warning(.telemetry, "send 401: stale registration, will re-register next cycle") + warning(.telemetry, "Send rejected with HTTP 401, re-registering next cycle") return failed("http_401") default: - warning(.telemetry, "send non-2xx status=\(http.statusCode)") + warning(.telemetry, "Send failed with HTTP \(http.statusCode)") return failed("http_\(http.statusCode)") } } catch { - warning(.telemetry, "send request failed", error: error) + warning(.telemetry, "Send failed", error: error) return failed("request_failed") } } diff --git a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift index a61ffd0bc2..7b4a3e0be0 100644 --- a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift +++ b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift @@ -111,7 +111,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In guard let error else { return } - print(error) + warning(.alerts, "Setting app badge failed", error: error) } } return @@ -129,7 +129,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In guard let error else { return } - print(error) + warning(.alerts, "Setting app badge failed", error: error) } } } @@ -186,7 +186,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In }.first?.glucose addAppBadge(glucose: latest.map { Int($0) }) } catch { - debug(.service, "Failed to update glucose badge: \(error)") + warning(.alerts, "Updating glucose app badge failed", error: error) } } @@ -199,15 +199,15 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In } func requestNotificationPermissions(completion: @escaping (Bool) -> Void) { - debug(.service, "requestNotificationPermissions") + debug(.alerts, "Requesting notification permission") notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) { granted, error in if granted { - debug(.service, "requestNotificationPermissions was granted") + info(.alerts, "Notification permission granted") DispatchQueue.main.async { completion(granted) } } else { - warning(.service, "requestNotificationPermissions failed", error: error) + warning(.alerts, "Notification permission not granted", error: error) } } } @@ -245,8 +245,8 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate { defer { completionHandler() } debug( - .service, - "NotificationResponse: actionIdentifier=\(response.actionIdentifier) category=\(response.notification.request.content.categoryIdentifier)" + .alerts, + "Notification response \(response.actionIdentifier) for category \(response.notification.request.content.categoryIdentifier)" ) let userInfo = response.notification.request.content.userInfo @@ -288,20 +288,20 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate { action: ScheduleNotificationAction, notification: UNNotification ) { - let info = notification.request.content.userInfo + let userInfo = notification.request.content.userInfo guard - let scheduleRaw = info[ScheduleNotificationUserInfoKey.scheduleID] as? String, + let scheduleRaw = userInfo[ScheduleNotificationUserInfoKey.scheduleID] as? String, let scheduleID = UUID(uuidString: scheduleRaw), - let profileRaw = info[ScheduleNotificationUserInfoKey.profileID] as? String, + let profileRaw = userInfo[ScheduleNotificationUserInfoKey.profileID] as? String, let profileID = UUID(uuidString: profileRaw), - let occurrenceEpoch = info[ScheduleNotificationUserInfoKey.occurrenceEpoch] as? Double + let occurrenceEpoch = userInfo[ScheduleNotificationUserInfoKey.occurrenceEpoch] as? Double else { - debug(.service, "Schedule notification payload malformed; ignoring") + warning(.alerts, "Profile schedule notification payload is malformed, ignoring it") return } let occurrence = Date(timeIntervalSince1970: occurrenceEpoch) - debug(.service, "ScheduledActivation: response action=\(action.rawValue) schedule=\(scheduleID)") + info(.settings, "Profile schedule notification answered with \(action.rawValue)") switch action { case .confirm: runScheduledActivation( @@ -319,19 +319,19 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate { /// dialog. This is distinct from the explicit `.confirm` / `.skip` action buttons, which run /// directly without an in-app prompt. private func handleScheduleActivationDefaultTap(notification: UNNotification) { - let info = notification.request.content.userInfo + let userInfo = notification.request.content.userInfo guard - let scheduleRaw = info[ScheduleNotificationUserInfoKey.scheduleID] as? String, + let scheduleRaw = userInfo[ScheduleNotificationUserInfoKey.scheduleID] as? String, let scheduleID = UUID(uuidString: scheduleRaw), - let profileRaw = info[ScheduleNotificationUserInfoKey.profileID] as? String, + let profileRaw = userInfo[ScheduleNotificationUserInfoKey.profileID] as? String, let profileID = UUID(uuidString: profileRaw), - let occurrenceEpoch = info[ScheduleNotificationUserInfoKey.occurrenceEpoch] as? Double + let occurrenceEpoch = userInfo[ScheduleNotificationUserInfoKey.occurrenceEpoch] as? Double else { - debug(.service, "ScheduledActivation default-tap: malformed payload") + warning(.alerts, "Profile schedule notification payload is malformed, ignoring tap") return } let occurrence = Date(timeIntervalSince1970: occurrenceEpoch) - debug(.service, "ScheduledActivation: default-tap routing to AdaptProfile for schedule=\(scheduleID)") + debug(.settings, "Profile schedule notification tapped, opening profiles") // Stash the request first so AdaptProfile.StateModel picks it up regardless of whether // it's already alive (Foundation notification path) or about to spin up (drain on // subscribe). Navigate afterwards so the new RootView sees the mailbox populated. @@ -360,18 +360,17 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate { /// in-app confirmation — tapping the action IS the confirmation. Clears the schedule's /// `pendingOccurrence` either way so the firer moves on. private func runScheduledActivation(scheduleID: UUID, profileID: UUID, occurrence: Date) { - debug(.service, "ScheduledActivation: confirm received for schedule=\(scheduleID) profile=\(profileID)") + info(.settings, "Scheduled profile activation confirmed") Task { @MainActor [weak self] in guard let self else { return } let provider = AdaptProfile.Provider(resolver: self.resolver) await provider.markScheduleActivated(scheduleID: scheduleID, occurrence: occurrence) - debug(.service, "ScheduledActivation: markScheduleActivated done") let outcome = await provider.activate( id: profileID, durationMinutes: nil, confirmedPumpSync: true ) - debug(.service, "ScheduledActivation: activate outcome=\(outcome)") + info(.settings, "Scheduled profile activation finished with \(String(describing: outcome))") Foundation.NotificationCenter.default.post( name: .didUpdateProfileSchedules, object: nil diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift b/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift index 3ef088ff81..fa38e796a8 100644 --- a/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift +++ b/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift @@ -31,7 +31,7 @@ final class WatchCommandAuthorization { let bolusTurnedOff = old.isGarminBolusCommandEnabled && !new.isGarminBolusCommandEnabled guard masterTurnedOff || bolusTurnedOff else { return } lock.withLock { epoch += 1 } - debug(.watchManager, "⌚️🔐 Garmin: Command setting switched off - pending commands revoked") + info(.watch, "Garmin commands switched off, pending commands revoked") } } @@ -101,7 +101,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { private let cache: WatchCommandRequestCache private let insulinLane: WatchCommandInsulinLane private let now: () -> Date - private let log: (String) -> Void + private let log: (LogLevel, String) -> Void init( resolver: Resolver, @@ -109,7 +109,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { cache: WatchCommandRequestCache = WatchCommandRequestCache(), insulinLane: WatchCommandInsulinLane = WatchCommandInsulinLane(), now: @escaping () -> Date = Date.init, - log: @escaping (String) -> Void = { debug(.watchManager, $0) } + log: @escaping (LogLevel, String) -> Void = { TrioLogger.log($0, .watch, $1, file: #file, function: #function, line: #line) } ) { self.actions = actions ?? BaseWatchCommandActions(resolver: resolver) self.cache = cache @@ -129,20 +129,20 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { case let .accepted(admitted): ticket = admitted case let .completed(result): - log("⌚️⏱️ Garmin: Duplicate \(name) request - returning cached result") + log(.debug, "Garmin duplicate \(name) request, returning cached result") return result case .inProgress: - log("⌚️⏱️ Garmin: Duplicate \(name) request still in progress - rejected") + log(.info, "Garmin duplicate \(name) request still in progress, rejected") return WatchCommandResult( acknowledged: false, ackCode: .inProgress, message: String(localized: "Request is already in progress.", comment: "Watch command ack") ) case .conflict: - log("⌚️⏱️ Garmin: Request ID reused for a different command (\(name)) - rejected") + log(.warning, "Garmin request ID reused for a different command (\(name)), rejected") return .failure(String(localized: "Request ID was already used.", comment: "Watch command ack")) case .full: - log("⌚️⏱️ Garmin: Too many \(name) requests in progress - rejected") + log(.warning, "Garmin sent too many \(name) requests in progress, rejected") return .failure(String(localized: "Too many requests in progress. Please try again.", comment: "Watch command ack")) } @@ -150,9 +150,9 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { await cache.complete(ticket, result: result) if result.acknowledged { - log("⌚️✅ Garmin: \(name) -> \(result.ackCode.rawValue)") + log(.info, "Garmin \(name) acknowledged with \(result.ackCode.rawValue)") } else { - log("⌚️❌ Garmin: \(name) -> \(result.ackCode.rawValue)") + log(.warning, "Garmin \(name) failed with \(result.ackCode.rawValue)") } return result } @@ -221,7 +221,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { return .failure(String(localized: "Invalid carbs amount.", comment: "Watch command ack")) } guard Decimal(grams) <= settingsManager.settings.maxCarbs else { - log("⌚️❌ Garmin: \(request.command.name) rejected - carbs above max carbs") + log(.info, "Garmin \(request.command.name) rejected, carbs above max carbs") return .failure(String(localized: "Carbs exceed the maximum allowed.", comment: "Watch command ack")) } } @@ -234,7 +234,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { private func expiryRejection(for request: WatchCommandRequest) -> WatchCommandResult? { let age = now().timeIntervalSince(request.date) guard age <= Self.maximumCommandAge, age >= -Self.maximumCommandLead else { - log("⌚️⏱️ Garmin: \(request.command.name) timestamp outside the freshness window - rejected") + log(.warning, "Garmin \(request.command.name) timestamp outside the freshness window, rejected") return .failure(String(localized: "Command expired. Please try again.", comment: "Watch command ack")) } return nil @@ -248,16 +248,16 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { let name = request.command.name let garminSettings = settingsManager.settings.garminSettings guard authorization.current == grant else { - log("⌚️🔐 Garmin: \(name) rejected - command settings changed while pending") + log(.info, "Garmin \(name) rejected, command settings changed while pending") return .failure(String(localized: "Watch command settings changed.", comment: "Watch command ack")) } guard garminSettings.isCommandControlEnabled else { - log("⌚️🔐 Garmin: \(name) rejected - watch commands disabled") + log(.info, "Garmin \(name) rejected, watch commands disabled") return .failure(String(localized: "Watch commands are disabled.", comment: "Watch command ack")) } guard !request.command.isInsulin || garminSettings.isBolusCommandEnabled else { - log("⌚️🔐 Garmin: \(name) rejected - bolus commands disabled") + log(.info, "Garmin \(name) rejected, bolus commands disabled") return .failure(String(localized: "Bolus from watch is disabled.", comment: "Watch command ack")) } return nil @@ -309,7 +309,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { } catch let rejection as WatchCommandRejection { return rejection.result } catch { - log("⌚️❌ Garmin: Storing carbs failed (\(WatchCommandErrorCategory.name(for: error)))") + log(.warning, "Garmin carbs could not be stored (\(WatchCommandErrorCategory.name(for: error)))") return .failure(String(localized: "Could not log carbs.", comment: "Watch command ack")) } } @@ -328,7 +328,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { } catch let rejection as WatchCommandRejection { return rejection.result } catch { - log("⌚️❌ Garmin: Storing meal carbs failed (\(WatchCommandErrorCategory.name(for: error)))") + log(.warning, "Garmin meal carbs could not be stored (\(WatchCommandErrorCategory.name(for: error)))") return .failure(String(localized: "Could not log carbs. No bolus was delivered.", comment: "Watch command ack")) } @@ -370,7 +370,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { let recentWindow = Double(BolusSafetyEvaluator.recentBolusWindowMinutes * 60) let previousEnactment = await insulinLane.lastEnactment if let previousEnactment, now().timeIntervalSince(previousEnactment) < recentWindow { - log("⌚️❌ Garmin: Bolus rejected - previous watch bolus may not be in pump history yet") + log(.info, "Garmin bolus rejected, previous watch bolus may not be in pump history yet") return .rejected(BolusSafetyRejection.recentBolusWithinWindow(totalRecent: 0).watchMessage) } @@ -382,11 +382,11 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { case .allowed: break case let .rejected(reason): - log("⌚️❌ Garmin: Bolus rejected by safety check (\(reason.logName))") + log(.info, "Garmin bolus rejected by safety check (\(reason.logName))") return .rejected(reason.watchMessage) } } catch { - log("⌚️❌ Garmin: Bolus safety check failed (\(WatchCommandErrorCategory.name(for: error)))") + log(.warning, "Garmin bolus safety check failed (\(WatchCommandErrorCategory.name(for: error)))") return .rejected(String(localized: "Could not verify bolus safety.", comment: "Watch command ack")) } @@ -412,7 +412,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { do { let presets = try await actions.overridePresets() guard let preset = uniquePreset(named: name, in: presets) else { - log("⌚️❌ Garmin: Override preset missing or ambiguous") + log(.warning, "Garmin override preset missing or ambiguous") return .failure(String(localized: "Override preset not found.", comment: "Watch command ack")) } if let rejection = authorizationRejection(for: request, grant: grant) { @@ -427,7 +427,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { } catch let rejection as WatchCommandRejection { return rejection.result } catch { - log("⌚️❌ Garmin: Activating override failed (\(WatchCommandErrorCategory.name(for: error)))") + log(.warning, "Garmin override activation failed (\(WatchCommandErrorCategory.name(for: error)))") return .failure(String(localized: "Could not start override.", comment: "Watch command ack")) } } @@ -443,7 +443,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { } catch AdjustmentError.nothingActive { // cancelling an inactive override already has the requested outcome } catch { - log("⌚️❌ Garmin: Cancelling override failed (\(WatchCommandErrorCategory.name(for: error)))") + log(.warning, "Garmin override cancellation failed (\(WatchCommandErrorCategory.name(for: error)))") return .failure(String(localized: "Could not stop override.", comment: "Watch command ack")) } return .success(.overrideStopped, String(localized: "Override stopped.", comment: "Watch command ack")) @@ -453,7 +453,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { do { let presets = try await actions.tempTargetPresets() guard let preset = uniquePreset(named: name, in: presets) else { - log("⌚️❌ Garmin: Temp target preset missing or ambiguous") + log(.warning, "Garmin temp target preset missing or ambiguous") return .failure(String(localized: "Temp target preset not found.", comment: "Watch command ack")) } if let rejection = authorizationRejection(for: request, grant: grant) { @@ -468,7 +468,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { } catch let rejection as WatchCommandRejection { return rejection.result } catch { - log("⌚️❌ Garmin: Activating temp target failed (\(WatchCommandErrorCategory.name(for: error)))") + log(.warning, "Garmin temp target activation failed (\(WatchCommandErrorCategory.name(for: error)))") return .failure(String(localized: "Could not start temp target.", comment: "Watch command ack")) } } @@ -484,7 +484,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { } catch AdjustmentError.nothingActive { // cancelling an inactive temp target already has the requested outcome } catch { - log("⌚️❌ Garmin: Cancelling temp target failed (\(WatchCommandErrorCategory.name(for: error)))") + log(.warning, "Garmin temp target cancellation failed (\(WatchCommandErrorCategory.name(for: error)))") return .failure(String(localized: "Could not stop temp target.", comment: "Watch command ack")) } return .success(.tempTargetStopped, String(localized: "Temp target stopped.", comment: "Watch command ack")) diff --git a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift index a241cc6dd0..33c00fb0a1 100644 --- a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift +++ b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift @@ -153,9 +153,9 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana session.activate() self.session = session - debug(.watchManager, "📱 Phone session setup - isPaired: \(session.isPaired)") + debug(.watch, "Watch session set up, paired: \(session.isPaired)") } else { - debug(.watchManager, "📱 WCSession is not supported on this device") + debug(.watch, "Watch connectivity not supported on this device") } } @@ -164,7 +164,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana guard let session = session else { return } if !session.isReachable { - debug(.watchManager, "📱 Attempting to reactivate session...") + debug(.watch, "Reactivating watch session") session.activate() } } @@ -174,13 +174,13 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana func setupWatchState() async -> WatchState { // Check if a watch is paired and reachable before doing expensive calculations guard let session = session, session.isPaired, session.isReachable, session.isWatchAppInstalled else { - debug(.watchManager, "⌚️❌ Skipping setupWatchState - No Watch is paired or app not installed") + debug(.watch, "Watch state skipped: no watch paired or app not installed") return WatchState(date: Date()) } // Skip if watch session is not activated guard session.activationState == .activated else { - debug(.watchManager, "⌚️❌ Skipping setupWatchState - Watch session not activated") + debug(.watch, "Watch state skipped: session not activated") return WatchState(date: Date()) } do { @@ -425,18 +425,14 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana } debug( - .watchManager, - - "📱 Setup WatchState - currentGlucose: \(watchState.currentGlucose ?? "nil"), trend: \(watchState.trend ?? "nil"), delta: \(watchState.delta ?? "nil"), values: \(watchState.glucoseValues.count)" + .watch, + "Watch state built: glucose \(watchState.currentGlucose ?? "none"), trend \(watchState.trend ?? "none"), delta \(watchState.delta ?? "none"), \(watchState.glucoseValues.count) readings" ) return watchState } } catch { - debug( - .watchManager, - "\(DebuggingIdentifiers.failed) Error setting up watch state: \(error)" - ) + warning(.watch, "Building watch state failed", error: error) // Return empty state in case of error return WatchState(date: Date()) } @@ -498,10 +494,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana activeBolusAmount = lastBolusObject.first?.bolus?.amount?.doubleValue ?? 0.0 } } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Error getting active bolus amount: \(error)" - ) + warning(.watch, "Fetching active bolus amount failed", error: error) } } @@ -569,18 +562,17 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana guard let session = session else { return } guard session.isPaired else { - debug(.watchManager, "⌚️❌ No Watch is paired") + debug(.watch, "No watch paired") return } guard session.isWatchAppInstalled else { - debug(.watchManager, "⌚️❌ Trio Watch app is") + debug(.watch, "Trio watch app not installed") return } guard session.activationState == .activated else { - let activationStateString = "\(session.activationState)" - debug(.watchManager, "⌚️ Watch session activationState = \(activationStateString). Reactivating...") + debug(.watch, "Watch session not activated (state \(session.activationState.rawValue)), reactivating") session.activate() return } @@ -598,18 +590,18 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana // if session is not reachable, it means it's in background -> send watchState as userInfo if session.isReachable { session.sendMessage([WatchMessageKeys.watchState: message], replyHandler: nil) { error in - debug(.watchManager, "❌ Error sending watch state: \(error)") + warning(.watch, "Sending watch state failed", error: error) } } else { session.transferUserInfo([WatchMessageKeys.watchState: message]) - debug(.watchManager, "📤 Transferred new WatchState snapshot via userInfo") + debug(.watch, "Watch state queued for background transfer") } WatchStateSnapshot.saveLatestDateToDisk(state.date) } func sendAcknowledgment(toWatch success: Bool, message: String = "", ackCode: AcknowledgmentCode) { guard let session = session, session.isReachable else { - debug(.watchManager, "⌚️ Watch not reachable for acknowledgment") + debug(.watch, "Watch not reachable, acknowledgment not sent") return } @@ -620,7 +612,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana ] session.sendMessage(ackMessage, replyHandler: nil) { error in - debug(.watchManager, "❌ Error sending acknowledgment: \(error)") + warning(.watch, "Sending acknowledgment to watch failed", error: error) } } @@ -628,12 +620,11 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { if let error = error { - debug(.watchManager, "📱 Phone session activation failed: \(error)") + warning(.watch, "Watch session activation failed", error: error) return } - debug(.watchManager, "📱 Phone session activated with state: \(activationState.rawValue)") - debug(.watchManager, "📱 Phone isReachable after activation: \(session.isReachable)") + info(.watch, "Watch session activated, state \(activationState.rawValue), reachable: \(session.isReachable)") // Try to send initial data after activation Task { @@ -645,8 +636,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana func session(_: WCSession, didReceiveMessage message: [String: Any]) { // Handle logs first - doesn't need self, so it can run even during teardown if let logs = message["watchLogs"] as? String { - SimpleLogReporter.appendToWatchLog(logs) - return + WatchLogIngest.legacyRecords(from: logs).forEach(TrioLogger.sink.accept) + if message.count == 1 { return } } Task { @MainActor [weak self] in @@ -655,7 +646,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana if let requestWatchUpdate = message[WatchMessageKeys.requestWatchUpdate] as? String, requestWatchUpdate == WatchMessageKeys.watchState { - debug(.watchManager, "📱 Watch requested watch state data update.") + debug(.watch, "Watch requested a state update") // Skip if no watch is paired or app not installed guard let session = self.session, session.isPaired, session.isReachable, session.isWatchAppInstalled else { return } @@ -667,45 +658,68 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana } if let snoozeMinutes = message[WatchMessageKeys.snoozeDuration] as? Int { - debug(.watchManager, "📱 Received snooze request from watch: \(snoozeMinutes) minutes") + info(.watch, "Watch requested alert snooze for \(snoozeMinutes) min") await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60)) return } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double, message[WatchMessageKeys.carbs] == nil, message[WatchMessageKeys.date] == nil { - debug(.watchManager, "📱 Received bolus request from watch: \(bolusAmount)U") + info(.watch, "Watch requested bolus of \(bolusAmount) U") self.handleBolusRequest(Decimal(bolusAmount)) } else if let carbsAmount = message[WatchMessageKeys.carbs] as? Int, let timestamp = message[WatchMessageKeys.date] as? TimeInterval, message[WatchMessageKeys.bolus] == nil { let date = Date(timeIntervalSince1970: timestamp) - debug(.watchManager, "📱 Received carbs request from watch: \(carbsAmount)g at \(date)") + info(.watch, "Watch requested \(carbsAmount) g carbs at \(date)") self.handleCarbsRequest(carbsAmount, date) } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double, let carbsAmount = message[WatchMessageKeys.carbs] as? Int, let timestamp = message[WatchMessageKeys.date] as? TimeInterval { let date = Date(timeIntervalSince1970: timestamp) - debug( - .watchManager, - "📱 Received meal bolus combo request from watch: \(bolusAmount)U, \(carbsAmount)g at \(date)" - ) + info(.watch, "Watch requested bolus of \(bolusAmount) U with \(carbsAmount) g carbs at \(date)") self.handleCombinedRequest(bolusAmount: Decimal(bolusAmount), carbsAmount: Decimal(carbsAmount), date: date) - } else if message[WatchMessageKeys.cancelOverride] as? Bool == true { - debug(.watchManager, "📱 Received cancel override request from watch") + } else if message[WatchMessageKeys.cancelOverride] == nil, + message[WatchMessageKeys.activateOverride] == nil, + message[WatchMessageKeys.cancelTempTarget] == nil, + message[WatchMessageKeys.activateTempTarget] == nil, + message[WatchMessageKeys.requestBolusRecommendation] == nil + { + warning( + .watch, + "Invalid or incomplete message from watch, keys: \(message.keys.sorted().joined(separator: ", "))" + ) + // Acknowledge failure + self.sendAcknowledgment( + toWatch: false, + message: "Error! Invalid or incomplete data received from watch.", + ackCode: .genericFailure + ) + } + + if message[WatchMessageKeys.cancelOverride] as? Bool == true { + info(.watch, "Watch requested override cancel") self.handleCancelOverride() - } else if let presetName = message[WatchMessageKeys.activateOverride] as? String { - debug(.watchManager, "📱 Received activate override request from watch for preset: \(presetName)") + } + + if let presetName = message[WatchMessageKeys.activateOverride] as? String { + info(.watch, "Watch requested override preset \(presetName)") self.handleActivateOverride(presetName) - } else if let presetName = message[WatchMessageKeys.activateTempTarget] as? String { - debug(.watchManager, "📱 Received activate temp target request from watch for preset: \(presetName)") + } + + if let presetName = message[WatchMessageKeys.activateTempTarget] as? String { + info(.watch, "Watch requested temp target preset \(presetName)") self.handleActivateTempTarget(presetName) - } else if message[WatchMessageKeys.cancelTempTarget] as? Bool == true { - debug(.watchManager, "📱 Received cancel temp target request from watch") + } + + if message[WatchMessageKeys.cancelTempTarget] as? Bool == true { + info(.watch, "Watch requested temp target cancel") self.handleCancelTempTarget() - } else if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true { + } + + if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true { let carbs = message[WatchMessageKeys.carbs] as? Int ?? 0 var minPredBG: Decimal = 54 @@ -732,9 +746,9 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana } } catch let error as CoreDataError { - debug(.default, "Core Data error: \(error)") + warning(.watch, "Fetching determination for watch bolus recommendation failed", error: error) } catch { - debug(.default, "Unexpected error: \(error)") + warning(.watch, "Fetching determination for watch bolus recommendation failed", error: error) } // Get recommendation from BolusCalculationManager @@ -754,30 +768,22 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana ] if let session = self.session, session.isReachable { - debug(.watchManager, "📱 Sending recommendedBolus: \(result.insulinCalculated)") + debug(.watch, "Sending recommended bolus \(result.insulinCalculated) U to watch") session.sendMessage(recommendationMessage, replyHandler: nil) } } return - } else { - debug(.watchManager, "📱 Invalid or incomplete data received from watch. Received: \(message)") - // Acknowledge failure - self.sendAcknowledgment( - toWatch: false, - message: "Error! Invalid or incomplete data received from watch.", - ackCode: .genericFailure - ) } } } func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { if let logs = userInfo["watchLogs"] as? String { - SimpleLogReporter.appendToWatchLog(logs) + WatchLogIngest.legacyRecords(from: logs).forEach(TrioLogger.sink.accept) } if let snoozeMinutes = userInfo[WatchMessageKeys.snoozeDuration] as? Int { - debug(.watchManager, "📱 Received snooze userInfo from watch: \(snoozeMinutes) minutes") + info(.watch, "Watch requested alert snooze for \(snoozeMinutes) min") Task { @MainActor [weak self] in guard let self else { return } await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60)) @@ -785,6 +791,10 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana } } + func session(_: WCSession, didReceive file: WCSessionFile) { + WatchLogIngest.shared.receive(file.fileURL, metadata: file.metadata) + } + #if os(iOS) func sessionDidBecomeInactive(_: WCSession) {} func sessionDidDeactivate(_ session: WCSession) { @@ -793,7 +803,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana #endif func sessionReachabilityDidChange(_ session: WCSession) { - debug(.watchManager, "📱 Phone reachability changed: \(session.isReachable)") + debug(.watch, "Watch reachable: \(session.isReachable)") if session.isReachable { // Try to send data when connection is established @@ -821,7 +831,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana ackCode: success == true ? .genericSuccess : .genericFailure ) } - debug(.watchManager, "📱 Enacted bolus via APS Manager: \(amount)U") + debug(.bolus, "Watch bolus of \(amount) U handed to APS manager") } } @@ -855,7 +865,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana return } try context.save() - debug(.watchManager, "📱 Saved carbs from watch: \(amount)g at \(date)") + debug(.treatments, "Saved \(amount) g carbs from watch") // Acknowledge success self.sendAcknowledgment( @@ -866,8 +876,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana ), ackCode: .carbsLogged ) - } catch { - debug(.watchManager, "❌ Error saving carbs: \(error)") + } catch let saveError { + error(.treatments, "Saving carbs from watch failed", error: saveError) // Acknowledge failure self.sendAcknowledgment(toWatch: false, message: "Error logging carbs", ackCode: .genericFailure) @@ -918,7 +928,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana return } try context.save() - debug(.watchManager, "📱 Saved carbs from watch: \(carbsAmount) g at \(date)") + debug(.treatments, "Saved \(carbsAmount) g carbs from watch") } // Notify Watch: "Enacting bolus..." @@ -941,7 +951,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana ackCode: success == true ? .genericSuccess : .genericFailure ) } - debug(.watchManager, "📱 Enacted bolus from watch via APS Manager: \(bolusDouble) U") + debug(.bolus, "Watch bolus of \(bolusDouble) U handed to APS manager") // Notify Watch: "Carbs and bolus logged successfully" sendAcknowledgment( toWatch: true, @@ -952,8 +962,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana ackCode: .comboComplete ) - } catch { - debug(.watchManager, "❌ Error processing combined request: \(error)") + } catch let requestError { + error(.watch, "Logging carbs and bolus from watch failed", error: requestError) sendAcknowledgment(toWatch: false, message: "Failed to log carbs and bolus", ackCode: .genericFailure) } } @@ -963,7 +973,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana Task { do { let outcome = try await adjustmentManager.cancelOverride(source: .watch) - debug(.watchManager, "📱 Successfully stopped override \"\(outcome.ended.first?.name ?? "Custom Override")\"") + debug(.treatments, "Override \"\(outcome.ended.first?.name ?? "Custom Override")\" stopped from watch") self.sendAcknowledgment( toWatch: true, @@ -974,7 +984,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana ackCode: .overrideStopped ) } catch { - debug(.watchManager, "❌ Error cancelling override: \(error)") + warning(.treatments, "Stopping override from watch failed", error: error) self.sendAcknowledgment( toWatch: false, message: (error as? AdjustmentError)?.errorDescription ?? "Error stopping Override.", @@ -988,9 +998,9 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana Task { do { let outcome = try await adjustmentManager.activateOverride(.presetName(presetName), source: .watch) - debug(.watchManager, "📱 Successfully activated override: \(presetName)") + debug(.treatments, "Override \(presetName) started from watch") if let ended = outcome.ended.first { - debug(.watchManager, "📱 Recorded run for replaced override \"\(ended.name ?? "Custom Override")\"") + debug(.treatments, "Recorded run for replaced override \"\(ended.name ?? "Custom Override")\"") } self.sendAcknowledgment( @@ -1002,7 +1012,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana ackCode: .overrideStarted ) } catch { - debug(.watchManager, "❌ Error activating override: \(error)") + warning(.treatments, "Starting override from watch failed", error: error) self.sendAcknowledgment( toWatch: false, message: (error as? AdjustmentError)?.errorDescription ?? "Error activating Override \"\(presetName)\".", @@ -1016,9 +1026,9 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana Task { do { let outcome = try await adjustmentManager.activateTempTarget(.presetName(presetName), source: .watch) - debug(.watchManager, "📱 Successfully activated temp target: \(presetName)") + debug(.treatments, "Temp target \(presetName) started from watch") if let ended = outcome.ended.first { - debug(.watchManager, "📱 Recorded run for replaced temp target \"\(ended.name ?? "Temp Target")\"") + debug(.treatments, "Recorded run for replaced temp target \"\(ended.name ?? "Temp Target")\"") } self.sendAcknowledgment( @@ -1030,7 +1040,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana ackCode: .tempTargetStarted ) } catch { - debug(.watchManager, "❌ Error activating temp target: \(error)") + warning(.treatments, "Starting temp target from watch failed", error: error) self.sendAcknowledgment( toWatch: false, message: (error as? AdjustmentError)?.errorDescription ?? "Error activating Temp Target \"\(presetName)\".", @@ -1044,7 +1054,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana Task { do { let outcome = try await adjustmentManager.cancelTempTarget(source: .watch) - debug(.watchManager, "📱 Successfully cancelled temp target \"\(outcome.ended.first?.name ?? "Temp Target")\"") + debug(.treatments, "Temp target \"\(outcome.ended.first?.name ?? "Temp Target")\" stopped from watch") self.sendAcknowledgment( toWatch: true, @@ -1055,7 +1065,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana ackCode: .tempTargetStopped ) } catch { - debug(.watchManager, "❌ Error stopping temp target: \(error)") + warning(.treatments, "Stopping temp target from watch failed", error: error) self.sendAcknowledgment( toWatch: false, message: (error as? AdjustmentError)?.errorDescription ?? "Error stopping Temp Target.", @@ -1108,7 +1118,7 @@ extension BaseWatchManager { for (index, entry) in entries.enumerated() { guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else { - debug(.default, "Invalid entry start time: \(entry.start)") + warning(.watch, "Skipped glucose target with invalid start time \(entry.start)") continue } diff --git a/Trio/Sources/Services/WatchManager/GarminCommandRouter.swift b/Trio/Sources/Services/WatchManager/GarminCommandRouter.swift index 4701f79a8e..ec7ec350e7 100644 --- a/Trio/Sources/Services/WatchManager/GarminCommandRouter.swift +++ b/Trio/Sources/Services/WatchManager/GarminCommandRouter.swift @@ -190,14 +190,14 @@ struct GarminCommandRouter { } let processor: WatchCommandProcessor - var log: (String) -> Void = { debug(.watchManager, $0) } + var log: (LogLevel, String) -> Void = { TrioLogger.log($0, .watch, $1, file: #file, function: #function, line: #line) } func route(_ message: Any, from appUUID: UUID, isRegistered: Bool, appName: String) async -> Outcome { let inbound = GarminCommandEnvelope.parse(message) - log("⌚️📥 Garmin: Received \(inbound.logName) from \(appName)") + log(.debug, "Garmin \(inbound.logName) received from \(appName)") guard isRegistered else { - log("⌚️🔐 Garmin: Ignoring \(inbound.logName) from unregistered app \(appUUID.uuidString)") + log(.warning, "Garmin \(inbound.logName) from unregistered app \(appUUID.uuidString) ignored") return .none } @@ -211,12 +211,12 @@ struct GarminCommandRouter { let presets = try await processor.presets() return Outcome(reply: GarminCommandEnvelope.presetsResponse(presets), refreshTrigger: nil) } catch { - log("⌚️❌ Garmin: Loading presets failed (\(WatchCommandErrorCategory.name(for: error)))") + log(.warning, "Garmin presets could not be loaded (\(WatchCommandErrorCategory.name(for: error)))") return .none } case let .command(requestIDString, requestID, date, command): - log("⌚️🔐 Garmin: Accepted \(command.name) from registered \(appName)") + log(.info, "Garmin \(command.name) accepted from \(appName)") let request = WatchCommandRequest(requestID: requestID, appUUID: appUUID, date: date, command: command) let result = await processor.process(request) return Outcome( diff --git a/Trio/Sources/Services/WatchManager/GarminManager.swift b/Trio/Sources/Services/WatchManager/GarminManager.swift index 15cb2776eb..9707c2239c 100644 --- a/Trio/Sources/Services/WatchManager/GarminManager.swift +++ b/Trio/Sources/Services/WatchManager/GarminManager.swift @@ -153,7 +153,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// - Parameter message: The debug message to log. private func debugGarmin(_ message: String) { guard debugGarminEnabled else { return } - debug(.watchManager, message) + debug(.watch, message) } // MARK: - Device Ready State (SDK 1.8+) @@ -419,8 +419,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { do { if self.debugWatchState { debug( - .watchManager, - "Garmin: Glucose fallback timer fired (no determination in \(Int(self.glucoseFallbackDelay))s)" + .watch, + "Garmin glucose fallback fired, no determination within \(Int(self.glucoseFallbackDelay)) s" ) } @@ -428,7 +428,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { let watchStateData = try JSONEncoder().encode(watchState) self.watchStateSubject.send(watchStateData) } catch { - debug(.watchManager, "Garmin: Error in glucose fallback: \(error)") + warning(.watch, "Garmin glucose fallback update failed", error: error) } } } @@ -437,7 +437,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { timerQueue.asyncAfter(deadline: .now() + glucoseFallbackDelay, execute: fallback) if debugWatchState { - debug(.watchManager, "Garmin: Glucose received - waiting \(Int(glucoseFallbackDelay))s for determination") + debug(.watch, "Garmin update waiting up to \(Int(glucoseFallbackDelay)) s for determination after new glucose") } } @@ -457,17 +457,14 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { Task { do { if self.debugWatchState { - debug( - .watchManager, - "Garmin: IOB fallback timer fired (no determination in \(Int(self.glucoseFallbackDelay))s)" - ) + debug(.watch, "Garmin IOB fallback fired, no determination within \(Int(self.glucoseFallbackDelay)) s") } let watchState = try await self.setupGarminWatchState(triggeredBy: "IOB") let watchStateData = try JSONEncoder().encode(watchState) self.watchStateSubject.send(watchStateData) } catch { - debug(.watchManager, "Garmin: Error in IOB fallback: \(error)") + warning(.watch, "Garmin IOB fallback update failed", error: error) } } } @@ -476,7 +473,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { timerQueue.asyncAfter(deadline: .now() + glucoseFallbackDelay, execute: fallback) if debugWatchState { - debug(.watchManager, "Garmin: IOB received - waiting \(Int(glucoseFallbackDelay))s for determination") + debug(.watch, "Garmin update waiting up to \(Int(glucoseFallbackDelay)) s for determination after IOB change") } } @@ -494,7 +491,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { self.pendingGlucoseFallback?.cancel() self.pendingGlucoseFallback = nil if self.debugWatchState { - debug(.watchManager, "Garmin: Determination arrived - cancelled glucose fallback timer") + debug(.watch, "Garmin fallback cancelled, determination arrived") } } } @@ -505,7 +502,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { let watchStateData = try JSONEncoder().encode(watchState) watchStateSubject.send(watchStateData) } catch { - debug(.watchManager, "Garmin: Error preparing watch state (\(trigger)): \(error)") + warning(.watch, "Preparing Garmin watch state failed, trigger \(trigger)", error: error) } } } @@ -524,7 +521,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { // If timer already scheduled, just log and return - data will be fresh when timer fires if pendingSettingsUpdate != nil { if debugWatchState { - debug(.watchManager, "Garmin: Settings throttle timer running, waiting for more changes") + debug(.watch, "Garmin settings update already scheduled") } return } @@ -534,7 +531,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { guard let self = self else { return } if self.debugWatchState { - debug(.watchManager, "Garmin: Settings throttle timer fired - sending update") + debug(.watch, "Sending throttled Garmin settings update") } Task { @@ -543,7 +540,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { let watchStateData = try JSONEncoder().encode(watchState) self.watchStateSubject.send(watchStateData) } catch { - debug(.watchManager, "Garmin: Error preparing settings watch state: \(error)") + warning(.watch, "Preparing Garmin settings update failed", error: error) } } @@ -556,7 +553,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { timerQueue.asyncAfter(deadline: .now() + settingsThrottleDuration, execute: throttledTask) if debugWatchState { - debug(.watchManager, "Garmin: Settings throttle timer started (\(Int(settingsThrottleDuration))s)") + debug(.watch, "Garmin settings update scheduled in \(Int(settingsThrottleDuration)) s") } } @@ -652,7 +649,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { } if debugWatchState { - debug(.watchManager, "Garmin: Preparing watch state [Trigger: \(triggeredBy)]") + debug(.watch, "Preparing Garmin watch state, trigger \(triggeredBy)") } // Fetch glucose - SwissAlpine needs 24, Trio needs 2 (for delta calculation) @@ -813,7 +810,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { let currentHash = watchStates.hashValue if currentHash == previousHash { if shouldDebug { - debug(.watchManager, "Garmin: Skipping - data unchanged") + debug(.watch, "Garmin watch state unchanged") } return previousWatchState ?? watchStates } @@ -825,8 +822,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { let sensRatioFormatted = String(format: "%.2f", watchStates.first?.sensRatio ?? 0) let targetFormatted = watchStates.first?.targetGlucose.map { "\($0)" } ?? "none" debug( - .watchManager, - "Garmin: Prepared \(watchStates.count) entries - sgv: \(watchStates.first?.sgv ?? 0), iob: \(iobFormatted), cob: \(cobFormatted), tbr: \(tbrFormatted), eventualBG: \(watchStates.first?.eventualBG ?? 0), sensRatio: \(sensRatioFormatted), colorScheme: \(watchStates.first?.glucoseColorScheme ?? "none"), target: \(targetFormatted)" + .watch, + "Garmin watch state prepared: \(watchStates.count) entries, BG \(watchStates.first?.sgv ?? 0), IOB \(iobFormatted) U, COB \(cobFormatted) g, temp basal \(tbrFormatted) U/h, eventual BG \(watchStates.first?.eventualBG ?? 0), sensitivity ratio \(sensRatioFormatted), color scheme \(watchStates.first?.glucoseColorScheme ?? "none"), target \(targetFormatted)" ) } @@ -876,11 +873,11 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { let watchfaceUUID = currentWatchface.watchfaceUUID, let watchfaceApp = IQApp(uuid: watchfaceUUID, store: UUID(), device: device) { - debugGarmin("Garmin: Registered \(appDetailedName(for: watchfaceUUID))") + debugGarmin("Registered Garmin \(appDetailedName(for: watchfaceUUID))") watchApps.append(watchfaceApp) connectIQClient?.registerApp(watchfaceApp, delegate: self) } else if !isWatchfaceDataEnabled { - debugGarmin("Garmin: Watchface data disabled - skipping watchface registration") + debugGarmin("Garmin watchface data disabled, watchface not registered") } // Always register every configured datafield - all of them receive the same payload @@ -889,7 +886,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { let datafieldApp = IQApp(uuid: datafieldUUID, store: UUID(), device: device) else { continue } - debugGarmin("Garmin: Registered \(appDetailedName(for: datafieldUUID))") + debugGarmin("Registered Garmin \(appDetailedName(for: datafieldUUID))") watchApps.append(datafieldApp) connectIQClient?.registerApp(datafieldApp, delegate: self) } @@ -992,35 +989,35 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { if currentHash == lastSentDataHash { if debugWatchState { - debug(.watchManager, "Garmin: Skipping broadcast - data unchanged") + debug(.watch, "Garmin broadcast skipped, data unchanged") } return } guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) else { - debug(.watchManager, "Garmin: Invalid JSON for watch-state data") + warning(.watch, "Garmin watch state is not valid JSON") return } watchApps.forEach { app in guard let appUUID = app.uuid else { - debug(.watchManager, "Garmin: Skipping app with undefined UUID") + debug(.watch, "Skipped Garmin app without UUID") return } let appName = self.appDisplayName(for: appUUID) // Check if device is ready (SDK 1.8+ requirement) guard let device = app.device, readyDevices.contains(device.uuid) else { - debugGarmin("Garmin: Skipping \(appName) - device not ready") + debugGarmin("Skipped Garmin \(appName), device not ready") return } connectIQClient?.appStatus(of: app) { [weak self] status in guard status?.isInstalled == true else { - debug(.watchManager, "Garmin: App not installed: \(appName)") + debug(.watch, "Garmin \(appName) not installed") return } - self?.debugGarmin("Garmin: Sending to \(appName)") + self?.debugGarmin("Sending to Garmin \(appName)") self?.sendMessage(jsonObject as Any, to: app, appName: appName) } } @@ -1076,15 +1073,16 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { completion: { [weak self] result in switch result { case .success: - debug(.watchManager, "Garmin: Successfully sent to \(appName)") + debug(.watch, "Sent to Garmin \(appName)") default: if isRetry { - debug(.watchManager, "Garmin: FAILED to send to \(appName) (retry also failed)") + error(.watch, "Sending to Garmin \(appName) failed after retry") } else { - debug(.watchManager, "Garmin: FAILED to send to \(appName) - will retry") + let retryDelay = self?.sendRetryDelay ?? 2 + warning(.watch, "Sending to Garmin \(appName) failed, retrying in \(retryDelay) s") // Retry after delay - SDK may need time after re-registration - DispatchQueue.main.asyncAfter(deadline: .now() + (self?.sendRetryDelay ?? 2)) { - self?.debugGarmin("Garmin: Retrying send to \(appName)") + DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { + self?.debugGarmin("Retrying send to Garmin \(appName)") self?.sendMessage(msg, to: app, appName: appName, isRetry: true) } } @@ -1102,7 +1100,7 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM /// Called if the Garmin Connect Mobile app is not installed or otherwise not available. /// Typically, you would show an alert or prompt the user to install the app from the store. func needsToInstallConnectMobile() { - debug(.apsManager, "Garmin is not available") + warning(.watch, "Garmin Connect app not available") let content = Alert.Content( title: String(localized: "Garmin is not available"), body: String( @@ -1137,21 +1135,21 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM // Always log connection state changes - critical for diagnosing SDK issues switch status { case .invalidDevice: - debug(.watchManager, "Garmin: Device status -> invalidDevice") + warning(.watch, "Garmin device invalid") readyDevices.remove(device.uuid) case .bluetoothNotReady: - debug(.watchManager, "Garmin: Device status -> bluetoothNotReady") + info(.watch, "Garmin device unavailable, Bluetooth not ready") readyDevices.remove(device.uuid) case .notFound: - debug(.watchManager, "Garmin: Device status -> notFound") + info(.watch, "Garmin device not found") readyDevices.remove(device.uuid) case .notConnected: - debug(.watchManager, "Garmin: Device status -> notConnected") + info(.watch, "Garmin device disconnected") readyDevices.remove(device.uuid) case .connected: - debug(.watchManager, "Garmin: Device status -> connected (waiting for characteristics)") + debug(.watch, "Garmin device connected, waiting for characteristics") @unknown default: - debug(.watchManager, "Garmin: Device status -> unknown(\(status.rawValue))") + debug(.watch, "Garmin device status unknown (\(status.rawValue))") } } @@ -1159,7 +1157,7 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM /// This is required in SDK 1.8+ - sending before this callback may fail. /// - Parameter device: The device whose characteristics have been discovered. func deviceCharacteristicsDiscovered(_ device: IQDevice) { - debug(.watchManager, "Garmin: Device characteristics discovered - ready for communication") + info(.watch, "Garmin device connected and ready") readyDevices.insert(device.uuid) // Trigger a data send now that device is ready @@ -1177,12 +1175,13 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM /// - app: The watch app sending the message. func receivedMessage(_ message: Any, from app: IQApp) { guard let appUUID = app.uuid else { - debug(.watchManager, "Garmin: Received message from app with undefined UUID - ignoring") + debug(.watch, "Ignored message from Garmin app without UUID") return } let appName = appDisplayName(for: appUUID) let isRegistered = watchApps.contains { $0.uuid == appUUID && $0.device?.uuid == app.device?.uuid } let router = GarminCommandRouter(processor: watchCommandProcessor) + debugGarmin("Message received from Garmin \(appName)") Task { let outcome = await router.route(message, from: appUUID, isRegistered: isRegistered, appName: appName) @@ -1221,10 +1220,7 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM } } } catch { - debug( - .watchManager, - "⌚️❌ Garmin: Loading presets for \(trigger) push failed (\(WatchCommandErrorCategory.name(for: error)))" - ) + warning(.watch, "Garmin presets for \(trigger) push could not be loaded (\(WatchCommandErrorCategory.name(for: error)))") } } } @@ -1233,7 +1229,7 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM /// would otherwise suppress the next state update or leak command data to other apps. private func sendTargetedResponse(_ response: [String: Any], to app: IQApp, appName: String) { let kind = response[WatchMessageKeys.request] as? String ?? "response" - debug(.watchManager, "⌚️📤 Garmin: Sending \(kind) to \(appName)") + debugGarmin("Sending \(kind) to Garmin \(appName)") sendMessage(response, to: app, appName: appName) } } @@ -1272,7 +1268,7 @@ extension BaseGarminManager: SettingsObserver { // Re-register devices only if watchface/datafield configuration changed if watchfaceChanged || datafieldChanged || watchfaceDataEnabledChanged { - debugGarmin("Garmin: Watchface/datafield config changed - re-registering devices") + debugGarmin("Garmin watchface or data field changed, re-registering devices") if !devices.isEmpty { registerDevices(devices) } @@ -1286,7 +1282,7 @@ extension BaseGarminManager: SettingsObserver { if watchfaceDataJustEnabled { // Send immediately when watchface data is enabled - user wants to see data now if debugWatchState { - debug(.watchManager, "Garmin: Watchface data enabled - sending update immediately") + debug(.watch, "Garmin watchface data enabled, sending update") } triggerWatchStateUpdate(triggeredBy: "Settings") } else if commandSettingsChanged { @@ -1294,7 +1290,7 @@ extension BaseGarminManager: SettingsObserver { } else if unitsChanged || displayAttributesChanged || colorSchemeChanged { // Throttle other settings changes in case user makes multiple changes if debugWatchState { - debug(.watchManager, "Garmin: Settings changed - scheduling throttled update") + debug(.watch, "Garmin display settings changed, scheduling update") } sendSettingsUpdateThrottled() } @@ -1315,7 +1311,7 @@ extension BaseGarminManager: BGTargetsObserver { /// - Parameter _: The updated targets, re-read from storage during preparation. func bgTargetsDidChange(_: BGTargets) { if debugWatchState { - debug(.watchManager, "Garmin: Glucose targets changed - scheduling throttled update") + debug(.watch, "Glucose targets changed, scheduling Garmin update") } sendSettingsUpdateThrottled() } diff --git a/Trio/Sources/Shortcuts/LiveActivity/RestartLiveActivityIntent.swift b/Trio/Sources/Shortcuts/LiveActivity/RestartLiveActivityIntent.swift index 32a29368e2..674ae78654 100644 --- a/Trio/Sources/Shortcuts/LiveActivity/RestartLiveActivityIntent.swift +++ b/Trio/Sources/Shortcuts/LiveActivity/RestartLiveActivityIntent.swift @@ -22,7 +22,7 @@ import Foundation do { try await request.performRestart() } catch { - debug(.default, "Error restarting Live Activity: \(error)") + warning(.liveActivity, "Restarting Live Activity from shortcut failed", error: error) } return .result(value: String(localized: "Trio Live Activity restarted successfully.")) } diff --git a/Trio/Sources/Shortcuts/LiveActivity/RestartLiveActivityIntentRequest.swift b/Trio/Sources/Shortcuts/LiveActivity/RestartLiveActivityIntentRequest.swift index 2569243854..5c311efe1d 100644 --- a/Trio/Sources/Shortcuts/LiveActivity/RestartLiveActivityIntentRequest.swift +++ b/Trio/Sources/Shortcuts/LiveActivity/RestartLiveActivityIntentRequest.swift @@ -16,12 +16,10 @@ import UIKit backgroundTaskID = startBackgroundTask(withName: "Restart Live Activity") guard backgroundTaskID != .invalid else { - debug(.default, "Failed to start background task.") + warning(.liveActivity, "Live Activity restart skipped, background task could not start") return } - debug(.default, "Background task started: \(backgroundTaskID)") - await liveActivityManager.restartActivityFromLiveActivityIntent() // Ensure background task ends properly diff --git a/Trio/Sources/Shortcuts/Override/OverridePresetsIntentRequest.swift b/Trio/Sources/Shortcuts/Override/OverridePresetsIntentRequest.swift index dd3ac61573..6a8e4ae7f8 100644 --- a/Trio/Sources/Shortcuts/Override/OverridePresetsIntentRequest.swift +++ b/Trio/Sources/Shortcuts/Override/OverridePresetsIntentRequest.swift @@ -36,10 +36,7 @@ import UIKit } } } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Error fetching/processing overrides: \(error)" - ) + debug(.treatments, "Fetching override presets failed") throw error } } @@ -63,10 +60,7 @@ import UIKit let result = try context.fetch(fetchRequest) if result.isEmpty { - debug( - .default, - "\(DebuggingIdentifiers.failed) No OverrideStored found for ids: \(uuid)" - ) + debug(.treatments, "No override presets found for \(uuid.count) IDs") throw overridePresetsError.noTempOverrideFound } @@ -74,10 +68,7 @@ import UIKit OverridePreset(id: overrideStored.id ?? UUID().uuidString, name: overrideStored.name ?? "") } } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to fetch Override: \(error)" - ) + debug(.treatments, "Fetching override presets by ID failed") throw error } } @@ -91,16 +82,16 @@ import UIKit - Returns: A boolean indicating whether the override was successfully enacted. */ @MainActor func enactOverride(_ preset: OverridePreset) async -> Bool { - debug(.default, "Enacting override: \(preset.name)") + debug(.treatments, "Starting override \(preset.name) from shortcut") var backgroundTaskID = startBackgroundTask(withName: "Override Enact") defer { endBackgroundTaskSafely(&backgroundTaskID, taskName: "Override Enact") } do { try await adjustmentManager.activateOverride(.presetID(preset.id), source: .shortcut, waitForUpload: true) - debug(.default, "Finished. Override enacted via Shortcut.") + debug(.treatments, "Override started from shortcut") return true } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to enact override: \(error)") + warning(.treatments, "Starting override from shortcut failed", error: error) return false } } @@ -109,16 +100,16 @@ import UIKit Cancels the active override. Nothing running is the state the intent asks for. */ @MainActor func cancelOverride() async { - debug(.default, "Cancelling active override") + debug(.treatments, "Stopping override from shortcut") var backgroundTaskID = startBackgroundTask(withName: "Override Cancel") defer { endBackgroundTaskSafely(&backgroundTaskID, taskName: "Override Cancel") } do { try await adjustmentManager.cancelOverride(source: .shortcut, waitForUpload: true) } catch AdjustmentError.nothingActive { - debug(.default, "No active override to cancel") + debug(.treatments, "No active override to stop") } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to cancel override: \(error)") + warning(.treatments, "Stopping override from shortcut failed", error: error) } } } diff --git a/Trio/Sources/Shortcuts/State/StateIntentRequest.swift b/Trio/Sources/Shortcuts/State/StateIntentRequest.swift index e93080e379..2070129106 100644 --- a/Trio/Sources/Shortcuts/State/StateIntentRequest.swift +++ b/Trio/Sources/Shortcuts/State/StateIntentRequest.swift @@ -91,10 +91,9 @@ final class StateIntentRequest: BaseIntentsRequest { .asMmolL : Decimal($0) ) as NSNumber)! } ?? "--" - debugPrint("StateIntentRequest: \(#function) \(DebuggingIdentifiers.succeeded) fetched latest 2 glucose values") return (lastValue.date ?? Date(), glucoseAsString, directionAsString, deltaAsString) } catch { - debugPrint("StateIntentRequest: \(#function) \(DebuggingIdentifiers.failed) failed to fetch latest 2 glucose values") + warning(.storage, "Fetching latest glucose for shortcut failed", error: error) return (Date(), "", "", "") } } diff --git a/Trio/Sources/Shortcuts/TempPresets/CreateCustomTempTargetIntent.swift b/Trio/Sources/Shortcuts/TempPresets/CreateCustomTempTargetIntent.swift index df9382c953..8ccb99c51b 100644 --- a/Trio/Sources/Shortcuts/TempPresets/CreateCustomTempTargetIntent.swift +++ b/Trio/Sources/Shortcuts/TempPresets/CreateCustomTempTargetIntent.swift @@ -160,10 +160,7 @@ final class CustomTempTargetIntentRequest: BaseIntentsRequest { endBackgroundTaskSafely(&backgroundTaskID, taskName: "TempTarget Custom Enact") return true } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to enact custom TempTarget: \(error)" - ) + warning(.treatments, "Enacting custom temp target failed", error: error) endBackgroundTaskSafely(&backgroundTaskID, taskName: "TempTarget Custom Enact") return false } diff --git a/Trio/Sources/Shortcuts/TempPresets/ScheduledTempTargetHelper.swift b/Trio/Sources/Shortcuts/TempPresets/ScheduledTempTargetHelper.swift index 77108dd191..2bc3128954 100644 --- a/Trio/Sources/Shortcuts/TempPresets/ScheduledTempTargetHelper.swift +++ b/Trio/Sources/Shortcuts/TempPresets/ScheduledTempTargetHelper.swift @@ -38,10 +38,7 @@ import Foundation do { try await tempTargetsStorage.storeTempTarget(tempTarget: scheduledTT) } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to store scheduled TempTarget: \(error)" - ) + warning(.treatments, "Storing scheduled temp target failed", error: error) return false } @@ -109,10 +106,7 @@ import Foundation await awaitNotification(.didUpdateTempTargetConfiguration) } } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to disable active TempTargets: \(error)" - ) + warning(.treatments, "Disabling active temp targets failed", error: error) } } @@ -136,10 +130,7 @@ import Foundation guard let firstID = ids.first, let row = try viewContext.existingObject(with: firstID) as? TempTargetStored else { - debug( - .default, - "\(DebuggingIdentifiers.failed) Scheduled TempTarget not found for \(startTime)" - ) + warning(.treatments, "Scheduled temp target for \(startTime) not found") return } row.enabled = true @@ -161,10 +152,7 @@ import Foundation object: nil ) } catch { - debug( - .default, - "\(DebuggingIdentifiers.failed) Failed to activate scheduled TempTarget: \(error)" - ) + warning(.treatments, "Activating scheduled temp target failed", error: error) } } diff --git a/Trio/Sources/Shortcuts/TempPresets/TempPresetsIntentRequest.swift b/Trio/Sources/Shortcuts/TempPresets/TempPresetsIntentRequest.swift index a53225aa62..a4c243e197 100644 --- a/Trio/Sources/Shortcuts/TempPresets/TempPresetsIntentRequest.swift +++ b/Trio/Sources/Shortcuts/TempPresets/TempPresetsIntentRequest.swift @@ -26,7 +26,7 @@ final class TempPresetsIntentRequest: BaseIntentsRequest { // Fetch existing TempTargetStored objects based on their NSManagedObjectIDs let tempTargetObjects: [TempTargetStored] = allTempTargetPresetsIDs.compactMap { id in guard let object = try? context.existingObject(with: id) as? TempTargetStored else { - debugPrint("\(#file) \(#function) Failed to fetch object for ID: \(id)") + warning(.treatments, "Skipped temp target preset that could not be loaded") return nil } return object @@ -39,7 +39,7 @@ final class TempPresetsIntentRequest: BaseIntentsRequest { let target = object.target?.decimalValue, let duration = object.duration?.decimalValue else { - debugPrint("\(#file) \(#function) Missing data for TempTargetStored object.") + debug(.treatments, "Temp target preset is missing required fields") throw TempPresetsError.noTempTargetFound } return TempPreset(id: id, name: name, targetTop: target, duration: duration) @@ -63,7 +63,7 @@ final class TempPresetsIntentRequest: BaseIntentsRequest { let result = try context.fetch(fetchRequest) if result.isEmpty { - debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) No TempTargetStored found for ids: \(uuid)") + debug(.treatments, "No temp target presets found for \(uuid.count) IDs") return [TempPreset(id: UUID(), name: "", duration: 0)] } @@ -75,9 +75,7 @@ final class TempPresetsIntentRequest: BaseIntentsRequest { ) } } catch let error as NSError { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch TempTarget: \(error)" - ) + warning(.treatments, "Fetching temp target presets failed", error: error) return [TempPreset(id: UUID(), name: "", duration: 0)] } } @@ -90,7 +88,7 @@ final class TempPresetsIntentRequest: BaseIntentsRequest { /// - Parameter preset: The `TempPreset` to apply. /// - Returns: `true` if successfully enacted, otherwise `false`. @MainActor func enactTempTarget(_ preset: TempPreset) async -> Bool { - debug(.default, "Enacting Temp Target: \(preset.name)") + debug(.treatments, "Starting temp target \(preset.name) from shortcut") var backgroundTaskID = startBackgroundTask(withName: "TempTarget Enact") defer { endBackgroundTaskSafely(&backgroundTaskID, taskName: "TempTarget Enact") } @@ -100,12 +98,10 @@ final class TempPresetsIntentRequest: BaseIntentsRequest { source: .shortcut, waitForUpload: true ) - debug(.default, "Finished. Temp Target enacted via Shortcut.") + debug(.treatments, "Temp target started from shortcut") return true } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to enact Temp Target with error: \(error)" - ) + warning(.treatments, "Starting temp target from shortcut failed", error: error) return false } } @@ -120,10 +116,7 @@ final class TempPresetsIntentRequest: BaseIntentsRequest { /// which is the same backend used by `CreateCustomTempTargetIntent`. @MainActor func schedulePresetTempTarget(_ preset: TempPreset, at fireAt: Date) async -> Bool { guard let details = await fetchPresetTempTargetDetails(preset) else { - debug( - .default, - "\(DebuggingIdentifiers.failed) Preset not found or incomplete for scheduling: \(preset.name)" - ) + warning(.treatments, "Temp target preset not found or incomplete, not scheduled") return false } return await ScheduledTempTargetHelper.enact( @@ -166,18 +159,16 @@ final class TempPresetsIntentRequest: BaseIntentsRequest { /// Cancels the active temporary target. Nothing running is the state the intent asks for. @MainActor func cancelTempTarget() async { - debug(.default, "Cancelling active temp target") + debug(.treatments, "Stopping temp target from shortcut") var backgroundTaskID = startBackgroundTask(withName: "TempTarget Cancel") defer { endBackgroundTaskSafely(&backgroundTaskID, taskName: "TempTarget Cancel") } do { try await adjustmentManager.cancelTempTarget(source: .shortcut, waitForUpload: true) } catch AdjustmentError.nothingActive { - debug(.default, "No active temp target to cancel") + debug(.treatments, "No active temp target to stop") } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to cancel Temp Target with error: \(error)" - ) + warning(.treatments, "Stopping temp target from shortcut failed", error: error) } } } diff --git a/Trio/Sources/Views/GlassActionSheet.swift b/Trio/Sources/Views/GlassActionSheet.swift index 6fe221a3d2..8bfe3fca52 100644 --- a/Trio/Sources/Views/GlassActionSheet.swift +++ b/Trio/Sources/Views/GlassActionSheet.swift @@ -34,14 +34,16 @@ extension View { message: Text? = nil, isPresented: Binding, actions: [GlassSheetAction], - onCancel: (() -> Void)? = nil + onCancel: (() -> Void)? = nil, + onDismiss: (() -> Void)? = nil ) -> some View { modifier(GlassActionSheetModifier( isPresented: isPresented, title: title.map { Text($0) }, message: message, actions: actions, - onCancel: onCancel + onCancel: onCancel, + onDismiss: onDismiss )) } @@ -51,14 +53,16 @@ extension View { message: Text? = nil, isPresented: Binding, actions: [GlassSheetAction], - onCancel: (() -> Void)? = nil + onCancel: (() -> Void)? = nil, + onDismiss: (() -> Void)? = nil ) -> some View { modifier(GlassActionSheetModifier( isPresented: isPresented, title: title, message: message, actions: actions, - onCancel: onCancel + onCancel: onCancel, + onDismiss: onDismiss )) } } @@ -69,6 +73,7 @@ private struct GlassActionSheetModifier: ViewModifier { let message: Text? let actions: [GlassSheetAction] let onCancel: (() -> Void)? + let onDismiss: (() -> Void)? // Shadow state so the cover is always presented/dismissed without its own // slide animation; the sheet animates internally. @@ -89,7 +94,7 @@ private struct GlassActionSheetModifier: ViewModifier { withTransaction(transaction) { coverShown = false } } } - .fullScreenCover(isPresented: $coverShown) { + .fullScreenCover(isPresented: $coverShown, onDismiss: onDismiss) { GlassActionSheetView( title: title, message: message, diff --git a/Trio/Sources/Views/LogShareRow.swift b/Trio/Sources/Views/LogShareRow.swift new file mode 100644 index 0000000000..1664d8f82e --- /dev/null +++ b/Trio/Sources/Views/LogShareRow.swift @@ -0,0 +1,131 @@ +import SwiftUI + +struct LogShareRow: View { + let title: LocalizedStringKey + var footnote: LocalizedStringKey? + let kinds: [LogExport.Kind] + let logExport: LogExport? + + @State private var showKinds = false + @State private var showDays = false + @State private var pendingKind: LogExport.Kind? + @State private var isPreparing = false + @State private var file: URL? + @State private var sharedFile: SharedFile? + @State private var showError = false + + var body: some View { + VStack(alignment: .leading) { + Button(action: start) { + HStack { + Text(title) + .foregroundColor(.primary) + Spacer() + if isPreparing { + ProgressView() + } else { + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.footnote) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .disabled(isPreparing || logExport == nil || kinds.isEmpty) + + if let footnote { + Text(footnote) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + } + } + .accessibilityElement(children: .combine) + .glassActionSheet( + "Share Logs", + message: Text("Your Nightscout address, passwords and device serial numbers are removed."), + isPresented: $showKinds, + actions: kinds.map { kind in + GlassSheetAction(kind.label) { pendingKind = kind } + }, + // A cover presented while another is still dismissing never appears and blocks touches + onDismiss: { + if pendingKind != nil { showDays = true } + } + ) + .glassActionSheet( + "How far back?", + isPresented: $showDays, + actions: [ + GlassSheetAction("Last 24 Hours") { share(days: 1) }, + GlassSheetAction("Last 3 Days") { share(days: 3) }, + GlassSheetAction("Last 7 Days") { share(days: 7) } + ], + onCancel: { pendingKind = nil } + ) + // item: hands the file to the sheet; isPresented: could build it before file was set and share nothing + .sheet(item: $sharedFile, onDismiss: discardFile) { shared in + // The share sheet takes seconds to build on device; keep the spinner until it shows + ShareSheet(activityItems: [shared.url]) + .onAppear { isPreparing = false } + } + .alert("Could not prepare logs", isPresented: $showError) { + Button("OK", role: .cancel) {} + } + } + + private func start() { + guard !isPreparing else { return } + if kinds.count == 1 { + pendingKind = kinds.first + showDays = true + } else { + pendingKind = nil + showKinds = true + } + } + + private func share(days: Int) { + guard let kind = pendingKind, let logExport else { return } + pendingKind = nil + isPreparing = true + Task { @MainActor in + do { + let url = try await logExport.makeFile(kind, days: days) + file = url + sharedFile = SharedFile(url: url) + } catch { + isPreparing = false + warning(.ui, "Preparing logs for sharing failed", error: error) + showError = true + } + } + } + + private func discardFile() { + isPreparing = false + guard let file else { return } + self.file = nil + Task.detached(priority: .utility) { + LogExport.discard(file) + } + } +} + +private struct SharedFile: Identifiable { + let url: URL + var id: URL { url } +} + +private extension LogExport.Kind { + var label: LocalizedStringKey { + switch self { + case .all: return "All Logs" + case .pump: return "Pump Logs" + case .cgm: return "CGM Logs" + } + } +} diff --git a/TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift b/TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift index 81492fee1f..17370423e0 100644 --- a/TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift +++ b/TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift @@ -867,6 +867,7 @@ extension BolusCalculatorTests { /// - Returns: The current decimal value for the specified setting type private func getCurrentSettingValue(for type: SettingType, at date: Date) async -> Decimal { let calendar = Calendar.current + // Wall-clock minutes; elapsed time since midnight is off by an hour on DST days let components = calendar.dateComponents([.hour, .minute], from: date) let minutesSinceMidnight = (components.hour ?? 0) * 60 + (components.minute ?? 0) diff --git a/TrioTests/CoreDataTests/ScheduledBasalInferenceTests.swift b/TrioTests/CoreDataTests/ScheduledBasalInferenceTests.swift index 7ccc0652bf..c9f8d3071c 100644 --- a/TrioTests/CoreDataTests/ScheduledBasalInferenceTests.swift +++ b/TrioTests/CoreDataTests/ScheduledBasalInferenceTests.swift @@ -14,10 +14,9 @@ import Testing [BasalProfileEntry(start: "00:00", minutes: 0, rate: 1.0)] } - // wall-clock anchored so DST transitions don't shift hour/minute semantics + // Wall-clock times on one calendar day, matching the sweep's boundary math; seconds from midnight drift on DST days private func time(_ hour: Int, _ minute: Int = 0) -> Date { - Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Calendar.current - .startOfDay(for: Date()) + Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date())! } private func tempBasal(_ start: Date, _ end: Date) -> TimelineEvent { @@ -219,7 +218,7 @@ import Testing } private func time(_ hour: Int, _ minute: Int = 0) -> Date { - Calendar.current.startOfDay(for: Date()).addingTimeInterval(TimeInterval(hour * 3600 + minute * 60)) + Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date())! } private func temp(_ start: Date, minutes: Double, rate: Decimal) -> BasalEvent { diff --git a/TrioTests/Logging/AppLogStoreTests.swift b/TrioTests/Logging/AppLogStoreTests.swift new file mode 100644 index 0000000000..da2a9dc9d6 --- /dev/null +++ b/TrioTests/Logging/AppLogStoreTests.swift @@ -0,0 +1,278 @@ +import CoreData +import Foundation +import Testing +import UIKit + +@testable import Trio + +@Suite("App Log Store Tests", .serialized) struct AppLogStoreTests { + private final class Loader: @unchecked Sendable { + private let lock = NSLock() + private var succeeds: Bool + private var loaded = false + + init(succeeds: Bool) { self.succeeds = succeeds } + + var didLoad: Bool { lock.withLock { loaded } } + func setSucceeds(_ value: Bool) { lock.withLock { succeeds = value } } + + func run(_ url: URL) -> NSPersistentContainer? { + guard lock.withLock({ succeeds }), let container = AppLogStore.loadContainer(url) else { return nil } + lock.withLock { loaded = true } + return container + } + } + + private static func makeStore( + policy: LogRetentionPolicy = .default, + loader: Loader = Loader(succeeds: true), + center: Foundation.NotificationCenter = Foundation.NotificationCenter(), + isProtectedDataAvailable: @escaping @MainActor() -> Bool = { true } + ) throws -> (store: AppLogStore, directory: URL) { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let store = AppLogStore( + storageURL: directory.appendingPathComponent("logs/AppLog.sqlite"), + policy: policy, + notificationCenter: center, + loadContainer: loader.run, + isProtectedDataAvailable: isProtectedDataAvailable + ) + return (store, directory) + } + + private static func record(_ index: Int, at date: Date, level: LogLevel = .info) -> LogRecord { + LogRecord( + timestamp: date, + level: level, + category: "Service", + message: "message \(index)", + file: "File.swift", + function: "run()", + line: UInt(index), + launchID: LaunchID.current + ) + } + + // Generous: the main actor is busy when the whole suite runs + private static func waitUntil(_ condition: () async -> Bool) async -> Bool { + for _ in 0 ..< 400 { + if await condition() { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return false + } + + private static let everything = DateInterval(start: .distantPast, end: .distantFuture) + + @Test("Records round-trip in order") func roundTrip() async throws { + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + let start = Date().addingTimeInterval(-60) + let records = (0 ..< 1000).map { Self.record($0, at: start.addingTimeInterval(Double($0) / 1000)) } + + records.forEach(store.accept) + await store.flush() + + let stored = await store.records(in: Self.everything, minimumLevel: .debug) + #expect(stored == records) + #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent("logs/AppLog.sqlite").path)) + } + + @Test("Flush throughput") func throughput() async throws { + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + await store.flush() + let now = Date() + let records = (0 ..< 4000).map { Self.record($0, at: now) } + + let clock = ContinuousClock() + let acceptDuration = clock.measure { records.forEach(store.accept) } + let flushDuration = await clock.measure { await store.flush() } + print("AppLogStore: accepted 4000 in \(acceptDuration), flushed in \(flushDuration)") + #expect(await store.records(in: Self.everything, minimumLevel: .debug).count == 4000) + } + + @Test("Purges records older than the maximum age") func agePurge() async throws { + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + let now = Date() + store.accept(Self.record(0, at: now.addingTimeInterval(-2 * 60 * 60))) + store.accept(Self.record(1, at: now.addingTimeInterval(-10))) + await store.flush() + + var policy = LogRetentionPolicy.default + policy.maxAge = 60 * 60 + store.apply(policy) + + let stored = await store.records(in: Self.everything, minimumLevel: .debug) + #expect(stored.map(\.message) == ["message 1"]) + } + + @Test("Purges several days of old records a day at a time while logging continues") func purgesByDay() async throws { + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + let now = Date() + var policy = LogRetentionPolicy.default + policy.maxAge = 2 * 24 * 60 * 60 + let cutoff = now.addingTimeInterval(-policy.maxAge) + let dates = stride(from: 10 * 24.0, to: 0, by: -1).map { now.addingTimeInterval(-$0 * 60 * 60 - 120) } + + [cutoff.addingTimeInterval(-300), cutoff.addingTimeInterval(300)] + let seeded = dates.sorted().enumerated().map { Self.record($0.offset, at: $0.element) } + seeded.forEach(store.accept) + await store.flush() + + store.apply(policy) + let during = (0 ..< 50).map { Self.record(1000 + $0, at: Date()) } + during.forEach(store.accept) + let early = await store.records(in: Self.everything, minimumLevel: .debug) + #expect(early.contains { $0.timestamp < cutoff }) + + #expect(await Self.waitUntil { + await store.records(in: Self.everything, minimumLevel: .debug).allSatisfy { $0.timestamp >= cutoff } + }) + let stored = await store.records(in: Self.everything, minimumLevel: .debug) + #expect(stored == seeded.filter { $0.timestamp >= cutoff } + during) + } + + @Test("Keeps only the newest rows over the cap") func rowCapPurge() async throws { + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + let start = Date().addingTimeInterval(-60) + (0 ..< 50).forEach { store.accept(Self.record($0, at: start.addingTimeInterval(Double($0)))) } + await store.flush() + + var policy = LogRetentionPolicy.default + policy.maxRows = 10 + store.apply(policy) + + let stored = await store.records(in: Self.everything, minimumLevel: .debug) + #expect(stored.map(\.message) == (40 ..< 50).map { "message \($0)" }) + } + + @Test("Drops debug records when verbose logging is off") func dropsDebug() async throws { + var policy = LogRetentionPolicy.default + policy.persistsDebug = false + let (store, directory) = try Self.makeStore(policy: policy) + defer { try? FileManager.default.removeItem(at: directory) } + let now = Date() + store.accept(Self.record(0, at: now, level: .debug)) + store.accept(Self.record(1, at: now, level: .info)) + await store.flush() + + let stored = await store.records(in: Self.everything, minimumLevel: .debug) + #expect(stored.map(\.message) == ["message 1"]) + } + + @Test("Lone surrogates and NUL bytes are stored without losing the batch") func sanitizesStrings() async throws { + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + let now = Date() + let surrogate = LogRecord( + timestamp: now, + level: .info, + category: "Service", + message: String(utf16CodeUnits: [0x61, 0xD800, 0x62], count: 3), + file: "File.swift", + function: "run()", + line: 1, + launchID: LaunchID.current + ) + let nul = LogRecord( + timestamp: now.addingTimeInterval(1), + level: .info, + category: "Service", + message: "a\u{0}b", + file: "File.swift", + function: "run()", + line: 2, + launchID: LaunchID.current + ) + let records = [ + Self.record(0, at: now.addingTimeInterval(-1)), + surrogate, + nul, + Self.record(3, at: now.addingTimeInterval(2)) + ] + records.forEach(store.accept) + await store.flush() + + let stored = await store.records(in: Self.everything, minimumLevel: .debug) + #expect(stored.map(\.message) == ["message 0", "a\u{FFFD}b", "a\u{FFFD}b", "message 3"]) + } + + @Test("Buffered records drain in order once the store opens") func drainsBuffer() async throws { + let loader = Loader(succeeds: false) + let center = Foundation.NotificationCenter() + let (store, directory) = try Self.makeStore(loader: loader, center: center, isProtectedDataAvailable: { false }) + defer { try? FileManager.default.removeItem(at: directory) } + let start = Date().addingTimeInterval(-60) + let records = (0 ..< 300).map { Self.record($0, at: start.addingTimeInterval(Double($0))) } + + records.forEach(store.accept) + await store.flush() + #expect(!loader.didLoad) + #expect(await store.records(in: Self.everything, minimumLevel: .debug) == records) + + loader.setSucceeds(true) + center.post(name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + + #expect(await Self.waitUntil { loader.didLoad }) + #expect(await store.records(in: Self.everything, minimumLevel: .debug) == records) + } + + @Test("An unlock during the protected-data check still opens the store") func unlockDuringCheck() async throws { + let loader = Loader(succeeds: false) + let center = Foundation.NotificationCenter() + let (store, directory) = try Self.makeStore(loader: loader, center: center, isProtectedDataAvailable: { + Thread.sleep(forTimeInterval: 0.3) + return false + }) + defer { try? FileManager.default.removeItem(at: directory) } + _ = store + + try await Task.sleep(nanoseconds: 100_000_000) + loader.setSucceeds(true) + center.post(name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + + #expect(await Self.waitUntil { loader.didLoad }) + } + + @Test("Equal timestamps come back in the order they were stored") func equalTimestampsKeepOrder() async throws { + let (store, directory) = try Self.makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + let date = Date() + for batch in 0 ..< 5 { + (batch * 100 ..< batch * 100 + 100).forEach { store.accept(Self.record($0, at: date)) } + await store.flush() + } + let stored = await store.records(in: Self.everything, minimumLevel: .debug) + #expect(stored.map(\.line) == Array(0 ..< 500).map(UInt.init)) + } + + @Test("Moved-aside stores older than 7 days are deleted on open") func removesOldUnreadableStores() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let logs = directory.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + let now = Int(Date().timeIntervalSince1970) + let old = now - 8 * 24 * 60 * 60 + let expired = ["AppLog.sqlite.unreadable-\(old)", "AppLog.sqlite-wal.unreadable-\(old)"] + let kept = [ + "AppLog.sqlite.unreadable-\(now - 24 * 60 * 60)", + "AppLog.sqlite.unreadable-unknown", + "DeviceLog.sqlite.unreadable-\(old)" + ] + for name in expired + kept { + FileManager.default.createFile(atPath: logs.appendingPathComponent(name).path, contents: Data([1])) + } + let store = AppLogStore( + storageURL: logs.appendingPathComponent("AppLog.sqlite"), + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + await store.flush() + let remaining = try FileManager.default.contentsOfDirectory(atPath: logs.path).filter { $0.contains(".unreadable-") } + #expect(Set(remaining) == Set(kept)) + } +} diff --git a/TrioTests/Logging/DeviceLogStoreTests.swift b/TrioTests/Logging/DeviceLogStoreTests.swift new file mode 100644 index 0000000000..7a83000d1c --- /dev/null +++ b/TrioTests/Logging/DeviceLogStoreTests.swift @@ -0,0 +1,190 @@ +import Foundation +import LoopKit +import Testing +import UIKit + +@testable import Trio + +@Suite("Device Log Store Tests", .serialized) struct DeviceLogStoreTests { + private final class Preflight: @unchecked Sendable { + private let lock = NSLock() + private var succeeds: Bool + private var calls = 0 + + init(succeeds: Bool) { self.succeeds = succeeds } + + var callCount: Int { lock.withLock { calls } } + func setSucceeds(_ value: Bool) { lock.withLock { succeeds = value } } + + func run(_ url: URL) -> Bool { + let shouldSucceed = lock.withLock { + calls += 1 + return succeeds + } + return shouldSucceed && DeviceLogStore.preflight(url) + } + } + + private static func makeStorageURL() throws -> (directory: URL, file: URL) { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return (directory, directory.appendingPathComponent("Logs/DeviceLog.sqlite")) + } + + private static var window: DateInterval { + DateInterval(start: Date().addingTimeInterval(-60), end: Date().addingTimeInterval(60)) + } + + private static func log(_ store: DeviceLogStore, manager: String, message: String) async -> Error? { + await withCheckedContinuation { continuation in + store.log(managerIdentifier: manager, deviceIdentifier: "device-1", type: .send, message: message) { + continuation.resume(returning: $0) + } + } + } + + // Generous: the main actor is busy when the whole suite runs + private static func waitUntil(_ condition: () async -> Bool) async -> Bool { + for _ in 0 ..< 400 { + if await condition() { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return false + } + + @Test("Entries are filtered by manager and ordered by time") func filtersAndOrders() async throws { + let (directory, file) = try Self.makeStorageURL() + defer { try? FileManager.default.removeItem(at: directory) } + let store = DeviceLogStore(storageURL: file, isProtectedDataAvailable: { true }) + + #expect(await Self.log(store, manager: "com.example.pump", message: "first") == nil) + #expect(await Self.log(store, manager: "com.example.cgm", message: "second") == nil) + #expect(await Self.log(store, manager: "com.example.pump", message: "third") == nil) + + let all = await store.entries(in: Self.window) + #expect(all.map(\.message) == ["first", "second", "third"]) + + let pump = await store.entries(in: Self.window, managerIdentifier: "com.example.pump") + #expect(pump.map(\.message) == ["first", "third"]) + #expect(pump.allSatisfy { $0.deviceIdentifier == "device-1" && $0.type == .send }) + + let cgm = await store.entries(in: Self.window, managerIdentifier: "com.example.cgm") + #expect(cgm.map(\.message) == ["second"]) + } + + @Test("Buffered entries drain once the store opens") func drainsBuffer() async throws { + let (directory, file) = try Self.makeStorageURL() + defer { try? FileManager.default.removeItem(at: directory) } + let preflight = Preflight(succeeds: false) + let center = Foundation.NotificationCenter() + let store = DeviceLogStore( + storageURL: file, + notificationCenter: center, + preflight: preflight.run, + isProtectedDataAvailable: { true } + ) + + #expect(await Self.waitUntil { preflight.callCount == 3 }) + #expect(await Self.log(store, manager: "com.example.pump", message: "buffered-1") == nil) + #expect(await Self.log(store, manager: "com.example.cgm", message: "buffered-2") == nil) + #expect(await store.entries(in: Self.window).map(\.message) == ["buffered-1", "buffered-2"]) + #expect(await store.entries(in: Self.window, managerIdentifier: "com.example.cgm").map(\.message) == ["buffered-2"]) + + preflight.setSucceeds(true) + center.post(name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + + #expect(await Self.waitUntil { + await store.entries(in: Self.window).filter { $0.message.hasPrefix("[queued ") }.count == 2 + }) + let entries = await store.entries(in: Self.window) + #expect(entries.count == 2) + let pattern = try NSRegularExpression(pattern: #"^\[queued \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] buffered-(\d)$"#) + let suffixes = entries.compactMap { entry -> String? in + let range = NSRange(entry.message.startIndex..., in: entry.message) + guard let match = pattern.firstMatch(in: entry.message, range: range), + let suffix = Range(match.range(at: 1), in: entry.message) + else { return nil } + return String(entry.message[suffix]) + } + #expect(suffixes == ["1", "2"]) + } + + @Test("Store is left in place while protected data is unavailable") func keepsStoreWhileLocked() async throws { + let (directory, file) = try Self.makeStorageURL() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("existing".utf8).write(to: file) + let preflight = Preflight(succeeds: false) + let store = DeviceLogStore( + storageURL: file, + notificationCenter: Foundation.NotificationCenter(), + preflight: preflight.run, + isProtectedDataAvailable: { false } + ) + + #expect(await Self.log(store, manager: "com.example.pump", message: "locked") == nil) + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(preflight.callCount == 1) + #expect(FileManager.default.fileExists(atPath: file.path)) + } + + @Test("Expired entries across several days are purged while logging continues") func purgesByDay() async throws { + let (directory, file) = try Self.makeStorageURL() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + let maxAge: TimeInterval = 3 * 24 * 60 * 60 + let now = Date() + let cutoff = now.addingTimeInterval(-maxAge) + let dates = stride(from: 10 * 24.0, to: 0, by: -2).map { now.addingTimeInterval(-$0 * 60 * 60 - 120) } + + [cutoff.addingTimeInterval(-300), cutoff.addingTimeInterval(300)] + let seeded = dates.map { + StoredDeviceLogEntry( + type: .receive, + managerIdentifier: "com.example.pump", + deviceIdentifier: "device-1", + message: "seeded", + timestamp: $0 + ) + } + #expect(PersistentDeviceLog(storageFile: file).addStoredDeviceLogEntries(entries: seeded) == nil) + + let store = DeviceLogStore( + storageURL: file, + maxAge: maxAge, + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + for index in 0 ..< 50 { + store.log(managerIdentifier: "com.example.cgm", deviceIdentifier: nil, type: .send, message: "during \(index)") + } + + let entries = await store.entries(in: DateInterval(start: .distantPast, end: now.addingTimeInterval(60))) + let kept = entries.filter { $0.message == "seeded" }.map(\.timestamp) + #expect(kept.allSatisfy { $0 >= cutoff }) + #expect(kept.count == dates.filter { $0 >= cutoff }.count) + #expect(kept.contains(cutoff.addingTimeInterval(300))) + #expect(entries.filter { $0.managerIdentifier == "com.example.cgm" }.map(\.message) == (0 ..< 50).map { "during \($0)" }) + } + + @Test("Moved-aside stores older than 7 days are deleted on open") func removesOldUnreadableStores() async throws { + let (directory, file) = try Self.makeStorageURL() + defer { try? FileManager.default.removeItem(at: directory) } + let logs = file.deletingLastPathComponent() + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + let now = Int(Date().timeIntervalSince1970) + let old = now - 8 * 24 * 60 * 60 + let expired = ["DeviceLog.sqlite.unreadable-\(old)", "DeviceLog.sqlite-shm.unreadable-\(old)"] + let kept = ["DeviceLog.sqlite.unreadable-\(now - 60)", "AppLog.sqlite.unreadable-\(old)"] + for name in expired + kept { + FileManager.default.createFile(atPath: logs.appendingPathComponent(name).path, contents: Data([1])) + } + let store = DeviceLogStore( + storageURL: file, + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + _ = await store.entries(in: Self.window) + let remaining = try FileManager.default.contentsOfDirectory(atPath: logs.path).filter { $0.contains(".unreadable-") } + #expect(Set(remaining) == Set(kept)) + } +} diff --git a/TrioTests/Logging/LogExportTests.swift b/TrioTests/Logging/LogExportTests.swift new file mode 100644 index 0000000000..6bc777d596 --- /dev/null +++ b/TrioTests/Logging/LogExportTests.swift @@ -0,0 +1,311 @@ +import CoreData +import Foundation +import LoopKit +import os +import Testing + +@testable import Trio + +@Suite("Log Export Tests", .serialized) struct LogExportTests { + private class StubManager: DeviceManager { + class var pluginIdentifier: String { "com.example.pump" } + var localizedTitle: String { "Example Pump" } + var debugDescription: String { "## StubManager\nserial: SYN-PUMP-0042" } + var rawState: RawStateValue { [:] } + var isOnboarded: Bool { true } + + init() {} + required init?(rawState _: RawStateValue) {} + + func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func getSoundBaseURL() -> URL? { nil } + func getSounds() -> [Alert.Sound] { [] } + func initializationComplete(for _: [Pluggable]) {} + } + + private final class StubCGMManager: StubManager { + override class var pluginIdentifier: String { "com.example.cgm" } + override var localizedTitle: String { "Example CGM" } + override var debugDescription: String { "## StubCGMManager\nstate: synthetic" } + } + + private struct Fixture { + let directory: URL + let export: LogExport + } + + private static func makeFixture() async throws -> Fixture { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let store = DeviceLogStore( + storageURL: directory.appendingPathComponent("logs/DeviceLog.sqlite"), + isProtectedDataAvailable: { true } + ) + await log(store, manager: "com.example.pump", device: "SYN-PUMP-0042", message: "connected to SYN-PUMP-0042") + await log(store, manager: "com.example.cgm", device: nil, message: "glucose 123") + await log(store, manager: "com.example.other", device: nil, message: "orphan entry") + + let appLog = InMemoryLogSink() + appLog.accept(record("upload to https://example-site.test/api/v1/entries", level: .info)) + appLog.accept(record("loop failed", level: .error)) + var stored = SensitiveValues() + stored.addNightscout(url: "https://example-site.test", secret: nil) + return Fixture( + directory: directory, + export: LogExport( + appLog: appLog, + deviceLogStore: store, + pump: StubManager(), + cgm: StubCGMManager(), + storedValues: stored + ) + ) + } + + private static func log(_ store: DeviceLogStore, manager: String, device: String?, message: String) async { + await withCheckedContinuation { continuation in + store.log(managerIdentifier: manager, deviceIdentifier: device, type: .send, message: message) { _ in + continuation.resume() + } + } + } + + private static func record(_ message: String, level: LogLevel, at date: Date = Date()) -> LogRecord { + LogRecord( + timestamp: date, + level: level, + category: "Service", + message: message, + file: "File.swift", + function: "run()", + line: 1, + launchID: UUID() + ) + } + + private static func contents(of kind: LogExport.Kind, in fixture: Fixture) async throws -> (name: String, text: String) { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = try await fixture.export.writeLog(kind, days: 1, into: directory) + return (url.lastPathComponent, try String(contentsOf: url, encoding: .utf8)) + } + + private static func logLines(_ text: String) -> [String] { + guard let range = text.range(of: "\n== Log ==\n") else { return [] } + return text[range.upperBound...].split(separator: "\n").map(String.init) + } + + private static func sections(_ text: String) -> [String] { + text.split(separator: "\n").filter { $0.hasPrefix("== ") } + .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "= ")) } + } + + @Test("Lines from all sources merge into one time-sorted list across slices") func mergesSources() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let window = DateInterval(start: start, duration: 18 * 60 * 60) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") + defer { try? FileManager.default.removeItem(at: url) } + let hour: TimeInterval = 60 * 60 + let lines = [ + LogLine(date: start.addingTimeInterval(7 * hour), source: "Pump", text: "b"), + LogLine(date: start.addingTimeInterval(1 * hour), source: "Trio", text: "a"), + LogLine(date: start.addingTimeInterval(13 * hour), source: "CGM", text: "d"), + LogLine(date: start.addingTimeInterval(7 * hour + 1), source: "Trio", text: "c"), + LogLine(date: start.addingTimeInterval(6 * hour), source: "Algorithm", text: "boundary"), + LogLine(date: window.end, source: "Trio", text: "end"), + LogLine(date: start.addingTimeInterval(-1), source: "Trio", text: "before") + ] + var requested: [DateInterval] = [] + + try await LogFileWriter.write(header: "Header\n", window: window, to: url, redactor: Redactor(exactValues: [])) { slice in + requested.append(slice) + // Every slice sees every line, as an inclusive store query would return the boundary twice + return Array(lines.reversed()) + } + + let written = try Self.logLines(String(contentsOf: url, encoding: .utf8)) + #expect(requested.count == 3) + #expect(written.map { $0.split(separator: " ").suffix(2).joined(separator: " ") } == [ + "Trio a", "Algorithm boundary", "Pump b", "Trio c", "CGM d", "Trio end" + ]) + #expect(written.first?.range( + of: #"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{4} Trio a$"#, + options: .regularExpression + ) != nil) + } + + @Test("Trio lines carry a level only for warnings and worse") func levelPrefix() { + let expected: [(LogLevel, String)] = [ + (.debug, "[Service] message"), + (.info, "[Service] message"), + (.notice, "[Service] message"), + (.warning, "WARNING: [Service] message"), + (.error, "ERROR: [Service] message"), + (.fault, "FAULT: [Service] message") + ] + for (level, text) in expected { + let line = LogExport.line(for: Self.record("message", level: level)) + #expect(line.source == "Trio") + #expect(line.text == text) + } + } + + @Test("Watch records are labelled Watch with their own category") func watchLines() { + let date = Date() + let warning = WatchLogEntry(seq: 1, date: date, level: .warning, category: .connectivity, message: "Log transfer failed") + let info = WatchLogEntry(seq: 2, date: date, level: .info, category: .treatments, message: "Override cancel requested") + + let warningLine = LogExport.line(for: WatchLogIngest.record(warning)) + let infoLine = LogExport.line(for: WatchLogIngest.record(info)) + + #expect(warningLine.source == "Watch") + #expect(warningLine.text == "WARNING: [Connectivity] Log transfer failed") + #expect(infoLine.source == "Watch") + #expect(infoLine.text == "[Treatments] Override cancel requested") + #expect(warningLine.date == date) + } + + @Test("Only the full log asks the watch for its newest entries") func requestsWatchLogs() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = DeviceLogStore( + storageURL: directory.appendingPathComponent("logs/DeviceLog.sqlite"), + isProtectedDataAvailable: { true } + ) + let appLog = InMemoryLogSink() + let requests = OSAllocatedUnfairLock(initialState: 0) + let export = LogExport(appLog: appLog, deviceLogStore: store, pump: StubManager(), cgm: nil, requestWatchLogs: { + requests.withLock { $0 += 1 } + let date = Date().addingTimeInterval(-60) + let entry = WatchLogEntry(seq: 1, date: date, level: .info, category: .connectivity, message: "Session activated") + appLog.accept(WatchLogIngest.record(entry)) + }) + let fixture = Fixture(directory: directory, export: export) + + let all = try await Self.logLines(Self.contents(of: .all, in: fixture).text) + #expect(requests.withLock { $0 } == 1) + #expect(all.contains { $0.hasSuffix(" Watch [Connectivity] Session activated") }) + + _ = try await Self.contents(of: .pump, in: fixture) + #expect(requests.withLock { $0 } == 1) + } + + @Test("Shared file is a zip named after the log") func sharesZip() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let url = try await fixture.export.makeFile(.pump, days: 1) + defer { LogExport.discard(url) } + #expect(url.lastPathComponent.hasPrefix("Trio Pump Logs ")) + #expect(url.pathExtension == "zip") + let data = try Data(contentsOf: url) + #expect(Array(data.prefix(4)) == [0x50, 0x4B, 0x03, 0x04]) + } + + @Test("Each file has the sections for its kind") func headerSections() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let all = try await Self.contents(of: .all, in: fixture) + let pump = try await Self.contents(of: .pump, in: fixture) + let cgm = try await Self.contents(of: .cgm, in: fixture) + + #expect(all.name.hasPrefix("Trio Logs ")) + #expect(pump.name.hasPrefix("Trio Pump Logs ")) + #expect(cgm.name.hasPrefix("Trio CGM Logs ")) + #expect([all.name, pump.name, cgm.name].allSatisfy { $0.hasSuffix(".txt") }) + #expect(Self.sections(all.text) == ["App", "Phone", "Pump", "CGM", "Settings", "Alerts", "Log"]) + #expect(Self.sections(pump.text) == ["App", "Phone", "Pump", "Log"]) + #expect(Self.sections(cgm.text) == ["App", "Phone", "CGM", "Log"]) + #expect(all.text.hasPrefix("Trio Logs\nCreated: ")) + #expect(all.text.contains("\nCovers: last 24 hours\n")) + #expect(pump.text.contains("Name: Example Pump\nPlugin: com.example.pump\n## StubManager\n")) + } + + @Test("Pump and CGM files hold only their own device lines") func deviceFilesFilterSources() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let pump = try await Self.logLines(Self.contents(of: .pump, in: fixture).text) + let cgm = try await Self.logLines(Self.contents(of: .cgm, in: fixture).text) + let all = try await Self.logLines(Self.contents(of: .all, in: fixture).text) + + #expect(pump.count == 1) + #expect(pump.allSatisfy { $0.contains(" Pump [send] ") }) + #expect(cgm.count == 1) + #expect(cgm.first?.hasSuffix(" CGM [send] glucose 123") == true) + #expect(all.count == 5) + #expect(all.contains { $0.hasSuffix(" com.example.other [send] orphan entry") }) + #expect(all.contains { $0.hasSuffix(" Trio ERROR: [Service] loop failed") }) + } + + @Test("Serials and URLs are replaced in every file") func redactsEveryLine() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + for kind in [LogExport.Kind.all, .pump, .cgm] { + let text = try await Self.contents(of: kind, in: fixture).text + #expect(!text.contains("SYN-PUMP-0042")) + #expect(!text.contains("example-site.test")) + } + let all = try await Self.contents(of: .all, in: fixture).text + #expect(all.contains("serial: ")) + #expect(all.contains("Pump [send] connected to ")) + #expect(all.contains("Trio [Service] upload to /api/v1/entries")) + } + + @Test("Determinations in the window become Algorithm lines") func algorithmLines() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let stack = try await CoreDataStack.createForTests() + let context = stack.newTaskContext() + let now = Date() + let reason = "Autosens ratio: 1, ISF: 55→55, COB: 0, Dev: 37, BGI: -4, CR: 12, Target: 95, minPredBG 125, " + + "minGuardBG 134, IOBpredBG 108, UAMpredBG 135; Eventual BG 135 >= 95, insulinReq 0.55; " + + "setting 30m low temp of 0.27U/h. Microbolusing 0.2U. " + let hour: TimeInterval = 60 * 60 + let determinations: [(offset: TimeInterval, enacted: Bool)] = [(-2 * hour, true), (-hour, false), (-48 * hour, true)] + try await context.perform { + for (offset, enacted) in determinations { + let determination = OrefDetermination(context: context) + determination.id = UUID() + determination.deliverAt = now.addingTimeInterval(offset) + determination.timestamp = now.addingTimeInterval(offset) + determination.enacted = enacted + determination.glucose = 187 + determination.iob = NSDecimalNumber(string: "2.179") + determination.cob = 0 + determination.rate = NSDecimalNumber(string: "0.27") + determination.duration = 30 + determination.smbToDeliver = enacted ? NSDecimalNumber(string: "0.2") : nil + determination.eventualBG = 135 + determination.reason = reason + } + try context.save() + } + let store = DeviceLogStore( + storageURL: directory.appendingPathComponent("logs/DeviceLog.sqlite"), + isProtectedDataAvailable: { true } + ) + let export = LogExport(appLog: InMemoryLogSink(), deviceLogStore: store, pump: nil, cgm: nil, coreDataStack: stack) + + let all = try await Self.logLines(String( + contentsOf: export.writeLog(.all, days: 1, into: directory.appendingPathComponent("all")), + encoding: .utf8 + )) + let pump = try await Self.logLines(String( + contentsOf: export.writeLog(.pump, days: 1, into: directory.appendingPathComponent("pump")), + encoding: .utf8 + )) + + let algorithm = all.filter { $0.dropFirst(30).hasPrefix("Algorithm ") } + #expect(algorithm.count == 2) + let enacted = "Algorithm enacted · BG 187 · IOB 2.18 · COB 0 · rate 0.27/30m · SMB 0.2 · eventual 135 · \(reason)" + let suggested = "Algorithm suggested · BG 187 · IOB 2.18 · COB 0 · rate 0.27/30m · eventual 135 · \(reason)" + #expect(algorithm.map { String($0.dropFirst(30)) } == [enacted, suggested]) + #expect(pump.isEmpty) + } +} diff --git a/TrioTests/Logging/LoggingLongRunStressTests.swift b/TrioTests/Logging/LoggingLongRunStressTests.swift new file mode 100644 index 0000000000..4c038f0c67 --- /dev/null +++ b/TrioTests/Logging/LoggingLongRunStressTests.swift @@ -0,0 +1,1356 @@ +import CoreData +import Darwin +import Foundation +import LoopKit +import Testing + +@testable import Trio + +@Suite( + "Logging Long-Run Stress Tests", + .serialized, + .enabled(if: ProcessInfo.processInfo.environment["TRIO_STRESS"] == "1") +) struct LoggingLongRunStressTests { + private typealias Fixtures = LoggingStressFixtures + + private class StubManager: DeviceManager { + class var pluginIdentifier: String { LoggingStressFixtures.pumpManager } + var localizedTitle: String { "Stress Pump" } + var debugDescription: String { "## StubManager\npumpSN: \(LoggingStressFixtures.pumpSerial)" } + var rawState: RawStateValue { [:] } + var isOnboarded: Bool { true } + + init() {} + required init?(rawState _: RawStateValue) {} + + func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func getSoundBaseURL() -> URL? { nil } + func getSounds() -> [Alert.Sound] { [] } + func initializationComplete(for _: [Pluggable]) {} + } + + private final class StubCGMManager: StubManager { + override class var pluginIdentifier: String { LoggingStressFixtures.cgmManager } + override var localizedTitle: String { "Stress CGM" } + override var debugDescription: String { "## StubCGMManager\ntransmitterID: \(LoggingStressFixtures.transmitterID)" } + } + + private struct Dataset { + let stack: CoreDataStack + let end: Date + } + + private struct Logs { + let appLog: AppLogStore + let deviceLog: DeviceLogStore + let appURL: URL + let deviceURL: URL + } + + private actor Install { + static let shared = Install() + + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("LoggingLongRunStress", isDirectory: true) + let end = Date() + private var dataset: Dataset? + private var logs: [String: Logs] = [:] + + func preparedDataset() async throws -> Dataset { + if let dataset { return dataset } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let stack = try await LoggingLongRunStressTests.buildDataset( + end: end, + storeURL: directory.appendingPathComponent("Trio.sqlite") + ) + let dataset = Dataset(stack: stack, end: end) + self.dataset = dataset + return dataset + } + + func preparedLogs(_ profile: StressProfile) async throws -> Logs { + if let prepared = logs[profile.name] { return prepared } + let prepared = try await LoggingLongRunStressTests.writeLogs( + profile, + end: end, + directory: directory.appendingPathComponent(profile.name, isDirectory: true) + ) + logs[profile.name] = prepared + return prepared + } + + func reset() { + dataset = nil + logs = [:] + try? FileManager.default.removeItem(at: directory) + } + } + + private static let clock = ContinuousClock() + private static let logDays = 7 + private static let firstLogDay = Fixtures.days - logDays + // The app deletes forecasts after 2 days and non-preset overrides after 3, at most once a day + private static let shortLivedDays = 3 + private static let cyclePeriod: TimeInterval = 2 + private static let installID = "stress-watch-install" + + private static let entities = [ + "GlucoseStored", "OrefDetermination", "Forecast", "ForecastValue", "PumpEventStored", "TempBasalStored", + "BolusStored", "CarbEntryStored", "OverrideStored", "OverrideRunStored", "TempTargetStored", + "TempTargetRunStored", "TDDStored", "LoopStatRecord", "OpenAPS_Battery" + ] + + // MARK: - 90-day Core Data install + + private struct Simulation { + var rng = StressRandom(seed: 0x90DA) + var noise = 0.0 + var previousGlucose = 120 + var iob = 0.0 + var lastTemp = Date.distantPast + var lastRate = 0.0 + var resumeAt: Date? + var tempTargetUntil = Date.distantPast + var meals: [(date: Date, carbs: Double)] = [] + var reservoir = 180.0 + var battery = 100.0 + } + + private static func decimal(_ value: Double) -> NSDecimalNumber { + NSDecimalNumber(value: Int((value * 100).rounded())).dividing(by: 100) + } + + private static func step(_ value: Double, _ increment: Double) -> Double { + (value / increment).rounded() * increment + } + + private static func hourOfDay(_ date: Date) -> Double { + date.timeIntervalSince1970.truncatingRemainder(dividingBy: Fixtures.day) / 3600 + } + + private static func scheduledBasal(at date: Date) -> Double { + switch hourOfDay(date) { + case ..<6: return 0.8 + case ..<10: return 1.1 + default: return 0.9 + } + } + + private static func buildDataset(end: Date, storeURL: URL) async throws -> CoreDataStack { + let stack = try await CoreDataStack.createForTests() + let context = stack.newTaskContext() + context.name = "stressDataset" + var simulation = Simulation() + var counts: [String: Int] = [:] + + let sampler = MemorySampler() + let start = clock.now + for day in 0 ..< Fixtures.days { + let added = try insertDay(day, end: end, simulation: &simulation, into: context) + counts.merge(added, uniquingKeysWith: +) + } + let insertDuration = clock.now - start + + let stored = try context.performAndWait { + try Dictionary(uniqueKeysWithValues: entities.map { entity in + (entity, try context.count(for: NSFetchRequest(entityName: entity))) + }) + } + + // The test stack lives in memory; moving it to a file gives a store size and real disk reads during export + let migrateStart = clock.now + let coordinator = stack.persistentContainer.persistentStoreCoordinator + let memoryStore = try #require(coordinator.persistentStores.first) + try? FileManager.default.removeItem(at: storeURL) + _ = try coordinator.migratePersistentStore(memoryStore, to: storeURL, options: memoryStore.options, type: .sqlite) + // The saved history token names the in-memory store, which no longer exists + UserDefaults.standard.lastHistoryToken = nil + let migrateDuration = clock.now - migrateStart + let peak = sampler.stop() + + let window = DateInterval(start: end.addingTimeInterval(-7 * Fixtures.day), end: end) + let fetchStart = clock.now + let lines = await LogExport.algorithmLines(in: window, from: stack) + let fetchDuration = clock.now - fetchStart + + Fixtures.report("dataset", [ + "days": "\(Fixtures.days)", + "rows": "\(stored.values.reduce(0, +))", + "perEntity": entities.map { "\($0):\(stored[$0] ?? 0)" }.joined(separator: ","), + "insertSeconds": String(format: "%.1f", Fixtures.seconds(insertDuration)), + "toFileSeconds": String(format: "%.1f", Fixtures.seconds(migrateDuration)), + "peakFootprint": Fixtures.megabytes(peak.footprint), + "sqlite+wal": Fixtures.megabytes(Fixtures.storeSize(storeURL)), + "algorithmLines7d": "\(lines.count)", + "algorithmLines7dSeconds": String(format: "%.2f", Fixtures.seconds(fetchDuration)) + ]) + for entity in entities { + #expect(stored[entity] == counts[entity] ?? 0, "\(entity)") + } + #expect(stored["GlucoseStored"] == Fixtures.days * Fixtures.cyclesPerDay) + #expect(stored["OrefDetermination"] == Fixtures.days * Fixtures.cyclesPerDay) + return stack + } + + private static func insertDay( + _ day: Int, + end: Date, + simulation: inout Simulation, + into context: NSManagedObjectContext + ) throws -> [String: Int] { + var sim = simulation + defer { simulation = sim } + let dayStart = Fixtures.dayStart(day, end: end) + let keepsShortLived = day >= Fixtures.days - shortLivedDays + var counts: [String: Int] = [:] + + // Offsets in seconds into the day, with fat and protein on main meals + var carbPlan: [(offset: TimeInterval, carbs: Double, fat: Double, protein: Double)] = [] + func plan(_ hour: Double, _ carbs: ClosedRange, meal: Bool) { + let offset = hour * 3600 + Double(Int.random(in: -2700 ... 2700, using: &sim.rng)) + let fat = meal ? Double(Int.random(in: 5 ... 40, using: &sim.rng)) : 0 + let protein = meal ? Double(Int.random(in: 5 ... 40, using: &sim.rng)) : 0 + carbPlan.append((offset, Double(Int.random(in: carbs, using: &sim.rng)), fat, protein)) + } + if !sim.rng.chance(0.15) { plan(7.5, 20 ... 60, meal: true) } + plan(12.5, 30 ... 80, meal: true) + plan(18.5, 30 ... 90, meal: true) + for _ in 0 ..< Int.random(in: 0 ... 3, using: &sim.rng) { + plan(Double(Int.random(in: 9 ... 22, using: &sim.rng)), 10 ... 30, meal: false) + } + let carbCycles = Dictionary(grouping: carbPlan) { Int(max(0, $0.offset) / Fixtures.cycle) } + + let tempTargetCycle = sim.rng.chance(0.3) ? Int.random(in: 60 ..< 260, using: &sim.rng) : nil + let overrideCycle = keepsShortLived && sim.rng.chance(0.6) ? Int.random(in: 60 ..< 260, using: &sim.rng) : nil + + try context.performAndWait { + func count(_ entity: String, _ amount: Int = 1) { + counts[entity, default: 0] += amount + } + + func pumpEvent(_ type: PumpEventStored.EventType, at date: Date) -> PumpEventStored { + let event = PumpEventStored(context: context) + event.id = UUID().uuidString + event.syncIdentifier = sim.rng.hex(bytes: 16) + event.timestamp = date + event.type = type.rawValue + event.isMutable = false + event.isUploadedToNS = true + event.isUploadedToHealth = true + event.isUploadedToTidepool = true + count("PumpEventStored") + return event + } + + func bolus(_ amount: Double, at date: Date, smb: Bool, external: Bool = false) { + let event = pumpEvent(.bolus, at: date) + let bolus = BolusStored(context: context) + bolus.amount = decimal(amount) + bolus.programmedAmount = decimal(amount) + bolus.isSMB = smb + bolus.isExternal = external + bolus.pumpEvent = event + count("BolusStored") + sim.iob += amount + } + + if day == 0 { + for (index, name) in ["Exercise", "Sick", "Sleep", "Pre-meal"].enumerated() { + let preset = OverrideStored(context: context) + preset.id = UUID().uuidString + preset.name = name + preset.date = dayStart + preset.isPreset = true + preset.percentage = [80, 120, 90, 100][index] + preset.duration = 60 + preset.orderPosition = Int16(index) + count("OverrideStored") + } + for (index, target) in [140, 160, 80].enumerated() { + let preset = TempTargetStored(context: context) + preset.id = UUID() + preset.name = ["Activity", "Eating soon", "Hypo"][index] + preset.date = dayStart + preset.isPreset = true + preset.target = NSDecimalNumber(value: target) + preset.duration = 60 + preset.orderPosition = Int16(index) + count("TempTargetStored") + } + } + + for cycleIndex in 0 ..< Fixtures.cyclesPerDay { + let date = dayStart.addingTimeInterval(Double(cycleIndex) * Fixtures.cycle) + + for entry in carbCycles[cycleIndex] ?? [] { + let entryDate = dayStart.addingTimeInterval(max(0, entry.offset)) + let carbs = CarbEntryStored(context: context) + carbs.id = UUID() + carbs.date = entryDate + carbs.carbs = entry.carbs + carbs.fat = entry.fat + carbs.protein = entry.protein + carbs.isUploadedToNS = true + carbs.isUploadedToHealth = true + carbs.isUploadedToTidepool = true + count("CarbEntryStored") + sim.meals.append((entryDate, entry.carbs)) + if entry.fat + entry.protein >= 30 { + let fpuID = UUID() + carbs.fpuID = fpuID + let parts = Int.random(in: 3 ... 5, using: &sim.rng) + let equivalent = (entry.fat * 9 + entry.protein * 4) / 10 + for part in 1 ... parts { + let fpu = CarbEntryStored(context: context) + fpu.id = UUID() + fpu.fpuID = fpuID + fpu.isFPU = true + fpu.date = entryDate.addingTimeInterval(Double(part) * 3600) + fpu.carbs = (equivalent / Double(parts)).rounded() + count("CarbEntryStored") + } + } + bolus(step(entry.carbs / 12 * 0.8, 0.05), at: entryDate.addingTimeInterval(20), smb: false) + } + + if cycleIndex == tempTargetCycle { + let target = TempTargetStored(context: context) + target.id = UUID() + target.name = "Activity" + target.date = date + target.target = 140 + target.duration = 60 + target.enabled = false + target.isUploadedToNS = true + let run = TempTargetRunStored(context: context) + run.id = UUID() + run.name = "Activity" + run.startDate = date + run.endDate = date.addingTimeInterval(3600) + run.target = 140 + run.tempTarget = target + count("TempTargetStored") + count("TempTargetRunStored") + sim.tempTargetUntil = date.addingTimeInterval(3600) + } + + if cycleIndex == overrideCycle { + let override = OverrideStored(context: context) + override.id = UUID().uuidString + override.name = "Exercise" + override.date = date + override.percentage = 80 + override.duration = 90 + override.enabled = false + override.isUploadedToNS = true + let run = OverrideRunStored(context: context) + run.id = UUID() + run.name = "Exercise" + run.startDate = date + run.endDate = date.addingTimeInterval(5400) + run.override = override + count("OverrideStored") + count("OverrideRunStored") + } + + sim.meals.removeAll { date.timeIntervalSince($0.date) > 4 * 3600 } + var excursion = 0.0 + var cob = 0.0 + for meal in sim.meals { + let minutes = date.timeIntervalSince(meal.date) / 60 + guard minutes >= 0 else { continue } + excursion += meal.carbs * 1.6 * (minutes / 60) * exp(1 - minutes / 60) + cob += meal.carbs * max(0, 1 - minutes / 180) + } + let hour = hourOfDay(date) + let circadian = 12 * sin((hour - 4) / 24 * 2 * .pi) + (hour >= 3 && hour < 7 ? 15 : 0) + sim.noise = 0.92 * sim.noise + Double(Int.random(in: -5 ... 5, using: &sim.rng)) + let glucose = Int(min(400, max(40, 110 + circadian + excursion - sim.iob * 6 + sim.noise))) + let delta = glucose - sim.previousGlucose + sim.previousGlucose = glucose + + let reading = GlucoseStored(context: context) + reading.id = UUID() + reading.date = date + reading.glucose = Int16(glucose) + reading.smoothedGlucose = NSDecimalNumber(value: glucose) + reading.direction = switch delta { + case ..<(-10): "DoubleDown" + case ..<(-5): "SingleDown" + case ..<(-2): "FortyFiveDown" + case ...2: "Flat" + case ...5: "FortyFiveUp" + case ...10: "SingleUp" + default: "DoubleUp" + } + reading.isUploadedToNS = true + reading.isUploadedToHealth = true + reading.isUploadedToTidepool = true + count("GlucoseStored") + + if let resumeAt = sim.resumeAt, date >= resumeAt { + _ = pumpEvent(.pumpResume, at: date.addingTimeInterval(10)) + sim.resumeAt = nil + sim.lastTemp = .distantPast + } else if sim.resumeAt == nil, sim.rng.chance(1.0 / 600) { + _ = pumpEvent(.pumpSuspend, at: date.addingTimeInterval(10)) + sim.resumeAt = date.addingTimeInterval(Double(Int.random(in: 15 ... 60, using: &sim.rng)) * 60) + } + let suspended = sim.resumeAt != nil + + if day % 3 == 0, cycleIndex == 19 * 12 { + _ = pumpEvent(.rewind, at: date.addingTimeInterval(60)) + let prime = pumpEvent(.prime, at: date.addingTimeInterval(150)) + prime.note = "Site change" + sim.reservoir = 180 + } + if day % 7 == 2, cycleIndex == 150 { + bolus(2, at: date.addingTimeInterval(30), smb: false, external: true) + } + + let isf = 55 + let ratio = [0.9, 1, 1, 1, 1.1][Int.random(in: 0 ... 4, using: &sim.rng)] + let target = date < sim.tempTargetUntil ? 140 : 100 + let eventual = min(400, max(39, glucose + delta * 3 - Int(sim.iob * Double(isf) * 0.6) + Int(cob * 1.5))) + let minPred = max(39, min(glucose, eventual) - Int.random(in: 0 ... 20, using: &sim.rng)) + let insulinReq = max(0, Double(eventual - target) / Double(isf)) + let scheduled = scheduledBasal(at: date) + let wanted = eventual < target ? 0 : scheduled + insulinReq * 0.5 + let rate = suspended ? 0 : step(min(scheduled * 3, wanted), 0.05) + let smbAmount = step(min(insulinReq * 0.5, 1.5), 0.05) + let smb = !suspended && glucose > 110 && smbAmount >= 0.05 && sim.rng.chance(0.55) ? smbAmount : nil + let deviation = delta * 6 + Int.random(in: -10 ... 10, using: &sim.rng) + let bgi = -Int(sim.iob * 2) + let deliverAt = date.addingTimeInterval(3) + let enacted = !suspended && sim.rng.chance(0.95) + + let determination = OrefDetermination(context: context) + determination.id = UUID() + determination.deliverAt = deliverAt + determination.timestamp = deliverAt + determination.timestampEnacted = enacted ? deliverAt.addingTimeInterval(4) : nil + determination.enacted = enacted + determination.received = enacted + determination.isUploadedToNS = true + determination.glucose = NSDecimalNumber(value: glucose) + determination.iob = decimal(sim.iob) + determination.cob = Int16(cob.rounded()) + determination.rate = decimal(rate) + determination.tempBasal = decimal(rate) + determination.temp = "absolute" + determination.duration = 30 + determination.smbToDeliver = smb.map(decimal) + determination.eventualBG = NSDecimalNumber(value: eventual) + determination.insulinReq = decimal(insulinReq) + determination.insulinSensitivity = NSDecimalNumber(value: isf) + determination.sensitivityRatio = decimal(ratio) + determination.currentTarget = NSDecimalNumber(value: target) + determination.threshold = 68 + determination.expectedDelta = decimal(Double(bgi) * 0.8) + determination.minDelta = NSDecimalNumber(value: delta) + determination.carbRatio = 12 + determination.scheduledBasal = decimal(scheduled) + determination.reservoir = decimal(sim.reservoir) + let cobPart = cob > 0 ? ", COBpredBG \(eventual + 10)" : "" + let lowTemp = "Eventual BG \(eventual) < \(target), setting 30m low temp of \(rate)U/h. " + let highTemp = "Eventual BG \(eventual) >= \(target), insulinReq \(decimal(insulinReq)); " + + "setting 30m high temp of \(rate)U/h. " + let action = eventual < target ? lowTemp : highTemp + determination.reason = "Autosens ratio: \(ratio), ISF: \(isf)→\(Int(Double(isf) / ratio)), " + + "COB: \(Int(cob)), Dev: \(deviation), BGI: \(bgi), CR: 12, Target: \(target), minPredBG \(minPred), " + + "minGuardBG \(minPred + 9), IOBpredBG \(eventual - 27), UAMpredBG \(eventual)\(cobPart); " + + action + (smb.map { "Microbolusing \($0)U. " } ?? "") + count("OrefDetermination") + + if keepsShortLived { + var types = ["IOB", "UAM", "ZT"] + if cob > 0 { types.append("COB") } + for type in types { + let forecast = Forecast(context: context) + forecast.id = UUID() + forecast.date = deliverAt + forecast.type = type + forecast.orefDetermination = determination + count("Forecast") + let length = switch type { + case "ZT": Int.random(in: 7 ... 48, using: &sim.rng) + case "UAM": 47 + default: 48 + } + for position in 0 ..< length { + let point = ForecastValue(context: context) + point.index = Int32(position) + point.value = Int32(glucose + (eventual - glucose) * position / 47) + point.forecast = forecast + } + count("ForecastValue", length) + } + } + + if !suspended, enacted { + let sinceTemp = date.timeIntervalSince(sim.lastTemp) + if sinceTemp >= 30 * 60 || abs(rate - sim.lastRate) >= 0.1 && sinceTemp >= 5 * 60 && sim.rng.chance(0.6) { + let event = pumpEvent(.tempBasal, at: deliverAt.addingTimeInterval(4)) + let temp = TempBasalStored(context: context) + temp.rate = decimal(rate) + temp.duration = 30 + temp.startDate = deliverAt.addingTimeInterval(4) + temp.endDate = deliverAt.addingTimeInterval(4 + 30 * 60) + temp.tempType = "absolute" + temp.pumpEvent = event + count("TempBasalStored") + sim.lastTemp = date + sim.lastRate = rate + } + if let smb { + bolus(smb, at: deliverAt.addingTimeInterval(8), smb: true) + } + } + sim.iob = max(-1, sim.iob * 0.96 + (rate - scheduled) / 12) + sim.reservoir = max(0, sim.reservoir - scheduled / 12 - (smb ?? 0)) + + let tdd = TDDStored(context: context) + tdd.id = UUID() + tdd.date = deliverAt.addingTimeInterval(6) + tdd.total = decimal(38 + Double(Int.random(in: -40 ... 40, using: &sim.rng)) / 10) + tdd.bolus = decimal(18 + Double(Int.random(in: -30 ... 30, using: &sim.rng)) / 10) + tdd.tempBasal = decimal(14) + tdd.scheduledBasal = decimal(6) + tdd.weightedAverage = decimal(37.5) + count("TDDStored") + + let stat = LoopStatRecord(context: context) + stat.start = date + stat.end = deliverAt.addingTimeInterval(5) + stat.duration = 8 + stat.interval = Fixtures.cycle + stat.loopStatus = "Success" + count("LoopStatRecord") + + sim.battery = sim.battery <= 20 ? 100 : sim.battery - 0.01 + let battery = OpenAPS_Battery(context: context) + battery.id = UUID() + battery.date = date + battery.percent = sim.battery.rounded() + battery.voltage = decimal(1.4) + battery.status = "normal" + battery.display = true + count("OpenAPS_Battery") + } + try context.save() + context.reset() + } + return counts + } + + // MARK: - 7 days of logs + + private static func watchBatches(end: Date) -> [[WatchLogEntry]] { + var rng = StressRandom(seed: 0x3A7C) + let start = end.addingTimeInterval(-Double(logDays) * Fixtures.day) + var batches: [[WatchLogEntry]] = [] + var batch: [WatchLogEntry] = [] + var seq: UInt64 = 0 + for cycleIndex in 0 ..< logDays * Fixtures.cyclesPerDay { + let date = start.addingTimeInterval(Double(cycleIndex) * Fixtures.cycle + 45) + func add( + _ offset: TimeInterval, + _ level: WatchLogEntry.Level, + _ category: WatchLogEntry.Category, + _ message: String + ) { + seq += 1 + batch + .append(WatchLogEntry( + seq: seq, + date: date.addingTimeInterval(offset), + level: level, + category: category, + message: message + )) + } + add(0, .debug, .data, "#w\(seq) Received state, BG \(Int.random(in: 60 ... 250, using: &rng)) mg/dL, IOB 1.2 U") + add(0.2, .debug, .data, "#w\(seq) Complication updated") + if rng.chance(0.3) { add(1, .info, .connectivity, "#w\(seq) Session reachable") } + if rng.chance(0.01) { add(2, .warning, .connectivity, "#w\(seq) Phone not reachable, will retry") } + if rng.chance(0.005) { add(3, .info, .bolus, "#w\(seq) Bolus of 1.5 U requested") } + if rng.chance(0.004) { add(3, .info, .treatments, "#w\(seq) Carbs 20 g sent") } + if rng.chance(0.002) { add(4, .info, .lifecycle, "#w\(seq) App became active") } + // The watch sends its ring about every 30 minutes + if cycleIndex % 6 == 5 { + batches.append(batch) + batch = [] + } + } + if !batch.isEmpty { batches.append(batch) } + return batches + } + + private static func writeLogs(_ profile: StressProfile, end: Date, directory: URL) async throws -> Logs { + let appURL = directory.appendingPathComponent("logs/AppLog.sqlite") + let deviceURL = directory.appendingPathComponent("logs/DeviceLog.sqlite") + try? FileManager.default.removeItem(at: directory) + try FileManager.default.createDirectory(at: appURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let appLog = AppLogStore( + storageURL: appURL, + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + await appLog.flush() + let sampler = MemorySampler() + var appRecords = 0 + var appElapsed = Duration.zero + for day in firstLogDay ..< Fixtures.days { + let records = Fixtures.appRecords(day: day, end: end, profile: profile) + let start = clock.now + for chunk in stride(from: 0, to: records.count, by: 2000) { + records[chunk ..< min(chunk + 2000, records.count)].forEach(appLog.accept) + await appLog.flush() + } + appElapsed += clock.now - start + appRecords += records.count + } + + let suiteName = "LoggingLongRunStress-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let ingest = WatchLogIngest(defaults: defaults, sink: { appLog }) + let staging = directory.appendingPathComponent("watch", isDirectory: true) + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + let batches = watchBatches(end: end) + var watchRecords = 0 + let watchStart = clock.now + for (index, entries) in batches.enumerated() { + let file = staging.appendingPathComponent("received-\(index).jsonl") + try WatchLogEntry.jsonLines(entries).write(to: file) + let batch = try #require(WatchLogIngest.stage(file, metadata: [ + WatchLogTransfer.marker: NSNumber(value: WatchLogTransfer.version), + WatchLogTransfer.installID: installID, + WatchLogTransfer.firstSeq: NSNumber(value: entries.first?.seq ?? 0), + WatchLogTransfer.lastSeq: NSNumber(value: entries.last?.seq ?? 0) + ], into: staging)) + try? FileManager.default.removeItem(at: file) + let ack = await ingest.ingest(batch) + #expect(ack == entries.last?.seq) + watchRecords += entries.count + } + let watchDuration = clock.now - watchStart + + let container = try #require(Fixtures.loadDeviceLogContainer(deviceURL)) + let context = container.newBackgroundContext() + var counter: Int64 = 0 + var deviceEntries = 0 + let deviceStart = clock.now + for day in firstLogDay ..< Fixtures.days { + let entries = Fixtures.deviceEntries(day: day, end: end, profile: profile) + try Fixtures.insert(entries, into: context, counter: &counter) + deviceEntries += entries.count + } + let deviceDuration = clock.now - deviceStart + Fixtures.unload(container) + let deviceLog = DeviceLogStore(storageURL: deviceURL, isProtectedDataAvailable: { true }) + _ = await deviceLog.entries(in: DateInterval(start: end, duration: 1)) + let peak = sampler.stop() + + Fixtures.report("logs.\(profile.name)", [ + "appRecords": "\(appRecords)", + "appRowsPerSec": String(format: "%.0f", Double(appRecords) / Fixtures.seconds(appElapsed)), + "watchRecords": "\(watchRecords)", + "watchBatches": "\(batches.count)", + "watchIngestSeconds": String(format: "%.2f", Fixtures.seconds(watchDuration)), + "deviceEntries": "\(deviceEntries)", + "deviceInsertSeconds": String(format: "%.1f", Fixtures.seconds(deviceDuration)), + "appSqlite+wal": Fixtures.megabytes(Fixtures.storeSize(appURL)), + "deviceSqlite+wal": Fixtures.megabytes(Fixtures.storeSize(deviceURL)), + "peakFootprint": Fixtures.megabytes(peak.footprint) + ]) + let stored = rows(appURL, entity: "AppLogEntry", load: AppLogStore.loadContainer) + #expect(stored == appRecords + watchRecords) + return Logs(appLog: appLog, deviceLog: deviceLog, appURL: appURL, deviceURL: deviceURL) + } + + // MARK: - Helpers + + private static func rows( + _ url: URL, + entity: String, + load: (URL) -> NSPersistentContainer?, + predicate: NSPredicate? = nil + ) -> Int { + guard let container = load(url) else { return -1 } + defer { Fixtures.unload(container) } + let context = container.newBackgroundContext() + return context.performAndWait { + let request = NSFetchRequest(entityName: entity) + request.predicate = predicate + return (try? context.count(for: request)) ?? -1 + } + } + + private static func oldest(_ url: URL, entity: String, load: (URL) -> NSPersistentContainer?) -> Date? { + guard let container = load(url) else { return nil } + defer { Fixtures.unload(container) } + let context = container.newBackgroundContext() + return context.performAndWait { + let request = NSFetchRequest(entityName: entity) + request.resultType = .dictionaryResultType + request.propertiesToFetch = ["timestamp"] + request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)] + request.fetchLimit = 1 + return (try? context.fetch(request))?.first?["timestamp"] as? Date + } + } + + // Purges may run in steps between other requests, so watch the store itself + private static func waitForPurge( + _ url: URL, + entity: String, + cutoff: Date, + load: (URL) -> NSPersistentContainer? + ) async -> Bool { + let deadline = clock.now + .seconds(300) + while clock.now < deadline { + if let date = oldest(url, entity: entity, load: load), date >= cutoff.addingTimeInterval(-60) { return true } + try? await Task.sleep(for: .milliseconds(50)) + } + return false + } + + private static func determinations(since date: Date, in stack: CoreDataStack) -> Int { + let context = stack.newTaskContext() + return context.performAndWait { + let request = NSFetchRequest(entityName: "OrefDetermination") + request.predicate = NSPredicate(format: "deliverAt >= %@", date as NSDate) + return (try? context.count(for: request)) ?? -1 + } + } + + private static func expectedRows(_ source: String, since date: Date, logs: Logs, stack: CoreDataStack) -> Int { + let since = NSPredicate(format: "timestamp >= %@", date as NSDate) + let watch = NSPredicate(format: "category BEGINSWITH %@", WatchLogIngest.categoryPrefix) + switch source { + case "Trio": + let notWatch = NSCompoundPredicate(notPredicateWithSubpredicate: watch) + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [since, notWatch]) + return rows(logs.appURL, entity: "AppLogEntry", load: AppLogStore.loadContainer, predicate: predicate) + case "Watch": + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [since, watch]) + return rows(logs.appURL, entity: "AppLogEntry", load: AppLogStore.loadContainer, predicate: predicate) + case "Algorithm": + return determinations(since: date, in: stack) + default: + let manager = source == "Pump" ? Fixtures.pumpManager : Fixtures.cgmManager + let predicate = NSPredicate(format: "timestamp >= %@ AND managerIdentifier == %@", date as NSDate, manager) + return rows(logs.deviceURL, entity: "Entry", load: Fixtures.loadDeviceLogContainer, predicate: predicate) + } + } + + private struct Analysis { + var counts: [String: Int] = [:] + var unsorted = 0 + var leaks: [String] = [] + } + + private static func analyze(_ text: String) -> Analysis { + var analysis = Analysis() + analysis.leaks = Fixtures.planted.filter { text.contains($0) } + guard let range = text.range(of: "\n== Log ==\n") else { return analysis } + let formatter = LogFileWriter.timestampFormatter() + var previous: Substring = "" + var previousDate = Date.distantPast + for line in text[range.upperBound...].split(separator: "\n") { + guard line.count > 30, line.first?.isNumber == true else { continue } + let stamp = line.prefix(29) + if previous.suffix(5) == stamp.suffix(5) { + if stamp < previous { analysis.unsorted += 1 } + } else if let date = formatter.date(from: String(stamp)) { + if date < previousDate { analysis.unsorted += 1 } + previousDate = date + } + previous = stamp + let source = line.dropFirst(30).prefix { $0 != " " } + analysis.counts[String(source), default: 0] += 1 + } + return analysis + } + + private static func zip(_ file: URL) throws -> URL { + let folder = file.deletingLastPathComponent() + let archive = folder.deletingLastPathComponent().appendingPathComponent("\(folder.lastPathComponent).zip") + var coordinationError: NSError? + var copyError: Error? + NSFileCoordinator().coordinate(readingItemAt: folder, options: .forUploading, error: &coordinationError) { zipped in + do { + try FileManager.default.copyItem(at: zipped, to: archive) + } catch { + copyError = error + } + } + if let error = coordinationError ?? copyError { throw error } + return archive + } + + private static func makeExport(_ logs: Logs, stack: CoreDataStack) -> LogExport { + LogExport( + appLog: logs.appLog, + deviceLogStore: logs.deviceLog, + pump: StubManager(), + cgm: StubCGMManager(), + storedValues: Fixtures.storedValues, + coreDataStack: stack + ) + } + + private static func percentile(_ sorted: [Double], _ fraction: Double) -> Double { + guard !sorted.isEmpty else { return 0 } + return sorted[min(sorted.count - 1, Int(Double(sorted.count) * fraction))] + } + + private static func milliseconds(_ seconds: Double) -> String { + String(format: "%.1fms", seconds * 1000) + } + + private static func microseconds(_ seconds: Double) -> String { + String(format: "%.1fus", seconds * 1_000_000) + } + + // MARK: - Tests + + @Test("A 90-day install fills the app's Core Data store") func dataset() async throws { + await Install.shared.reset() + _ = try await Install.shared.preparedDataset() + } + + @Test( + "Exports from a 90-day install are sorted, redacted, complete and within budget", + arguments: [StressProfile.typical, .heavy] + ) func exports(profile: StressProfile) async throws { + let dataset = try await Install.shared.preparedDataset() + let logs = try await Install.shared.preparedLogs(profile) + let export = Self.makeExport(logs, stack: dataset.stack) + + for days in [1, 3, 7] { + for kind in [LogExport.Kind.all, .pump, .cgm] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let span = Double(days) * Fixtures.day + let sources: [String] = switch kind { + case .all: ["Trio", "Watch", "Pump", "CGM", "Algorithm"] + case .pump: ["Pump"] + case .cgm: ["CGM"] + } + let before = Date() + let upper = Dictionary(uniqueKeysWithValues: sources.map { + ($0, Self.expectedRows($0, since: before.addingTimeInterval(-span), logs: logs, stack: dataset.stack)) + }) + let sampler = MemorySampler() + let start = Self.clock.now + let url = try await export.writeLog(kind, days: days, into: directory.appendingPathComponent("log")) + let duration = Self.clock.now - start + let peak = sampler.stop() + let after = Date() + let zipStart = Self.clock.now + let archive = try Self.zip(url) + let zipDuration = Self.clock.now - zipStart + let textSize = Fixtures.fileSize(url) + let zipSize = Fixtures.fileSize(archive) + let analysis = Self.analyze(try String(contentsOf: url, encoding: .utf8)) + let name = "\(profile.name).\(kind)-\(days)d" + + Fixtures.report("longRunExport.\(name)", [ + "seconds": String(format: "%.2f", Fixtures.seconds(duration)), + "zipSeconds": String(format: "%.2f", Fixtures.seconds(zipDuration)), + "peakFootprint": Fixtures.megabytes(peak.footprint), + "footprintGrowth": Fixtures.megabytes(peak.footprint - min(peak.footprint, sampler.baselineFootprint)), + "txt": Fixtures.megabytes(textSize), + "zip": Fixtures.megabytes(zipSize), + "lines": "\(analysis.counts.values.reduce(0, +))", + "sources": analysis.counts.sorted { $0.key < $1.key }.map { "\($0.key):\($0.value)" }.joined(separator: ",") + ]) + + #expect(analysis.leaks.isEmpty, "\(name) leaks \(analysis.leaks)") + #expect(analysis.unsorted == 0, "\(name)") + #expect(Set(analysis.counts.keys) == Set(sources), "\(name)") + for source in sources { + let lower = Self.expectedRows( + source, + since: after.addingTimeInterval(-span), + logs: logs, + stack: dataset.stack + ) + let found = analysis.counts[source] ?? 0 + let upperBound = upper[source] ?? 0 + #expect(found >= lower && found <= upperBound, "\(name) \(source): \(found) not in \(lower)...\(upperBound)") + } + if kind == .all { + let expected = Self.determinations(since: after.addingTimeInterval(-span), in: dataset.stack) + #expect(analysis.counts["Algorithm"] == expected || analysis.counts["Algorithm"] == upper["Algorithm"]) + } + let limit: Double = switch (kind, days) { + case (.all, 1): 1.5 + case (.all, _): profile.name == StressProfile.heavy.name ? 8 : 5 + default: 2 + } + #expect(Fixtures.seconds(duration) <= limit, "\(name) took \(duration)") + } + } + await reportPhases(profile, logs: logs, stack: dataset.stack) + } + + private func reportPhases(_ profile: StressProfile, logs: Logs, stack: CoreDataStack) async { + let now = Date() + let window = DateInterval(start: now.addingTimeInterval(-7 * Fixtures.day), end: now) + let slices = LogFileWriter.slices(of: window) + var phases: [String: Duration] = [:] + func measure(_ phase: String, _ body: () async throws -> T) async rethrows -> T { + let start = Self.clock.now + defer { phases[phase, default: .zero] += Self.clock.now - start } + return try await body() + } + var values = Fixtures.storedValues + for slice in slices { + for entry in await measure("identifiers", { await logs.deviceLog.entries(in: slice) }) { + values.add(entry.deviceIdentifier, .device) + } + } + let redactor = Redactor(exactValues: values.values) + let formatter = LogFileWriter.timestampFormatter() + var bytes = 0 + for slice in slices { + var lines = await measure("appFetch") { await logs.appLog.records(in: slice, minimumLevel: .debug) } + .map(LogExport.line) + lines += await measure("deviceFetch") { + await logs.deviceLog.entries(in: slice).map { LogExport.line(for: $0, source: "Pump") } + } + lines += await measure("algorithm") { await LogExport.algorithmLines(in: slice, from: stack) } + let sorted = await measure("sort") { lines.sorted { $0.date < $1.date } } + let text = await measure("format") { sorted.map { LogFileWriter.format($0, formatter: formatter) }.joined() } + bytes += await measure("redact") { redactor.redact(text) }.utf8.count + } + let total = phases.values.reduce(Duration.zero, +) + Fixtures.report("longRunPhases.\(profile.name).all-7d", [ + "total": String(format: "%.2f", Fixtures.seconds(total)), + "bytes": Fixtures.megabytes(UInt64(bytes)), + "phases": phases.sorted { $0.value > $1.value } + .map { "\($0.key):" + String(format: "%.2f", Fixtures.seconds($0.value)) }.joined(separator: ",") + ]) + } + + private final class Stop: @unchecked Sendable { + private let lock = NSLock() + private var stopped = false + + var isStopped: Bool { lock.withLock { stopped } } + func stop() { lock.withLock { stopped = true } } + } + + private struct CycleStats { + var cycles = 0 + var lines = 0 + var logLatencies: [Double] = [] + var saves: [Double] = [] + var saveFailures = 0 + } + + // Each cycle saves glucose, a determination with forecasts and pump history, logs and talks to the pump + private static func runCycles( + tag: String, + minimum: Int, + until stop: Stop, + stack: CoreDataStack, + deviceLog: DeviceLogStore, + profile: StressProfile + ) async -> CycleStats { + let context = stack.newTaskContext() + context.name = "stressLoop" + return await withCheckedContinuation { continuation in + let thread = Thread { + var stats = CycleStats() + var rng = StressRandom(seed: 0x100F) + var timebase = mach_timebase_info_data_t() + mach_timebase_info(&timebase) + + func log(_ count: Int) { + for _ in 0 ..< count { + let message = "#\(tag).\(stats.lines) BG \(Int.random(in: 70 ... 220, using: &rng)) mg/dL, " + + "IOB 1.25 U, rate 0.85 U/h for 30 min" + let start = mach_absolute_time() + if stats.lines % 8 == 0 { + info(.loop, message) + } else { + debug(.algorithm, message) + } + let nanos = (mach_absolute_time() - start) * UInt64(timebase.numer) / UInt64(timebase.denom) + stats.logLatencies.append(Double(nanos) / 1E9) + stats.lines += 1 + } + } + + func save(_ body: () -> Void) { + let start = clock.now + context.performAndWait { + body() + do { + try context.save() + } catch { + stats.saveFailures += 1 + } + context.reset() + } + stats.saves.append(Fixtures.seconds(clock.now - start)) + } + + while stats.cycles < minimum || !stop.isStopped { + let cycleStart = Date() + let glucose = Int.random(in: 70 ... 220, using: &rng) + save { + let reading = GlucoseStored(context: context) + reading.id = UUID() + reading.date = cycleStart + reading.glucose = Int16(glucose) + reading.direction = "Flat" + } + log(profile.linesPerCycle / 3) + save { + let determination = OrefDetermination(context: context) + determination.id = UUID() + determination.deliverAt = Date() + determination.timestamp = determination.deliverAt + determination.glucose = NSDecimalNumber(value: glucose) + determination.reason = "Autosens ratio: 1, ISF: 55→55, COB: 0, Dev: 4, BGI: -2, CR: 12, Target: 100" + for type in ["IOB", "UAM", "ZT"] { + let forecast = Forecast(context: context) + forecast.id = UUID() + forecast.date = determination.deliverAt + forecast.type = type + forecast.orefDetermination = determination + for position in 0 ..< 48 { + let point = ForecastValue(context: context) + point.index = Int32(position) + point.value = Int32(glucose) + point.forecast = forecast + } + } + } + log(profile.linesPerCycle / 3) + for pair in 0 ..< profile.pumpPairsPerCycle * 2 { + deviceLog.log( + managerIdentifier: Fixtures.pumpManager, + deviceIdentifier: Fixtures.pumpDevice, + type: pair % 2 == 0 ? .send : .receive, + message: "#\(tag)-dev.\(stats.cycles).\(pair) \(rng.hex(bytes: 12))" + ) + } + save { + let event = PumpEventStored(context: context) + event.id = UUID().uuidString + event.timestamp = Date() + event.type = PumpEventStored.EventType.tempBasal.rawValue + let temp = TempBasalStored(context: context) + temp.rate = decimal(0.85) + temp.duration = 30 + temp.startDate = event.timestamp + temp.pumpEvent = event + let tdd = TDDStored(context: context) + tdd.id = UUID() + tdd.date = Date() + tdd.total = 38 + let stat = LoopStatRecord(context: context) + stat.start = cycleStart + stat.end = Date() + stat.loopStatus = "Success" + } + log(profile.linesPerCycle - 2 * (profile.linesPerCycle / 3)) + stats.cycles += 1 + let remaining = cyclePeriod - Date().timeIntervalSince(cycleStart) + if remaining > 0 { Thread.sleep(forTimeInterval: remaining) } + } + continuation.resume(returning: stats) + } + thread.qualityOfService = .userInitiated + thread.start() + } + } + + private static func describe(_ stats: CycleStats) -> KeyValuePairs { + let latencies = stats.logLatencies.sorted() + let saves = stats.saves.sorted() + return [ + "cycles": "\(stats.cycles)", + "logCalls": "\(stats.lines)", + "logP50": microseconds(percentile(latencies, 0.5)), + "logP99": microseconds(percentile(latencies, 0.99)), + "logMax": microseconds(latencies.last ?? 0), + "saves": "\(saves.count)", + "saveP50": milliseconds(percentile(saves, 0.5)), + "saveP99": milliseconds(percentile(saves, 0.99)), + "saveMax": milliseconds(saves.last ?? 0), + "saveFailures": "\(stats.saveFailures)" + ] + } + + @Test( + "A loop cycle keeps logging and saving while a 90-day install exports", + arguments: [StressProfile.typical, .heavy] + ) func exportDuringLoop(profile: StressProfile) async throws { + let dataset = try await Install.shared.preparedDataset() + let logs = try await Install.shared.preparedLogs(profile) + let export = Self.makeExport(logs, stack: dataset.stack) + let previous = TrioLogger.sink + TrioLogger.install(logs.appLog) + defer { TrioLogger.install(previous) } + + let idleTag = "loop-idle-\(profile.name)" + let idleStop = Stop() + idleStop.stop() + let idle = await Self.runCycles( + tag: idleTag, + minimum: 8, + until: idleStop, + stack: dataset.stack, + deviceLog: logs.deviceLog, + profile: profile + ) + + let busyTag = "loop-export-\(profile.name)" + let stop = Stop() + async let busy = Self.runCycles( + tag: busyTag, + minimum: 4, + until: stop, + stack: dataset.stack, + deviceLog: logs.deviceLog, + profile: profile + ) + var exportSeconds: [String] = [] + for kind in [LogExport.Kind.all, .pump, .cgm] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let start = Self.clock.now + _ = try await export.writeLog(kind, days: 7, into: directory) + exportSeconds.append("\(kind):" + String(format: "%.2f", Fixtures.seconds(Self.clock.now - start))) + } + stop.stop() + let during = await busy + await logs.appLog.flush() + + Fixtures.report("loopIdle.\(profile.name)", Self.describe(idle)) + Fixtures.report("loopDuringExport.\(profile.name)", Self.describe(during)) + let idlePersisted = Self.rows( + logs.appURL, + entity: "AppLogEntry", + load: AppLogStore.loadContainer, + predicate: NSPredicate(format: "message BEGINSWITH %@", "#\(idleTag).") + ) + let busyPersisted = Self.rows( + logs.appURL, + entity: "AppLogEntry", + load: AppLogStore.loadContainer, + predicate: NSPredicate(format: "message BEGINSWITH %@", "#\(busyTag).") + ) + Fixtures.report("loopExports.\(profile.name)", [ + "exports7d": exportSeconds.joined(separator: ","), + "persistedIdle": "\(idlePersisted)/\(idle.lines)", + "persistedDuringExport": "\(busyPersisted)/\(during.lines)" + ]) + + let latencies = during.logLatencies.sorted() + #expect(Self.percentile(latencies, 0.99) < 0.001) + #expect(idle.saveFailures == 0 && during.saveFailures == 0) + #expect((during.saves.max() ?? 0) < 1) + #expect(idlePersisted == idle.lines) + #expect(busyPersisted == during.lines) + } + + private static func insertAppRecords(_ records: [LogRecord], into context: NSManagedObjectContext) throws { + var iterator = records.makeIterator() + let request = NSBatchInsertRequest(entityName: "AppLogEntry", dictionaryHandler: { dictionary in + guard let record = iterator.next() else { return true } + dictionary.addEntries(from: [ + "timestamp": record.timestamp, + "level": record.level.rawValue, + "category": record.category, + "message": record.message, + "file": record.file, + "function": record.function, + "line": Int32(clamping: record.line), + "launchID": record.launchID + ]) + return false + }) + try context.performAndWait { + _ = try context.execute(request) + } + } + + @Test( + "Opening log stores with 7 fresh and 83 stale days purges to the retention age", + arguments: [StressProfile.typical, .heavy] + ) func launchPurge(profile: StressProfile) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "LoggingLongRunPurge-\(profile.name)", + isDirectory: true + ) + try? FileManager.default.removeItem(at: directory) + defer { try? FileManager.default.removeItem(at: directory) } + let appURL = directory.appendingPathComponent("logs/AppLog.sqlite") + let deviceURL = directory.appendingPathComponent("logs/DeviceLog.sqlite") + try FileManager.default.createDirectory(at: appURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let end = Date() + let cutoff = end.addingTimeInterval(-LogRetentionPolicy.default.maxAge) + + let buildStart = Self.clock.now + var appRows = 0 + var freshAppRows = 0 + let appContainer = try #require(AppLogStore.loadContainer(appURL)) + let appContext = appContainer.newBackgroundContext() + for day in 0 ..< Fixtures.days { + let records = Fixtures.appRecords(day: day, end: end, profile: profile) + try Self.insertAppRecords(records, into: appContext) + appRows += records.count + freshAppRows += records.filter { $0.timestamp >= cutoff }.count + } + Fixtures.unload(appContainer) + var deviceRows = 0 + let deviceContainer = try #require(Fixtures.loadDeviceLogContainer(deviceURL)) + let deviceContext = deviceContainer.newBackgroundContext() + var counter: Int64 = 0 + for day in 0 ..< Fixtures.days { + let entries = Fixtures.deviceEntries(day: day, end: end, profile: profile) + try Fixtures.insert(entries, into: deviceContext, counter: &counter) + deviceRows += entries.count + } + Fixtures.unload(deviceContainer) + let appSizeBefore = Fixtures.storeSize(appURL) + let deviceSizeBefore = Fixtures.storeSize(deviceURL) + Fixtures.report("stale.\(profile.name)", [ + "appRows": "\(appRows)", + "deviceRows": "\(deviceRows)", + "buildSeconds": String(format: "%.1f", Fixtures.seconds(Self.clock.now - buildStart)), + "appSqlite+wal": Fixtures.megabytes(appSizeBefore), + "deviceSqlite+wal": Fixtures.megabytes(deviceSizeBefore) + ]) + + var timebase = mach_timebase_info_data_t() + mach_timebase_info(&timebase) + func acceptLatencies(_ body: (Int) -> Void) -> [Double] { + (0 ..< 500).map { index in + let start = mach_absolute_time() + body(index) + return Double((mach_absolute_time() - start) * UInt64(timebase.numer) / UInt64(timebase.denom)) / 1E9 + }.sorted() + } + + let appSampler = MemorySampler() + let appStart = Self.clock.now + let appLog = AppLogStore( + storageURL: appURL, + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + let appEarlyRequest = Task { + let requested = Self.clock.now + await appLog.flush() + _ = await appLog.records(in: DateInterval(start: end.addingTimeInterval(-3600), end: Date()), minimumLevel: .debug) + return Self.clock.now - requested + } + let appLatencies = acceptLatencies { index in + appLog.accept(LogRecord( + timestamp: Date(), + level: .debug, + category: "Loop", + message: "#launch.\(index) logged during purge", + file: "Stress.swift", + function: "launch()", + line: 1, + launchID: LaunchID.current + )) + } + let appEarlyWait = await appEarlyRequest.value + let appPurged = await Self.waitForPurge(appURL, entity: "AppLogEntry", cutoff: cutoff, load: AppLogStore.loadContainer) + await appLog.flush() + let appDuration = Self.clock.now - appStart + let appPeak = appSampler.stop() + let appRemaining = Self.rows(appURL, entity: "AppLogEntry", load: AppLogStore.loadContainer) + let appOldest = Self.oldest(appURL, entity: "AppLogEntry", load: AppLogStore.loadContainer) + Fixtures.report("launchPurge.app.\(profile.name)", [ + "rowsBefore": "\(appRows)", + "rowsAfter": "\(appRemaining)", + "seconds": String(format: "%.2f", Fixtures.seconds(appDuration)), + "earlyRequestWait": String(format: "%.2f", Fixtures.seconds(appEarlyWait)), + "peakFootprint": Fixtures.megabytes(appPeak.footprint), + "footprintGrowth": Fixtures.megabytes(appPeak.footprint - min(appPeak.footprint, appSampler.baselineFootprint)), + "acceptP99": Self.microseconds(Self.percentile(appLatencies, 0.99)), + "acceptMax": Self.microseconds(appLatencies.last ?? 0), + "sqlite+walBefore": Fixtures.megabytes(appSizeBefore), + "sqlite+walAfter": Fixtures.megabytes(Fixtures.storeSize(appURL)) + ]) + #expect(appPurged) + #expect((appOldest ?? .distantPast) >= cutoff.addingTimeInterval(-60)) + #expect(appRemaining >= min(freshAppRows, LogRetentionPolicy.default.maxRows) - 2000) + #expect(appRemaining <= LogRetentionPolicy.default.maxRows + 500) + #expect(Self.percentile(appLatencies, 0.99) < 0.001) + + let deviceSampler = MemorySampler() + let deviceStart = Self.clock.now + let deviceLog = DeviceLogStore(storageURL: deviceURL, isProtectedDataAvailable: { true }) + let deviceEarlyRequest = Task { + let requested = Self.clock.now + _ = await deviceLog.entries(in: DateInterval(start: end.addingTimeInterval(-3600), end: Date())) + return Self.clock.now - requested + } + let deviceLatencies = acceptLatencies { index in + deviceLog.log( + managerIdentifier: Fixtures.pumpManager, + deviceIdentifier: Fixtures.pumpDevice, + type: .send, + message: "#launch-dev.\(index) logged during purge" + ) + } + let deviceEarlyWait = await deviceEarlyRequest.value + let devicePurged = await Self.waitForPurge( + deviceURL, + entity: "Entry", + cutoff: cutoff, + load: Fixtures.loadDeviceLogContainer + ) + _ = await deviceLog.entries(in: DateInterval(start: end, duration: 1)) + let deviceDuration = Self.clock.now - deviceStart + let devicePeak = deviceSampler.stop() + let deviceRemaining = Self.rows(deviceURL, entity: "Entry", load: Fixtures.loadDeviceLogContainer) + let deviceOldest = Self.oldest(deviceURL, entity: "Entry", load: Fixtures.loadDeviceLogContainer) + let retired = Self.rows( + deviceURL, + entity: "Entry", + load: Fixtures.loadDeviceLogContainer, + predicate: NSPredicate(format: "managerIdentifier == %@", Fixtures.retiredManager) + ) + Fixtures.report("launchPurge.device.\(profile.name)", [ + "rowsBefore": "\(deviceRows)", + "rowsAfter": "\(deviceRemaining)", + "seconds": String(format: "%.2f", Fixtures.seconds(deviceDuration)), + "earlyRequestWait": String(format: "%.2f", Fixtures.seconds(deviceEarlyWait)), + "peakFootprint": Fixtures.megabytes(devicePeak.footprint), + "footprintGrowth": Fixtures + .megabytes(devicePeak.footprint - min(devicePeak.footprint, deviceSampler.baselineFootprint)), + "logCallP99": Self.microseconds(Self.percentile(deviceLatencies, 0.99)), + "logCallMax": Self.microseconds(deviceLatencies.last ?? 0), + "sqlite+walBefore": Fixtures.megabytes(deviceSizeBefore), + "sqlite+walAfter": Fixtures.megabytes(Fixtures.storeSize(deviceURL)) + ]) + #expect(devicePurged) + #expect((deviceOldest ?? .distantPast) >= cutoff.addingTimeInterval(-60)) + #expect(retired == 0) + #expect(Self.percentile(deviceLatencies, 0.99) < 0.001) + } + + @Test("Clean up long-run stores") func cleanUp() async { + await Install.shared.reset() + } +} diff --git a/TrioTests/Logging/LoggingRobustnessStressTests.swift b/TrioTests/Logging/LoggingRobustnessStressTests.swift new file mode 100644 index 0000000000..86f8e57ff1 --- /dev/null +++ b/TrioTests/Logging/LoggingRobustnessStressTests.swift @@ -0,0 +1,1363 @@ +import CoreData +import CryptoKit +import Foundation +import LoopKit +import Testing +import UIKit + +@testable import Trio + +private extension Duration { + var seconds: Double { + Double(components.seconds) + Double(components.attoseconds) / 1E18 + } +} + +@Suite( + "Logging Robustness Stress Tests", + .serialized, + .enabled(if: ProcessInfo.processInfo.environment["TRIO_STRESS"] == "1") +) struct LoggingRobustnessStressTests { + enum Corruption: String, CaseIterable, Sendable { + case garbage + case truncated + case truncatedHeader + case directory + case readOnlyFile + case readOnlyDirectory + } + + private final class Box: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + init(_ value: Value) { self.value = value } + + func get() -> Value { lock.withLock { value } } + func set(_ newValue: Value) { lock.withLock { value = newValue } } + @discardableResult func update(_ body: (inout Value) -> T) -> T { lock.withLock { body(&value) } } + } + + private final class Loader: @unchecked Sendable { + let calls = Box(0) + let successes = Box(0) + private let fails: (Int) -> Bool + + init(fails: @escaping (Int) -> Bool = { _ in false }) { self.fails = fails } + + func run(_ url: URL) -> NSPersistentContainer? { + let index = calls.update { count -> Int in + count += 1 + return count - 1 + } + guard !fails(index), let container = AppLogStore.loadContainer(url) else { return nil } + successes.update { $0 += 1 } + return container + } + } + + private final class Preflight: @unchecked Sendable { + let calls = Box(0) + let successes = Box(0) + private let fails: (Int) -> Bool + + init(fails: @escaping (Int) -> Bool = { _ in false }) { self.fails = fails } + + func run(_ url: URL) -> Bool { + let index = calls.update { count -> Int in + count += 1 + return count - 1 + } + guard !fails(index), DeviceLogStore.preflight(url) else { return false } + successes.update { $0 += 1 } + return true + } + } + + private class StubManager: DeviceManager { + class var pluginIdentifier: String { "com.example.pump" } + var localizedTitle: String { "Example Pump" } + var debugDescription: String { "## StubManager\nserial: SYN-DEV-0042" } + var rawState: RawStateValue { [:] } + var isOnboarded: Bool { true } + + init() {} + required init?(rawState _: RawStateValue) {} + + func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func getSoundBaseURL() -> URL? { nil } + func getSounds() -> [Alert.Sound] { [] } + func initializationComplete(for _: [Pluggable]) {} + } + + private final class StubCGMManager: StubManager { + override class var pluginIdentifier: String { "com.example.cgm" } + override var localizedTitle: String { "Example CGM" } + } + + private static let everything = DateInterval(start: .distantPast, end: .distantFuture) + private static let hour: TimeInterval = 60 * 60 + private static let day: TimeInterval = 24 * 60 * 60 + + private static func makeDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private static func record(_ message: String, at date: Date, level: LogLevel = .info) -> LogRecord { + LogRecord( + timestamp: date, + level: level, + category: "Stress", + message: message, + file: "Stress.swift", + function: "run()", + line: 1, + launchID: LaunchID.current + ) + } + + private static func waitUntil(attempts: Int = 600, _ condition: () async -> Bool) async -> Bool { + for _ in 0 ..< attempts { + if await condition() { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return false + } + + @discardableResult private static func logDevice( + _ store: DeviceLogStore, + _ message: String, + manager: String = "com.example.pump", + device: String? = "SYN-DEV-0001" + ) async -> Error? { + await withCheckedContinuation { continuation in + store.log(managerIdentifier: manager, deviceIdentifier: device, type: .send, message: message) { + continuation.resume(returning: $0) + } + } + } + + private static func footprint() -> Int64 { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + return result == KERN_SUCCESS ? Int64(info.phys_footprint) : 0 + } + + private static func percentile(_ sorted: [UInt64], _ fraction: Double) -> UInt64 { + guard !sorted.isEmpty else { return 0 } + return sorted[min(sorted.count - 1, Int(Double(sorted.count) * fraction))] + } + + private static func movedAside(_ url: URL) -> [String] { + let names = (try? FileManager.default.contentsOfDirectory(atPath: url.deletingLastPathComponent().path)) ?? [] + return names.filter { $0.hasPrefix(url.lastPathComponent + ".unreadable-") } + } + + private static func populateStore( + at url: URL, + model name: String, + bundle: Bundle, + entity: String, + count: Int, + row: @escaping (Int) -> [String: Any] + ) throws { + guard let modelURL = bundle.url(forResource: name, withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) + else { throw CocoaError(.fileReadNoSuchFile) } + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model) + let store = try coordinator.addPersistentStore( + ofType: NSSQLiteStoreType, + configurationName: nil, + at: url, + options: [NSSQLitePragmasOption: ["journal_mode": "DELETE"]] + ) + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.persistentStoreCoordinator = coordinator + var index = 0 + let request = NSBatchInsertRequest(entityName: entity, dictionaryHandler: { dictionary in + guard index < count else { return true } + dictionary.addEntries(from: row(index)) + index += 1 + return false + }) + try context.performAndWait { _ = try context.execute(request) } + try coordinator.remove(store) + } + + private static func populateAppLog(at url: URL, count: Int, start: Date, step: TimeInterval = 1) throws { + try populateStore(at: url, model: "AppLog", bundle: .main, entity: "AppLogEntry", count: count) { index in + [ + "timestamp": start.addingTimeInterval(Double(index) * step), + "level": LogLevel.info.rawValue, + "category": "Stress", + "message": "seed \(index)", + "file": "Stress.swift", + "function": "run()", + "line": Int32(1), + "launchID": UUID() + ] + } + } + + private static func populateDeviceLog(at url: URL, count: Int, start: Date) throws { + try populateStore( + at: url, + model: "DeviceLog", + bundle: Bundle(for: PersistentDeviceLog.self), + entity: "Entry", + count: count + ) { index in + [ + "timestamp": start.addingTimeInterval(Double(index)), + "managerIdentifier": "com.example.pump", + "deviceIdentifier": "SYN-DEV-0001", + "type": DeviceLogEntryType.send.rawValue, + "message": "seed \(index)", + "modificationCounter": Int64(index + 1) + ] + } + } + + private static func corrupt(_ url: URL, _ corruption: Corruption, populate: (URL) throws -> Void) throws { + let fileManager = FileManager.default + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + switch corruption { + case .garbage: + try Data((0 ..< 65536).map { UInt8(truncatingIfNeeded: $0 &* 31 &+ 7) }).write(to: url) + case .truncated, + .truncatedHeader: + try populate(url) + let size = try fileManager.attributesOfItem(atPath: url.path)[.size] as? UInt64 ?? 0 + let handle = try FileHandle(forWritingTo: url) + try handle.truncate(atOffset: corruption == .truncated ? size / 2 : 50) + try handle.close() + case .directory: + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + try Data("not a store".utf8).write(to: url.appendingPathComponent("inside.txt")) + case .readOnlyFile: + try populate(url) + try fileManager.setAttributes([.posixPermissions: 0o444], ofItemAtPath: url.path) + case .readOnlyDirectory: + try populate(url) + try fileManager.setAttributes([.posixPermissions: 0o444], ofItemAtPath: url.deletingLastPathComponent().path) + } + } + + private static func restorePermissions(_ url: URL) { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.deletingLastPathComponent().path) + try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: url.path) + } + + private static func export( + _ kind: LogExport.Kind, + appLog: any LogSink, + deviceLogStore: DeviceLogStore + ) async throws -> String { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let export = LogExport(appLog: appLog, deviceLogStore: deviceLogStore, pump: nil, cgm: nil) + let url = try await export.writeLog(kind, days: 1, into: directory) + return try String(contentsOf: url, encoding: .utf8) + } + + private static func write( + _ lines: [LogLine], + window: DateInterval, + header: String = "Header", + redactor: Redactor = Redactor(exactValues: []) + ) async throws -> Data { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") + defer { try? FileManager.default.removeItem(at: url) } + try await LogFileWriter.write(header: header, window: window, to: url, redactor: redactor) { _ in lines } + return try Data(contentsOf: url) + } + + private static func body(_ text: String) -> String { + guard let range = text.range(of: "\n== Log ==\n") else { return "" } + return String(text[range.upperBound...]) + } + + private static func occurrences(of needle: String, in text: String) -> Int { + text.components(separatedBy: needle).count - 1 + } + + // MARK: Concurrency + + @Test("64 producers log through the free functions while flush, read and purge run") func concurrentProducers() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + var basePolicy = LogRetentionPolicy.default + basePolicy.maxRows = 5_000_000 + let store = AppLogStore( + storageURL: directory.appendingPathComponent("logs/AppLog.sqlite"), + policy: basePolicy, + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + await store.flush() + let previous = TrioLogger.sink + TrioLogger.install(store) + defer { TrioLogger.install(previous) } + + let token = "stress-\(UUID().uuidString.prefix(8))" + let producers = 64 + let perProducer = 20000 + let followUp = 50 + let start = Date() + let running = Box(true) + let chaos = Task { () -> (Int, Duration) in + var rounds = 0 + var slowest = Duration.zero + var policy = basePolicy + let clock = ContinuousClock() + while running.get() { + let round = await clock.measure { + await store.flush() + _ = await store.records( + in: DateInterval(start: Date().addingTimeInterval(-1), end: Date()), + minimumLevel: .warning + ) + } + slowest = max(slowest, round) + policy.maxAge = policy.maxAge == basePolicy.maxAge ? 6 * Self.day : basePolicy.maxAge + store.apply(policy) + rounds += 1 + } + return (rounds, slowest) + } + + let footprintBefore = Self.footprint() + let clock = ContinuousClock() + var latencies: [UInt64] = [] + let elapsed = await clock.measure { + latencies = await withTaskGroup(of: [UInt64].self) { group in + for producer in 0 ..< producers { + group.addTask { + var samples: [UInt64] = [] + samples.reserveCapacity(perProducer) + for index in 0 ..< perProducer { + let begin = DispatchTime.now().uptimeNanoseconds + info(.loop, "\(token) \(producer) \(index)") + samples.append(DispatchTime.now().uptimeNanoseconds - begin) + } + return samples + } + } + return await group.reduce(into: []) { $0 += $1 } + } + } + let footprintPeak = Self.footprint() + running.set(false) + let (rounds, slowestRound) = await chaos.value + + // Below the buffer cap nothing may be dropped, however flushes interleave + await withTaskGroup(of: Void.self) { group in + for producer in 0 ..< producers { + group.addTask { + for index in perProducer ..< perProducer + followUp { + info(.loop, "\(token) \(producer) \(index)") + } + } + } + } + await store.flush() + + // Same load without os_log isolates the sink's share of caller latency + var quiet = await withTaskGroup(of: [UInt64].self) { group in + for _ in 0 ..< producers { + group.addTask { + (0 ..< 5000).map { index -> UInt64 in + let begin = DispatchTime.now().uptimeNanoseconds + debug(.loop, "quiet \(index)", printToConsole: false) + return DispatchTime.now().uptimeNanoseconds - begin + } + } + } + return await group.reduce(into: []) { $0 += $1 } + } + quiet.sort() + print( + "STRESS: sink-only caller p50 \(Self.percentile(quiet, 0.5)) ns " + + "p99 \(Self.percentile(quiet, 0.99)) ns max \(quiet.last ?? 0) ns" + ) + await store.flush() + + let stored = await store.records(in: DateInterval(start: start, end: Date().addingTimeInterval(1)), minimumLevel: .debug) + .filter { $0.message.hasPrefix(token + " ") } + var seen = Set() + var lastIndex = [Int](repeating: -1, count: producers) + var lastDate = [Date](repeating: .distantPast, count: producers) + var duplicates = 0 + var outOfOrder = 0 + var followUpSeen = 0 + var inversions: [String] = [] + for record in stored { + let parts = record.message.split(separator: " ") + guard parts.count == 3, let producer = Int(parts[1]), let index = Int(parts[2]) else { continue } + if !seen.insert(producer * (perProducer + followUp) + index).inserted { duplicates += 1 } + if index <= lastIndex[producer] { + outOfOrder += 1 + if inversions.count < 5 { + let gap = record.timestamp.timeIntervalSince(lastDate[producer]) + inversions.append("p\(producer) \(lastIndex[producer])->\(index) gap \(gap)") + } + } + lastIndex[producer] = index + lastDate[producer] = record.timestamp + if index >= perProducer { followUpSeen += 1 } + } + let burstStored = seen.count - followUpSeen + let total = producers * perProducer + latencies.sort() + print( + "STRESS: concurrency \(producers)x\(perProducer) in \(elapsed), \(Int(Double(total) / elapsed.seconds)) calls/s, " + + "stored \(burstStored)/\(total) (dropped \(total - burstStored)), chaos rounds \(rounds) slowest \(slowestRound), " + + "caller p50 \(Self.percentile(latencies, 0.5)) ns p99 \(Self.percentile(latencies, 0.99)) ns " + + "p99.9 \(Self.percentile(latencies, 0.999)) ns max \(latencies.last ?? 0) ns, " + + "footprint +\((footprintPeak - footprintBefore) / 1_048_576) MB" + ) + print("STRESS: per-producer inversions \(outOfOrder) \(inversions)") + #expect(duplicates == 0) + #expect(outOfOrder == 0) + #expect(followUpSeen == producers * followUp) + #expect(burstStored > 0) + } + + // MARK: Burst while unavailable + + @Test("50 000 records while the app log store is locked keep the newest, in order") func appLogBurstWhileLocked() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let locked = Box(true) + let loader = Loader { _ in locked.get() } + let center = Foundation.NotificationCenter() + let store = AppLogStore( + storageURL: directory.appendingPathComponent("logs/AppLog.sqlite"), + notificationCenter: center, + loadContainer: loader.run, + isProtectedDataAvailable: { !locked.get() } + ) + await store.flush() + + let count = 50000 + let start = Date().addingTimeInterval(-Self.hour) + let padding = String(repeating: "x", count: 200) + let records = (0 ..< count).map { Self.record("burst \($0) \(padding)", at: start.addingTimeInterval(Double($0) / 100)) } + var expected = 0 + for _ in 0 ..< count { + expected += 1 + if expected > AppLogStore.bufferLimit { expected = AppLogStore.bufferLimit - AppLogStore.bufferLimit / 10 } + } + + let footprintBefore = Self.footprint() + let clock = ContinuousClock() + let acceptTime = clock.measure { records.forEach(store.accept) } + let buffered = await store.records(in: Self.everything, minimumLevel: .debug) + let footprintDelta = Self.footprint() - footprintBefore + + #expect(loader.successes.get() == 0) + #expect(buffered.count == expected) + #expect(buffered == Array(records.suffix(expected))) + #expect(footprintDelta < 64 * 1_048_576) + + locked.set(false) + let drainStart = clock.now + center.post(name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + #expect(await Self.waitUntil { loader.successes.get() == 1 }) + let drained = await store.records(in: Self.everything, minimumLevel: .debug) + let drainTime = clock.now - drainStart + #expect(drained == Array(records.suffix(expected))) + print( + "STRESS: app log burst \(count) accepted in \(acceptTime), buffered \(buffered.count), " + + "footprint +\(footprintDelta / 1024) KB, drained \(drained.count) in \(drainTime)" + ) + } + + @Test("50 000 device entries while locked keep the newest 5 000 with their queued time") func deviceLogBurstWhileLocked() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let locked = Box(true) + let preflight = Preflight { _ in locked.get() } + let center = Foundation.NotificationCenter() + let store = DeviceLogStore( + storageURL: directory.appendingPathComponent("logs/DeviceLog.sqlite"), + notificationCenter: center, + preflight: preflight.run, + isProtectedDataAvailable: { !locked.get() } + ) + + let count = 50000 + let before = Date() + let footprintBefore = Self.footprint() + let clock = ContinuousClock() + let bufferTime = await clock.measure { + for index in 0 ..< count - 1 { + store.log( + managerIdentifier: "com.example.pump", + deviceIdentifier: "SYN-DEV-0001", + type: .send, + message: "burst \(index)" + ) + } + await Self.logDevice(store, "burst \(count - 1)") + } + let footprintDelta = Self.footprint() - footprintBefore + let after = Date() + #expect(preflight.successes.get() == 0) + #expect(footprintDelta < 64 * 1_048_576) + + locked.set(false) + let drainStart = clock.now + center.post(name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + #expect(await Self.waitUntil(attempts: 2400) { + await store.entries(in: Self.everything).count >= DeviceLogStore.bufferLimit + }) + let drainTime = clock.now - drainStart + let entries = await store.entries(in: Self.everything) + + let pattern = try NSRegularExpression(pattern: #"^\[queued ([^\]]+)\] burst (\d+)$"#) + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var indexes: [Int] = [] + var queuedDates: [Date] = [] + for entry in entries { + let range = NSRange(entry.message.startIndex..., in: entry.message) + guard let match = pattern.firstMatch(in: entry.message, range: range), + let dateRange = Range(match.range(at: 1), in: entry.message), + let indexRange = Range(match.range(at: 2), in: entry.message), + let date = parser.date(from: String(entry.message[dateRange])), + let index = Int(entry.message[indexRange]) + else { continue } + indexes.append(index) + queuedDates.append(date) + } + #expect(entries.count == DeviceLogStore.bufferLimit) + #expect(indexes == Array(count - DeviceLogStore.bufferLimit ..< count)) + #expect(zip(queuedDates, queuedDates.dropFirst()).allSatisfy { $0 <= $1 }) + #expect(queuedDates.allSatisfy { $0 >= before.addingTimeInterval(-0.001) && $0 <= after.addingTimeInterval(0.001) }) + #expect(preflight.successes.get() == 1) + print( + "STRESS: device log burst \(count) buffered in \(bufferTime), footprint +\(footprintDelta / 1024) KB, " + + "drained \(entries.count) in \(drainTime)" + ) + } + + @Test("Device entries buffered while the store is unavailable reach the export") func bufferedDeviceEntriesExported() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = DeviceLogStore( + storageURL: directory.appendingPathComponent("logs/DeviceLog.sqlite"), + notificationCenter: Foundation.NotificationCenter(), + preflight: { _ in false }, + isProtectedDataAvailable: { false } + ) + for index in 0 ..< 20 { + await Self.logDevice(store, "pending \(index)") + } + let entries = await store.entries(in: Self.everything) + let text = try await Self.export(.all, appLog: InMemoryLogSink(), deviceLogStore: store) + + #expect(entries.count == 20) + #expect(Self.occurrences(of: "[send] pending ", in: Self.body(text)) == 20) + } + + // MARK: Corrupt stores + + @Test( + "Corrupt app log store is moved aside once and logging continues", + arguments: Corruption.allCases + ) func corruptAppLogStore(_ corruption: Corruption) async throws { + let directory = try Self.makeDirectory() + let url = directory.appendingPathComponent("logs/AppLog.sqlite") + defer { + Self.restorePermissions(url) + try? FileManager.default.removeItem(at: directory) + } + try Self + .corrupt(url, corruption) { + try Self.populateAppLog(at: $0, count: 5000, start: Date().addingTimeInterval(-Self.hour)) } + let loader = Loader() + let center = Foundation.NotificationCenter() + let store = AppLogStore( + storageURL: url, + notificationCenter: center, + loadContainer: loader.run, + isProtectedDataAvailable: { true } + ) + #expect(await Self.waitUntil { loader.successes.get() == 1 || loader.calls.get() >= 2 }) + + let token = "corrupt-\(corruption.rawValue)" + let start = Date().addingTimeInterval(-1) + let records = (0 ..< 20).map { Self.record("\(token) \($0)", at: start.addingTimeInterval(Double($0) / 1000)) } + records.forEach(store.accept) + for _ in 0 ..< 5 { + center.post(name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + } + await store.flush() + + let stored = await store.records(in: Self.everything, minimumLevel: .debug).filter { $0.message.hasPrefix(token) } + let deviceStore = DeviceLogStore( + storageURL: directory.appendingPathComponent("device/DeviceLog.sqlite"), + notificationCenter: center, + isProtectedDataAvailable: { true } + ) + let text = try await Self.export(.all, appLog: store, deviceLogStore: deviceStore) + let exported = Self.occurrences(of: "[Stress] \(token) ", in: Self.body(text)) + Self.restorePermissions(url) + let moved = Self.movedAside(url) + print( + "STRESS: corrupt app log \(corruption.rawValue): loads \(loader.calls.get()) ok \(loader.successes.get()), " + + "moved aside \(moved.count), stored \(stored.count)/20, exported \(exported)" + ) + + #expect(moved.count <= 1) + #expect(loader.successes.get() <= 1) + switch corruption { + case .directory, + .garbage, + .truncatedHeader: + #expect(moved.count == 1) + #expect(loader.successes.get() == 1) + case .readOnlyDirectory: + #expect(loader.successes.get() == 0) + case .truncated: + #expect(loader.successes.get() == 1) + case .readOnlyFile: + break + } + #expect(stored == records) + #expect(exported == 20) + } + + @Test("An app log store that rejects writes after opening keeps its records") func appLogStoreTurnsReadOnly() async throws { + let directory = try Self.makeDirectory() + let url = directory.appendingPathComponent("logs/AppLog.sqlite") + defer { + Self.restorePermissions(url) + try? FileManager.default.removeItem(at: directory) + } + try Self.corrupt(url, .readOnlyFile) { + try Self.populateAppLog(at: $0, count: 10, start: Date().addingTimeInterval(-Self.hour)) + } + let calls = Box(0) + let store = AppLogStore( + storageURL: url, + notificationCenter: Foundation.NotificationCenter(), + loadContainer: { url in + let call = calls.update { count -> Int in + count += 1 + return count - 1 + } + guard call == 0 else { return AppLogStore.loadContainer(url) } + guard let modelURL = Bundle.main.url(forResource: "AppLog", withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) + else { return nil } + let container = NSPersistentContainer(name: "AppLog", managedObjectModel: model) + let description = NSPersistentStoreDescription(url: url) + description.shouldAddStoreAsynchronously = false + container.persistentStoreDescriptions = [description] + container.loadPersistentStores { _, _ in } + return container + }, + isProtectedDataAvailable: { true } + ) + await store.flush() + + let start = Date().addingTimeInterval(-1) + let records = (0 ..< 20).map { Self.record("read-only \($0)", at: start.addingTimeInterval(Double($0) / 1000)) } + records.forEach(store.accept) + await store.flush() + func readBack() async -> [LogRecord] { + await store.records(in: Self.everything, minimumLevel: .debug).filter { $0.message.hasPrefix("read-only ") } + } + #expect(await Self.waitUntil { await readBack().count == records.count }) + let stored = await readBack() + Self.restorePermissions(url) + print( + "STRESS: app log turned read-only: loads \(calls.get()), moved aside \(Self.movedAside(url).count), stored \(stored.count)" + ) + #expect(stored == records) + #expect(Self.movedAside(url).count == 1) + } + + @Test( + "Corrupt device log store never reaches PersistentDeviceLog's fatalError", + arguments: Corruption.allCases + ) func corruptDeviceLogStore(_ corruption: Corruption) async throws { + let directory = try Self.makeDirectory() + let url = directory.appendingPathComponent("logs/DeviceLog.sqlite") + defer { + Self.restorePermissions(url) + try? FileManager.default.removeItem(at: directory) + } + try Self.corrupt(url, corruption) { + try Self.populateDeviceLog(at: $0, count: 5000, start: Date().addingTimeInterval(-Self.hour)) + } + let preflight = Preflight() + let center = Foundation.NotificationCenter() + let store = DeviceLogStore( + storageURL: url, + notificationCenter: center, + preflight: preflight.run, + isProtectedDataAvailable: { true } + ) + #expect(await Self.waitUntil { preflight.successes.get() == 1 || preflight.calls.get() >= 2 }) + + let token = "corrupt-\(corruption.rawValue)" + var errors = 0 + for index in 0 ..< 20 { + if await Self.logDevice(store, "\(token) \(index)") != nil { errors += 1 } + } + for _ in 0 ..< 5 { + center.post(name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + } + let stored = await store.entries(in: Self.everything).filter { $0.message.contains("\(token) ") } + let text = try await Self.export(.all, appLog: InMemoryLogSink(), deviceLogStore: store) + let exported = Self.occurrences(of: "\(token) ", in: Self.body(text)) + Self.restorePermissions(url) + let moved = Self.movedAside(url) + print( + "STRESS: corrupt device log \(corruption.rawValue): preflights \(preflight.calls.get()) ok \(preflight.successes.get()), " + + "moved aside \(moved.count), write errors \(errors), stored \(stored.count)/20, exported \(exported)" + ) + + #expect(moved.count <= 1) + #expect(preflight.successes.get() <= 1) + #expect(errors == 0) + #expect(stored.count == 20) + #expect(exported == 20) + } + + @Test("Preflight rejects a device log store that would open read-only") func preflightRejectsReadOnlyStore() throws { + let directory = try Self.makeDirectory() + let url = directory.appendingPathComponent("logs/DeviceLog.sqlite") + defer { + Self.restorePermissions(url) + try? FileManager.default.removeItem(at: directory) + } + try Self.corrupt(url, .readOnlyFile) { try Self.populateDeviceLog(at: $0, count: 10, start: Date()) } + #expect(!DeviceLogStore.preflight(url)) + } + + @Test("A load failure that races an unlock does not move a healthy store aside") func unlockRaceKeepsHealthyStore() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let appURL = directory.appendingPathComponent("logs/AppLog.sqlite") + let deviceURL = directory.appendingPathComponent("logs/DeviceLog.sqlite") + let seeded = Date().addingTimeInterval(-Self.hour) + try Self.populateAppLog(at: appURL, count: 100, start: seeded) + try Self.populateDeviceLog(at: deviceURL, count: 100, start: seeded) + + // First load fails as if still locked; the device unlocks before the main actor checks + let loader = Loader { $0 == 0 } + let preflight = Preflight { $0 == 0 } + let appStore = AppLogStore( + storageURL: appURL, + notificationCenter: Foundation.NotificationCenter(), + loadContainer: loader.run, + isProtectedDataAvailable: { true } + ) + let deviceStore = DeviceLogStore( + storageURL: deviceURL, + notificationCenter: Foundation.NotificationCenter(), + preflight: preflight.run, + isProtectedDataAvailable: { true } + ) + #expect(await Self.waitUntil { loader.successes.get() == 1 && preflight.successes.get() == 1 }) + + let appSeeds = await appStore.records(in: Self.everything, minimumLevel: .debug).filter { $0.message.hasPrefix("seed ") } + let deviceSeeds = await deviceStore.entries(in: Self.everything).filter { $0.message.hasPrefix("seed ") } + print( + "STRESS: unlock race: app seeds \(appSeeds.count)/100 moved \(Self.movedAside(appURL).count), " + + "device seeds \(deviceSeeds.count)/100 moved \(Self.movedAside(deviceURL).count)" + ) + #expect(Self.movedAside(appURL).isEmpty) + #expect(appSeeds.count == 100) + #expect(Self.movedAside(deviceURL).isEmpty) + #expect(deviceSeeds.count == 100) + } + + // MARK: Protected data flapping + + @Test("1 000 protected data flips while logging open each store exactly once") func protectedDataFlapping() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let appURL = directory.appendingPathComponent("logs/AppLog.sqlite") + let deviceURL = directory.appendingPathComponent("logs/DeviceLog.sqlite") + let locked = Box(true) + let loader = Loader { _ in locked.get() } + let preflight = Preflight { _ in locked.get() } + let center = Foundation.NotificationCenter() + let appStore = AppLogStore( + storageURL: appURL, + notificationCenter: center, + loadContainer: loader.run, + isProtectedDataAvailable: { !locked.get() } + ) + let deviceStore = DeviceLogStore( + storageURL: deviceURL, + notificationCenter: center, + preflight: preflight.run, + isProtectedDataAvailable: { !locked.get() } + ) + + let perSource = 4000 + let start = Date() + let producer = Task { + for index in 0 ..< perSource { + appStore.accept(Self.record("flap \(index)", at: start.addingTimeInterval(Double(index) / 1000))) + deviceStore.log( + managerIdentifier: "com.example.pump", + deviceIdentifier: nil, + type: .send, + message: "flap \(index)" + ) + if index % 100 == 0 { await Task.yield() } + } + } + let clock = ContinuousClock() + let flapTime = clock.measure { + DispatchQueue.concurrentPerform(iterations: 1000) { index in + let lock = index % 2 == 1 + locked.set(lock) + let name = lock ? UIApplication.protectedDataWillBecomeUnavailableNotification : UIApplication + .protectedDataDidBecomeAvailableNotification + center.post(name: name, object: nil) + } + } + await producer.value + locked.set(false) + center.post(name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + #expect(await Self.waitUntil { loader.successes.get() == 1 && preflight.successes.get() == 1 }) + for _ in 0 ..< 10 { + center.post(name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + } + await appStore.flush() + #expect(await Self.waitUntil(attempts: 1200) { await deviceStore.entries(in: Self.everything).count >= perSource }) + + let app = await appStore.records(in: Self.everything, minimumLevel: .debug).filter { $0.message.hasPrefix("flap ") } + let device = await deviceStore.entries(in: Self.everything).filter { $0.message.contains("flap ") } + let deviceIndexes = device.compactMap { $0.message.split(separator: " ").last.flatMap { Int($0) } } + print( + "STRESS: flapping 1000 posts in \(flapTime); loads \(loader.calls.get()) ok \(loader.successes.get()), " + + "preflights \(preflight.calls.get()) ok \(preflight.successes.get()), app \(app.count) device \(device.count)" + ) + #expect(loader.successes.get() == 1) + #expect(preflight.successes.get() == 1) + #expect(Self.movedAside(appURL).count <= 1) + #expect(Self.movedAside(deviceURL).count <= 1) + #expect(app.map(\.message) == (0 ..< perSource).map { "flap \($0)" }) + #expect(Set(deviceIndexes) == Set(0 ..< perSource)) + #expect(deviceIndexes.count == perSource) + } + + // MARK: Edge records + + @Test("Unusual messages round-trip through the app log store") func edgeMessagesRoundTrip() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = AppLogStore( + storageURL: directory.appendingPathComponent("logs/AppLog.sqlite"), + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + let messages = [ + "", + String(repeating: "lorem ipsum ", count: 5 * 1_048_576 / 12), + "nul\u{0}inside\u{0}", + "שלום עולם · مرحبا بالعالم", + "e\u{301} \u{1F9EA}\u{200D}\u{1F52C}", + "multi\nline\r\nmessage\u{2028}end" + ] + let now = Date() + let records = messages.enumerated().map { Self.record($0.element, at: now.addingTimeInterval(Double($0.offset))) } + records.forEach(store.accept) + await store.flush() + let stored = await store.records(in: Self.everything, minimumLevel: .debug) + + #expect(stored.count == records.count) + for (original, roundTripped) in zip(records, stored) { + // Core Data truncates strings at the first NUL, so the store replaces it + #expect(roundTripped.message == original.message.replacingOccurrences(of: "\u{0}", with: "\u{FFFD}")) + } + } + + @Test("One record with a lone surrogate does not drop the rest of its batch") func loneSurrogateBatch() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = AppLogStore( + storageURL: directory.appendingPathComponent("logs/AppLog.sqlite"), + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + await store.flush() + let start = Date().addingTimeInterval(-10) + let records = (0 ..< AppLogStore.batchSize).map { index in + Self.record( + index == 7 ? String(utf16CodeUnits: [0x61, 0xD800, 0x62], count: 3) : "good \(index)", + at: start.addingTimeInterval(Double(index) / 1000) + ) + } + records.forEach(store.accept) + await store.flush() + let stored = await store.records(in: Self.everything, minimumLevel: .debug) + print("STRESS: lone surrogate batch kept \(stored.count)/\(records.count)") + #expect(stored.filter { $0.message.hasPrefix("good ") }.count == records.count - 1) + } + + @Test("Edge timestamps export once each, in order, as valid UTF-8") func edgeTimestampsExport() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = AppLogStore( + storageURL: directory.appendingPathComponent("logs/AppLog.sqlite"), + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + await store.flush() + + let now = Date() + let window = DateInterval(start: now.addingTimeInterval(-Self.day), end: now) + var dated: [(String, Date)] = [ + ("", now.addingTimeInterval(-20 * Self.hour)), + ("big " + String(repeating: "lorem ipsum ", count: 5 * 1_048_576 / 12), now.addingTimeInterval(-19 * Self.hour)), + ("tab\tseparated", now.addingTimeInterval(-18 * Self.hour + 1)), + ("rtl שלום مرحبا", now.addingTimeInterval(-17 * Self.hour)), + ("multi\nline", now.addingTimeInterval(-16 * Self.hour)), + ("future", now.addingTimeInterval(Self.day)), + ("distant future", .distantFuture), + ("distant past", .distantPast), + ("epoch", Date(timeIntervalSince1970: 0)), + ("just before window", window.start.addingTimeInterval(-0.01)) + ] + let outside = dated.count - 5 + for (index, slice) in LogFileWriter.slices(of: window).enumerated() { + dated.append(("boundary \(index)", slice.start)) + dated.append(("before boundary \(index)", slice.start.addingTimeInterval(-0.01))) + } + dated.append(("window end", window.end)) + let duplicate = now.addingTimeInterval(-3 * Self.hour) + dated += (0 ..< 200).map { ("duplicate \($0)", duplicate) } + + var generator = SystemRandomNumberGenerator() + let accepted = dated.shuffled(using: &generator).map { Self.record($0.0, at: $0.1) } + accepted.forEach(store.accept) + await store.flush() + + let url = directory.appendingPathComponent("export.txt") + try await LogFileWriter.write(header: "Header", window: window, to: url, redactor: Redactor(exactValues: [])) { slice in + await store.records(in: slice, minimumLevel: .debug).map(LogExport.line) + } + let data = try Data(contentsOf: url) + let text = String(data: data, encoding: .utf8) + #expect(text != nil) + + let formatter = LogFileWriter.timestampFormatter() + let expectedRecords = accepted.enumerated() + .filter { window.start <= $0.element.timestamp && $0.element.timestamp <= window.end } + .sorted { ($0.element.timestamp, $0.offset) < ($1.element.timestamp, $1.offset) } + .map(\.element) + let expected = expectedRecords.map { LogFileWriter.format(LogExport.line(for: $0), formatter: formatter) }.joined() + let actual = Self.body(text ?? "") + #expect(expectedRecords.count == dated.count - outside - 1) + #expect(actual.split(separator: "\n").sorted() == expected.split(separator: "\n").sorted()) + #expect( + actual.replacingOccurrences(of: "duplicate \\d+", with: "", options: .regularExpression) == + expected.replacingOccurrences(of: "duplicate \\d+", with: "", options: .regularExpression) + ) + + let duplicateOrder = actual.components(separatedBy: "\n").compactMap { line -> Int? in + guard let range = line.range(of: "[Stress] duplicate ") else { return nil } + return Int(line[range.upperBound...]) + } + let acceptedOrder = accepted.compactMap { $0.message.hasPrefix("duplicate ") ? Int($0.message.dropFirst(10)) : nil } + #expect(duplicateOrder.sorted() == Array(0 ..< 200)) + #expect(duplicateOrder == acceptedOrder) + print("STRESS: edge export \(data.count) bytes, \(expectedRecords.count) lines") + } + + @Test("Lone surrogates and NUL bytes still produce a valid UTF-8 file") func invalidUnicodeExport() async throws { + let now = Date() + let window = DateInterval(start: now.addingTimeInterval(-Self.hour), end: now) + let lines = [ + LogLine(date: now.addingTimeInterval(-10), source: "Trio", text: String(utf16CodeUnits: [0x61, 0xD800], count: 2)), + LogLine(date: now.addingTimeInterval(-9), source: "Trio", text: String(utf16CodeUnits: [0xDFFF, 0x62], count: 2)), + LogLine(date: now.addingTimeInterval(-8), source: "Trio", text: "nul\u{0}end"), + LogLine(date: now.addingTimeInterval(-7), source: "Trio", text: "\u{FEFF}bom \u{202E}override") + ] + let data = try await Self.write(lines, window: window) + let text = String(data: data, encoding: .utf8) + #expect(text != nil) + #expect(Self.body(text ?? "").split(separator: "\n").count == lines.count) + } + + @Test("Export across Europe/Berlin DST changes stays in time order") func daylightSavingExport() async throws { + let previous = NSTimeZone.default + NSTimeZone.default = try #require(TimeZone(identifier: "Europe/Berlin")) + defer { NSTimeZone.default = previous } + + let parser = LogFileWriter.timestampFormatter() + let transitions: [(start: Date, repeated: String?, missing: String?)] = [ + (Date(timeIntervalSince1970: 1_774_735_200), nil, "2026-03-29 02:"), + (Date(timeIntervalSince1970: 1_792_879_200), "2026-10-25 02:30:00.000", nil) + ] + for transition in transitions { + let window = DateInterval(start: transition.start, duration: 6 * Self.hour) + let dates = (0 ... 72).map { transition.start.addingTimeInterval(Double($0) * 5 * 60) } + let lines = dates.reversed().map { LogLine(date: $0, source: "Trio", text: "tick") } + let data = try await Self.write(lines, window: window) + let body = Self.body(String(decoding: data, as: UTF8.self)) + let parsed = body.split(separator: "\n").compactMap { parser.date(from: String($0.dropLast(10))) } + #expect(parsed == dates) + #expect(body.contains("+0100")) + #expect(body.contains("+0200")) + if let repeated = transition.repeated { + #expect(body.contains("\(repeated) +0200")) + #expect(body.contains("\(repeated) +0100")) + } + if let missing = transition.missing { + #expect(!body.contains(missing)) + } + } + } + + // MARK: Redaction + + @Test("Secrets never leak across 64 KB redaction batches, file edges or multi-line messages") func redactionBoundaries() async throws { + let secret = "synthetic-secret-0001" + let host = "my-site.example.test" + let device = "SYN-DEV-0042" + let hexToken = Insecure.SHA1.hash(data: Data(secret.utf8)).map { String(format: "%02x", $0) }.joined() + var values = SensitiveValues() + values.addNightscout(url: "https://\(host)/", secret: secret) + values.add(device, .device) + let redactor = Redactor(exactValues: values.values) + let forms = [ + secret, + "GET HTTPS://\(host.uppercased())/api/v1/entries.json", + "{\"url\":\"https:\\/\\/\(host)\\/api\\/v1\"}", + "x\(host)y glued", + "line one\nline two \(secret)\nline three", + "api-secret: \(hexToken)", + "device \(device.lowercased()) ok", + "pumpSN: \(device)", + "ends with \(secret)" + ] + let now = Date() + let window = DateInterval(start: now.addingTimeInterval(-Self.day), end: now) + var lines: [LogLine] = [] + var planted = 0 + for index in 0 ..< 6000 { + var text = "row \(index) " + String(repeating: "p", count: (index * 37) % 211) + if index % 7 == 0 { + text += " " + forms[index % forms.count] + planted += 1 + } + lines.append(LogLine(date: window.start.addingTimeInterval(Double(index) * 14), source: "Trio", text: text)) + } + let filler = String(repeating: "lorem ipsum ", count: 20000) + lines + .append(LogLine( + date: now.addingTimeInterval(-60), + source: "Trio", + text: "\(secret) \(filler)\(host) \(filler)\(secret)" + )) + lines.append(LogLine(date: now, source: "Trio", text: "last \(secret)")) + + let clock = ContinuousClock() + var data = Data() + let elapsed = try await clock.measure { + data = try await Self.write(lines, window: window, header: "\(secret) header \(host)", redactor: redactor) + } + let text = String(decoding: data, as: UTF8.self).lowercased() + print("STRESS: redaction \(data.count) bytes, \(planted) planted in \(elapsed)") + for value in [secret, host, device, hexToken] { + #expect(!text.contains(value.lowercased()), "\(value) leaked") + } + #expect(text.hasPrefix(" header ")) + #expect(text.hasSuffix("last \n")) + } + + @Test( + "Encoded and case-shifted secrets are redacted", + arguments: [ + ("percent-encoded secret", "token=synthetic%20secret%2F0001%21 sent", "synthetic%20secret%2f0001%21"), + ("form-encoded secret", "body secret=synthetic+secret%2F0001%21", "synthetic+secret%2f0001%21"), + ("uppercase secret", "secret SYNTHETIC SECRET/0001! set", "synthetic secret/0001!"), + ("uppercase scheme and host", "GET HTTPS://MY-SITE.EXAMPLE.TEST/api/v1", "my-site.example.test"), + ("encoded URL", "next=https%3A%2F%2Fmy-site.example.test%2Fapi", "my-site.example.test") + ] + ) func encodedVariants(name _: String, input: String, leaked: String) { + var values = SensitiveValues() + values.addNightscout(url: "https://my-site.example.test", secret: "synthetic secret/0001!") + let output = Redactor(exactValues: values.values).redact(input).lowercased() + #expect(!output.contains(leaked), "\(output)") + } + + @Test("Thousands of distinct serials get stable, unique placeholders") func manySerials() { + let devices = (0 ..< 200).map { String(format: "SYN-DEV-%04d", $0) } + let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZ") + let serials = (0 ..< 5000).map { index -> String in + var value = index + var characters: [Character] = [] + for _ in 0 ..< 4 { + characters.append(alphabet[value % alphabet.count]) + value /= alphabet.count + } + return "8G" + String(characters) + } + let redactor = Redactor( + exactValues: devices.map { Redactor.ExactValue(value: $0, kind: .device) } + + serials.map { Redactor.ExactValue(value: $0, kind: .cgmSerial) } + ) + let first = serials.enumerated().map { + redactor.redact("transmitterID: \($0.element) device \(devices[$0.offset % devices.count])") + } + let placeholders = first.map { line -> String in + String(line.dropFirst("transmitterID: ".count).prefix { $0 != " " }) + } + #expect(placeholders == (1 ... 5000).map { "" }) + #expect(first.allSatisfy { !$0.contains("SYN-DEV") }) + #expect(Set(first.map { String($0.split(separator: " ").last ?? "") }).count == devices.count) + + var generator = SystemRandomNumberGenerator() + for index in Array(serials.indices).shuffled(using: &generator).prefix(1000) { + #expect(redactor.redact("transmitterID: \(serials[index])") == "transmitterID: \(placeholders[index])") + } + } + + @Test( + "Clinical numbers, dates, UUIDs and plugin identifiers are not redacted", + arguments: [ + "BG 123 mg/dL, delta -4, eventual 98", + "IOB 1.25 U, COB 23 g, ISF 2.8 mmol/L/U, CR 1:10", + "rate 0.85 U/h for 30 min, SMB 0.3 U", + "2026-10-18T12:00:00.000Z → 2026-10-18 14:00:00.000 +0200", + "3F2504E0-4F89-11D3-9A0C-0305E82C3301", + "com.loopkit.OmnipodKit org.nightscout.MedtrumKit org.loopkit.G7SensorKit", + "DexG6Transmitter G7CGMManager LibreTransmitterManagerV3 DanaKit", + "Transmitter battery low", + "glucose 5.5 mmol/L at 14:05, trend ↗︎", + "version 1.0.2.2 build 20261019" + ] + ) func noFalsePositives(input: String) { + #expect(Redactor(exactValues: []).redact(input) == input) + } + + @Test( + "Six-letter words after Transmitter are not treated as serials", + arguments: ["Transmitter paired", "Transmitter failed", "transmitter status ok", "TID search started"] + ) func transmitterWords(input: String) { + let redactor = Redactor(exactValues: [Redactor.ExactValue(value: "8GABCD", kind: .cgmSerial)]) + #expect(redactor.redact(input) == input) + } + + @Test("Long unbroken runs redact in linear time") func longRunsScale() { + let clock = ContinuousClock() + let timings = [8192, 16384, 32768].map { size -> (Int, Double) in + let run = String(repeating: "a1", count: size / 2) + let redactor = Redactor(exactValues: LoggingStressFixtures.storedValues.values) + return (size, clock.measure { _ = redactor.redact(run) }.seconds) + } + let spaced = String(repeating: "lorem ipsum ", count: 5 * 1_048_576 / 12) + let spacedTime = clock.measure { _ = Redactor(exactValues: LoggingStressFixtures.storedValues.values).redact(spaced) } + let ratio = timings[2].1 / max(timings[0].1, 0.000_001) + let runs = timings.map { "\($0.0) B \(String(format: "%.3f", $0.1)) s" }.joined(separator: ", ") + print( + "STRESS: unbroken run redaction \(runs); 4x size -> \(String(format: "%.1f", ratio))x time; " + + "5 MB spaced text \(spacedTime)" + ) + #expect(ratio < 8) + } + + // MARK: Export lifecycle + + @Test("Concurrent, cancelled, empty and failing exports leave no temporary directories") func exportLifecycle() async throws { + let fileManager = FileManager.default + let temporary = fileManager.temporaryDirectory + func exportDirectories() -> Set { + Set(((try? fileManager.contentsOfDirectory(atPath: temporary.path)) ?? []).filter { UUID(uuidString: $0) != nil }) + } + let fixture = try Self.makeDirectory() + defer { try? fileManager.removeItem(at: fixture) } + let deviceStore = DeviceLogStore( + storageURL: fixture.appendingPathComponent("logs/DeviceLog.sqlite"), + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + for index in 0 ..< 200 { + let manager = index.isMultiple(of: 2) ? "com.example.pump" : "com.example.cgm" + await Self.logDevice(deviceStore, "entry \(index)", manager: manager, device: "SYN-DEV-0042") + } + let sink = InMemoryLogSink(capacity: 120_000) + let now = Date() + for index in 0 ..< 100_000 { + sink + .accept( + Self + .record( + "history \(index) transmitterID: 8GAB\(index % 10)C", + at: now.addingTimeInterval(-Double(index) * 6) + ) + ) + } + let before = exportDirectories() + let export = LogExport(appLog: sink, deviceLogStore: deviceStore, pump: StubManager(), cgm: StubCGMManager()) + + let clock = ContinuousClock() + var files: [URL] = [] + let concurrentTime = await clock.measure { + files = await withTaskGroup(of: URL?.self) { group in + for (kind, days) in [(LogExport.Kind.all, 7), (.pump, 7), (.cgm, 7), (.all, 1), (.pump, 3)] { + group.addTask { try? await export.makeFile(kind, days: days) } + } + group.addTask { + for index in 0 ..< 5000 { + sink.accept(Self.record("live \(index)", at: Date())) + } + return nil + } + return await group.reduce(into: []) { files, file in + if let file { files.append(file) } + } + } + } + #expect(files.count == 5) + #expect(Set(files.map { $0.deletingLastPathComponent() }).count == 5) + for file in files { + #expect(try Data(contentsOf: file).prefix(4) == Data([0x50, 0x4B, 0x03, 0x04])) + LogExport.discard(file) + } + + var cancelOutcomes: [String] = [] + for delay in [0, 5, 20, 80] as [UInt64] { + let task = Task { try await export.makeFile(.all, days: 7) } + try? await Task.sleep(nanoseconds: delay * 1_000_000) + task.cancel() + let cancelled = clock.now + switch await task.result { + case let .success(file): + cancelOutcomes.append("\(delay) ms: finished") + LogExport.discard(file) + case let .failure(failure): + cancelOutcomes.append("\(delay) ms: \(type(of: failure)) after \(clock.now - cancelled)") + #expect(failure is CancellationError, "\(delay) ms") + } + } + #expect(cancelOutcomes.allSatisfy { !$0.hasSuffix("finished") }, "\(cancelOutcomes)") + + let emptyStore = DeviceLogStore( + storageURL: fixture.appendingPathComponent("empty/DeviceLog.sqlite"), + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + let emptyFile = try await LogExport(appLog: InMemoryLogSink(), deviceLogStore: emptyStore, pump: nil, cgm: nil) + .makeFile(.all, days: 1) + #expect(fileManager.fileExists(atPath: emptyFile.path)) + LogExport.discard(emptyFile) + + let permissions = try fileManager.attributesOfItem(atPath: temporary.path)[.posixPermissions] as? Int ?? 0o755 + try fileManager.setAttributes([.posixPermissions: 0o555], ofItemAtPath: temporary.path) + var failedCleanly = false + do { + let file = try await export.makeFile(.cgm, days: 1) + LogExport.discard(file) + } catch { + failedCleanly = true + } + try fileManager.setAttributes([.posixPermissions: permissions], ofItemAtPath: temporary.path) + #expect(failedCleanly) + + let leftovers = exportDirectories().subtracting(before) + print( + "STRESS: 5 concurrent exports in \(concurrentTime); cancel outcomes \(cancelOutcomes); leftovers \(leftovers.count)" + ) + #expect(leftovers.isEmpty) + } + + // MARK: Retention + + @Test("Changing retention during heavy logging purges and drops debug records") func retentionChangeUnderLoad() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("logs/AppLog.sqlite") + let now = Date() + try Self.populateAppLog(at: url, count: 7 * 24 * 60, start: now.addingTimeInterval(-7 * Self.day + 60), step: 60) + let store = AppLogStore( + storageURL: url, + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + await store.flush() + let seeded = await store.records(in: Self.everything, minimumLevel: .debug).count + + let counter = Box(0) + let running = Box(true) + let producers = (0 ..< 8).map { _ in + Task.detached { + while running.get() { + let index = counter.update { value -> Int in + value += 1 + return value + } + store.accept(Self.record("load \(index)", at: Date(), level: index.isMultiple(of: 2) ? .debug : .info)) + if index % 64 == 0 { await Task.yield() } + } + } + } + try await Task.sleep(nanoseconds: 300_000_000) + var policy = LogRetentionPolicy.default + policy.maxAge = Self.day + policy.persistsDebug = false + store.apply(policy) + let applied = counter.get() + try await Task.sleep(nanoseconds: 300_000_000) + + let clock = ContinuousClock() + let flipTime = clock.measure { + for flip in 0 ..< 1000 { + var flipped = policy + flipped.maxAge = flip.isMultiple(of: 2) ? Self.day + 60 : Self.day + store.apply(flipped) + } + store.apply(policy) + } + try await Task.sleep(nanoseconds: 300_000_000) + running.set(false) + for producer in producers { await producer.value } + await store.flush() + let produced = counter.get() + + let stored = await store.records(in: Self.everything, minimumLevel: .debug) + let seeds = stored.filter { $0.message.hasPrefix("seed ") } + let lateDebug = stored.filter { record in + guard record.level == .debug, record.message.hasPrefix("load "), let index = Int(record.message.dropFirst(5)) else { + return false + } + return index > applied + } + print( + "STRESS: retention seeded \(seeded), kept \(seeds.count) seeds, produced \(produced), " + + "stored \(stored.count - seeds.count), 1000 policy flips in \(flipTime)" + ) + #expect(seeds.allSatisfy { $0.timestamp >= now.addingTimeInterval(-Self.day - 120) }) + #expect(seeds.count >= 24 * 60 - 5 && seeds.count <= 24 * 60 + 1) + #expect(lateDebug.isEmpty) + + policy.persistsDebug = true + store.apply(policy) + store.accept(Self.record("debug again", at: Date(), level: .debug)) + await store.flush() + #expect(await store.records(in: Self.everything, minimumLevel: .debug).contains { $0.message == "debug again" }) + } +} diff --git a/TrioTests/Logging/LoggingStressFixtures.swift b/TrioTests/Logging/LoggingStressFixtures.swift new file mode 100644 index 0000000000..a2808d4a1d --- /dev/null +++ b/TrioTests/Logging/LoggingStressFixtures.swift @@ -0,0 +1,468 @@ +import CoreData +import Darwin +import Foundation +import LoopKit +import Testing + +@testable import Trio + +struct StressRandom: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { state = seed } + + // SplitMix64 + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var value = state + value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9 + value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB + return value ^ (value >> 31) + } + + mutating func chance(_ probability: Double) -> Bool { + Double.random(in: 0 ..< 1, using: &self) < probability + } + + mutating func hex(bytes: Int) -> String { + (0 ..< bytes).map { _ in String(format: "%02hhx", UInt8.random(in: 0 ... 255, using: &self)) }.joined() + } +} + +struct StressProfile: Sendable, CustomTestStringConvertible { + let name: String + let linesPerCycle: Int + let words: ClosedRange + let burstsPerDay: Int + let burstSize: Int + let pumpPairsPerCycle: Int + let cgmPerCycle: Int + + var testDescription: String { name } + + // Verbose Dana pump, chatty app log and four 1 000-line bursts a day + static let heavy = StressProfile( + name: "heavy", + linesPerCycle: 60, + words: 5 ... 30, + burstsPerDay: 4, + burstSize: 1000, + pumpPairsPerCycle: 34, + cgmPerCycle: 2 + ) + + // ~45 app lines of ~45 characters per loop cycle; Omnipod DASH status, temp basal and SMB exchanges + static let typical = StressProfile( + name: "typical", + linesPerCycle: 45, + words: 3 ... 10, + burstsPerDay: 1, + burstSize: 200, + pumpPairsPerCycle: 4, + cgmPerCycle: 3 + ) +} + +enum LoggingStressFixtures { + static let days = 90 + static let day: TimeInterval = 24 * 60 * 60 + static let cycle: TimeInterval = 5 * 60 + static let cyclesPerDay = Int(day / cycle) + static let retiredDays = 30 + + static let siteURL = "https://stress-site.example.test/api" + static let siteHost = "stress-site.example.test" + static let token = "a1b2c3d4e5f60718293a4b5c6d7e8f9001122334" + static let email = "stress.user@example.test" + static let pumpSerial = "0a1b2c3d" + static let podLot = "123456789" + static let podTID = "7654321" + static let transmitterID = "8GABCD" + + static let pumpManager = "StressPump" + static let cgmManager = "StressCGM" + static let retiredManager = "StressRetired" + static let pumpDevice = "stress-pump-0001" + static let cgmDevice = "stress-cgm-0001" + static let retiredDevice = "stress-retired-0001" + + static let planted = [ + siteURL, siteHost, token, email, pumpSerial, podLot, podTID, transmitterID, pumpDevice, cgmDevice, retiredDevice + ] + + // What the export would read from the keychain, Tidepool and the device managers' state + static var storedValues: SensitiveValues { + var values = SensitiveValues() + values.addNightscout(url: siteURL, secret: nil) + values.add(token, .apiSecret) + values.add(email, .email) + values.add(pumpSerial, .pumpSerial) + values.add(podLot, .pumpSerial) + values.add(podTID, .pumpSerial) + values.add(transmitterID, .cgmSerial) + return values + } + + private static let words = [ + "loop", "glucose", "basal", "temp", "bolus", "carbs", "forecast", "eventual", "target", "sensitivity", + "ratio", "upload", "fetch", "pump", "status", "reservoir", "battery", "suspend", "resume", "override", + "Ünïcödé", "Glukose", "血糖", "インスリン", "🩸", "📈", "✅", "⚠️", "💉", "🔋" + ] + private static let categories = Logger.Category.allCases.map(\.name) + private static let functions = ["determineBasal()", "fetchGlucose()", "upload(_:)", "enact(_:)", "refresh()"] + private static let files = [ + "APSManager.swift", + "FetchGlucoseManager.swift", + "NightscoutManager.swift", + "DeviceDataManager.swift" + ] + + static func dayStart(_ index: Int, end: Date) -> Date { + end.addingTimeInterval(-Double(days - index) * day) + } + + static func appRecords(day index: Int, end: Date, profile: StressProfile = .heavy) -> [LogRecord] { + var rng = StressRandom(seed: 0xA11 &+ UInt64(index)) + let start = dayStart(index, end: end) + let launchID = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, UInt8(index % 256))) + let burstCycles = Set((0 ..< profile.burstsPerDay).map { _ in Int.random(in: 0 ..< cyclesPerDay, using: &rng) }) + var records: [LogRecord] = [] + records.reserveCapacity(cyclesPerDay * profile.linesPerCycle + profile.burstsPerDay * profile.burstSize) + + func append(at date: Date, burst: Bool) { + let level: LogLevel + switch Double.random(in: 0 ..< 1, using: &rng) { + case ..<0.001: level = .error + case ..<0.021: level = .warning + case ..<0.15: level = .info + default: level = .debug + } + records.append(LogRecord( + timestamp: date, + level: level, + category: categories.randomElement(using: &rng) ?? "Loop", + message: "#a\(index).\(records.count) " + message(using: &rng, burst: burst, words: profile.words), + file: files.randomElement(using: &rng) ?? "", + function: functions.randomElement(using: &rng) ?? "", + line: UInt.random(in: 1 ... 2000, using: &rng), + launchID: launchID + )) + } + + for cycleIndex in 0 ..< cyclesPerDay { + let cycleStart = start.addingTimeInterval(Double(cycleIndex) * cycle) + for line in 0 ..< profile.linesPerCycle { + append(at: cycleStart.addingTimeInterval(Double(line) * 0.5 + 0.001), burst: false) + } + if burstCycles.contains(cycleIndex) { + let burstStart = cycleStart.addingTimeInterval(120) + for line in 0 ..< profile.burstSize { + append(at: burstStart.addingTimeInterval(Double(line) * 0.001), burst: true) + } + } + } + return records + } + + static func secretMessage(_ index: Int) -> String { + switch index % 9 { + case 0: return "✅ Uploaded 12 entries to \(siteURL)/v1/entries.json?count=12 🚀" + case 1: return "API secret hash \(token) accepted" + case 2: return "Signed in as \(email)" + case 3: return "Pump state pumpSN: \(pumpSerial) reservoir 120 U" + case 4: return "Pod paired Lot: \(podLot) TID: \(podTID)" + case 5: return "CGM transmitterID: \(transmitterID) battery ok" + case 6: return "Connected to \(pumpDevice) after 2 retries" + case 7: return "Reading from \(cgmDevice) 🩸 delta +3" + default: return "Nightscout \(siteURL) answered 200\nbody: ok\ntoken \(token)" + } + } + + private static func message(using rng: inout StressRandom, burst: Bool, words: ClosedRange) -> String { + if rng.chance(0.005) { + return secretMessage(Int.random(in: 0 ..< 9, using: &rng)) + } + if !burst, rng.chance(0.0005) { + return sentence(words: Int.random(in: 1500 ... 15000, using: &rng), using: &rng) + } + if rng.chance(0.02) { + return (0 ..< Int.random(in: 3 ... 10, using: &rng)) + .map { _ in sentence(words: Int.random(in: 4 ... 16, using: &rng), using: &rng) } + .joined(separator: "\n") + } + return sentence(words: Int.random(in: words, using: &rng), using: &rng) + } + + private static func sentence(words count: Int, using rng: inout StressRandom) -> String { + var text = "" + text.reserveCapacity(count * 8) + for index in 0 ..< count { + if index > 0 { text += " " } + if index % 7 == 3 { + text += String(Int.random(in: 0 ... 400, using: &rng)) + } else { + text += words.randomElement(using: &rng) ?? "" + } + } + return text + } + + static func deviceEntries(day index: Int, end: Date, profile: StressProfile = .heavy) -> [StoredDeviceLogEntry] { + var rng = StressRandom(seed: 0xDE7 &+ UInt64(index)) + let start = dayStart(index, end: end) + var entries: [StoredDeviceLogEntry] = [] + var pumpSequence = 0 + var cgmSequence = 0 + var retiredSequence = 0 + + func pump(_ type: DeviceLogEntryType, _ text: String, at date: Date) { + entries.append(StoredDeviceLogEntry( + type: type, + managerIdentifier: pumpManager, + deviceIdentifier: pumpDevice, + message: "#p\(index).\(pumpSequence) \(text)", + timestamp: date + )) + pumpSequence += 1 + } + + for cycleIndex in 0 ..< cyclesPerDay { + let cycleStart = start.addingTimeInterval(Double(cycleIndex) * cycle) + for pair in 0 ..< profile.pumpPairsPerCycle { + let date = cycleStart.addingTimeInterval(1 + Double(pair) * 0.5) + let opCode = Int.random(in: 1 ... 120, using: &rng) + let sent = rng.hex(bytes: Int.random(in: 6 ... 32, using: &rng)) + let received = rng.hex(bytes: Int.random(in: 6 ... 32, using: &rng)) + if profile.pumpPairsPerCycle > 8 { + pump(.send, "Sending data - Name: CMD_\(opCode), OpCode: \(opCode), Encoded data: \(sent)", at: date) + pump(.receive, "Decoding successful! Data: \(received)", at: date.addingTimeInterval(0.05)) + } else { + // Pod messages are logged as bare hex + pump(.send, sent, at: date) + pump(.receive, received, at: date.addingTimeInterval(0.05)) + } + } + let event: (DeviceLogEntryType, String) = { + switch cycleIndex % 4 { + case 0: return (.connection, "Dana - Connection is ok! \(pumpDevice)") + case 1: return (.delegate, "Enact temp basal, rate: 0.85U/h, duration: 30min") + case 2: return (.delegateResponse, "Temp basal enacted pumpSN: \(pumpSerial)") + default: return (.error, "Dana - Couldn't reconnect") + } + }() + pump(event.0, event.1, at: cycleStart.addingTimeInterval(30)) + + for reading in 0 ..< profile.cgmPerCycle { + let text = reading == 0 + ? "Glucose \(Int.random(in: 60 ... 250, using: &rng)) mg/dL from \(cgmDevice) 📈" + : "transmitterID: \(transmitterID) battery ok" + entries.append(StoredDeviceLogEntry( + type: .receive, + managerIdentifier: cgmManager, + deviceIdentifier: cgmDevice, + message: "#c\(index).\(cgmSequence) \(text)", + timestamp: cycleStart.addingTimeInterval(40 + Double(reading)) + )) + cgmSequence += 1 + } + + if index < retiredDays { + entries.append(StoredDeviceLogEntry( + type: .receive, + managerIdentifier: retiredManager, + deviceIdentifier: retiredDevice, + message: "#r\(index).\(retiredSequence) Legacy reading from \(retiredDevice)", + timestamp: cycleStart.addingTimeInterval(50) + )) + retiredSequence += 1 + } + } + return entries + } + + static func loadDeviceLogContainer(_ url: URL) -> NSPersistentContainer? { + guard let modelURL = Bundle(for: PersistentDeviceLog.self).url(forResource: "DeviceLog", withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) + else { return nil } + let container = NSPersistentContainer(name: "DeviceLog", managedObjectModel: model) + let description = NSPersistentStoreDescription(url: url) + description.shouldAddStoreAsynchronously = false + container.persistentStoreDescriptions = [description] + var loadError: Error? + container.loadPersistentStores { _, error in loadError = error } + return loadError == nil ? container : nil + } + + static func unload(_ container: NSPersistentContainer) { + let coordinator = container.persistentStoreCoordinator + for store in coordinator.persistentStores { + try? coordinator.remove(store) + } + } + + // Writes historical timestamps straight to the store; PersistentDeviceLog always stamps `Date()` + static func insert(_ entries: [StoredDeviceLogEntry], into context: NSManagedObjectContext, counter: inout Int64) throws { + var iterator = entries.makeIterator() + var next = counter + let request = NSBatchInsertRequest(entityName: "Entry", dictionaryHandler: { dictionary in + guard let entry = iterator.next() else { return true } + next += 1 + dictionary.addEntries(from: [ + "managerIdentifier": entry.managerIdentifier, + "deviceIdentifier": entry.deviceIdentifier ?? "", + "type": entry.type.rawValue, + "message": entry.message, + "timestamp": entry.timestamp, + "modificationCounter": next + ]) + return false + }) + try context.performAndWait { + _ = try context.execute(request) + } + counter = next + } + + // Shaped like OpenAPSSwiftTests/Parity/goldens/*/determination.json + static func insertDeterminations(day index: Int, end: Date, into context: NSManagedObjectContext) throws { + var rng = StressRandom(seed: 0x0DE7 &+ UInt64(index)) + let start = dayStart(index, end: end) + try context.performAndWait { + for cycleIndex in 0 ..< cyclesPerDay { + let date = start.addingTimeInterval(Double(cycleIndex) * cycle + 3) + let glucose = Int.random(in: 60 ... 250, using: &rng) + let cob = rng.chance(0.3) ? Int16.random(in: 1 ... 80, using: &rng) : 0 + let iob = Double(Int.random(in: -50 ... 600, using: &rng)) / 100 + let rate = Double(Int.random(in: 0 ... 300, using: &rng)) / 100 + let smb = rng.chance(0.4) ? Double(Int.random(in: 1 ... 10, using: &rng)) / 10 : nil + let isf = Int.random(in: 30 ... 90, using: &rng) + let eventual = Int.random(in: 60 ... 250, using: &rng) + let minPred = Int.random(in: 60 ... 200, using: &rng) + let insulinReq = Double(Int.random(in: 0 ... 150, using: &rng)) / 100 + let deviation = Int.random(in: -40 ... 80, using: &rng) + let bgi = Int.random(in: 0 ... 9, using: &rng) + + let determination = OrefDetermination(context: context) + determination.id = UUID() + determination.deliverAt = date + determination.timestamp = date + determination.enacted = rng.chance(0.9) + determination.glucose = NSDecimalNumber(value: glucose) + determination.iob = NSDecimalNumber(value: iob) + determination.cob = cob + determination.rate = NSDecimalNumber(value: rate) + determination.duration = 30 + determination.smbToDeliver = smb.map { NSDecimalNumber(value: $0) } + determination.eventualBG = NSDecimalNumber(value: eventual) + determination.insulinReq = NSDecimalNumber(value: insulinReq) + determination.reason = "Autosens ratio: 1, ISF: \(isf)→\(isf), COB: \(cob), Dev: \(deviation), " + + "BGI: -\(bgi), CR: 12, Target: 95, minPredBG \(minPred), " + + "minGuardBG \(minPred - 10), IOBpredBG \(eventual), UAMpredBG \(eventual - 20); " + + "Eventual BG \(eventual) >= 95, insulinReq \(insulinReq). " + + (smb.map { "Microbolusing \($0)U. " } ?? "") + "temp \(rate)<2.8U/hr." + + var types = ["iob", "uam", "zt"] + if cob > 0 { types.append("cob") } + for type in types { + let forecast = Forecast(context: context) + forecast.id = UUID() + forecast.date = date + forecast.type = type + forecast.orefDetermination = determination + var value = Int32(glucose) + for position in 0 ..< (type == "zt" ? Int.random(in: 7 ... 48, using: &rng) : 48) { + let point = ForecastValue(context: context) + point.index = Int32(position) + point.value = value + point.forecast = forecast + value = max(39, value + Int32.random(in: -6 ... 6, using: &rng)) + } + } + } + try context.save() + context.reset() + } + } + + static func residentBytes() -> UInt64 { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + return result == KERN_SUCCESS ? info.resident_size : 0 + } + + static func footprintBytes() -> UInt64 { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + return result == KERN_SUCCESS ? info.phys_footprint : 0 + } + + static func storeSize(_ url: URL) -> UInt64 { + ["", "-wal", "-shm"].reduce(0) { total, suffix in + total + fileSize(URL(fileURLWithPath: url.path + suffix)) + } + } + + static func fileSize(_ url: URL) -> UInt64 { + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + return (attributes?[.size] as? NSNumber)?.uint64Value ?? 0 + } + + static func megabytes(_ bytes: UInt64) -> String { + String(format: "%.1fMB", Double(bytes) / 1_048_576) + } + + static func seconds(_ duration: Duration) -> Double { + let components = duration.components + return Double(components.seconds) + Double(components.attoseconds) / 1E18 + } + + static func report(_ name: String, _ values: KeyValuePairs) { + print("STRESS: \(name) " + values.map { "\($0.key)=\($0.value)" }.joined(separator: " ")) + } +} + +final class MemorySampler: @unchecked Sendable { + private let lock = NSLock() + private var running = true + private var peakResident: UInt64 = 0 + private var peakFootprint: UInt64 = 0 + let baselineFootprint = LoggingStressFixtures.footprintBytes() + + init() { + let thread = Thread { [self] in + while lock.withLock({ running }) { + sample() + Thread.sleep(forTimeInterval: 0.02) + } + } + thread.qualityOfService = .userInitiated + thread.start() + } + + private func sample() { + let resident = LoggingStressFixtures.residentBytes() + let footprint = LoggingStressFixtures.footprintBytes() + lock.withLock { + peakResident = max(peakResident, resident) + peakFootprint = max(peakFootprint, footprint) + } + } + + func stop() -> (resident: UInt64, footprint: UInt64) { + sample() + return lock.withLock { + running = false + return (peakResident, peakFootprint) + } + } +} diff --git a/TrioTests/Logging/LoggingVolumeStressTests.swift b/TrioTests/Logging/LoggingVolumeStressTests.swift new file mode 100644 index 0000000000..cde4c9d676 --- /dev/null +++ b/TrioTests/Logging/LoggingVolumeStressTests.swift @@ -0,0 +1,796 @@ +import CoreData +import Foundation +import LoopKit +import Testing + +@testable import Trio + +@Suite( + "Logging Volume Stress Tests", + .serialized, + .enabled(if: ProcessInfo.processInfo.environment["TRIO_STRESS"] == "1") +) struct LoggingVolumeStressTests { + private typealias Fixtures = LoggingStressFixtures + + private class StubManager: DeviceManager { + class var pluginIdentifier: String { LoggingStressFixtures.pumpManager } + var localizedTitle: String { "Stress Pump" } + var debugDescription: String { "## StubManager\npumpSN: \(LoggingStressFixtures.pumpSerial)" } + var rawState: RawStateValue { [:] } + var isOnboarded: Bool { true } + + init() {} + required init?(rawState _: RawStateValue) {} + + func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func getSoundBaseURL() -> URL? { nil } + func getSounds() -> [Alert.Sound] { [] } + func initializationComplete(for _: [Pluggable]) {} + } + + private final class StubCGMManager: StubManager { + override class var pluginIdentifier: String { LoggingStressFixtures.cgmManager } + override var localizedTitle: String { "Stress CGM" } + override var debugDescription: String { "## StubCGMManager\ntransmitterID: \(LoggingStressFixtures.transmitterID)" } + } + + private actor Stores { + static let shared = Stores(profile: .heavy) + static let typical = Stores(profile: .typical) + + static func of(_ profile: StressProfile) -> Stores { + profile.name == StressProfile.typical.name ? typical : shared + } + + let profile: StressProfile + let directory: URL + // Whole hour so 6 h slice boundaries of a window ending here are known + let end = Date(timeIntervalSince1970: (Date().timeIntervalSince1970 / 3600).rounded(.down) * 3600) + private var appLog: AppLogStore? + private var deviceLog: DeviceLogStore? + private var algorithm: CoreDataStack? + + init(profile: StressProfile) { + self.profile = profile + directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "LoggingVolumeStress-\(profile.name)", + isDirectory: true + ) + } + + var appLogURL: URL { directory.appendingPathComponent("logs/AppLog.sqlite") } + var deviceLogURL: URL { directory.appendingPathComponent("logs/DeviceLog.sqlite") } + + var boundaries: [Date] { (1 ... 7).map { end.addingTimeInterval(-Double($0) * LogFileWriter.sliceDuration) } } + + func preparedAppLog() async throws -> AppLogStore { + if let appLog { return appLog } + let store = try await LoggingVolumeStressTests.ingestAppLog(into: self) + appLog = store + return store + } + + func preparedDeviceLog() async throws -> DeviceLogStore { + if let deviceLog { return deviceLog } + let store = try await LoggingVolumeStressTests.ingestDeviceLog(into: self) + deviceLog = store + return store + } + + // Determinations for the newest 8 days, as many as an export can show + func preparedAlgorithm() async throws -> CoreDataStack { + if let algorithm { return algorithm } + let stack = try await CoreDataStack.createForTests() + let context = stack.newTaskContext() + for day in Fixtures.days - 8 ..< Fixtures.days { + try Fixtures.insertDeterminations(day: day, end: end, into: context) + } + algorithm = stack + return stack + } + + func reset() { + appLog = nil + deviceLog = nil + algorithm = nil + try? FileManager.default.removeItem(at: directory) + } + } + + private static let clock = ContinuousClock() + private static let ingestPolicy = LogRetentionPolicy(maxAge: 91 * Fixtures.day, persistsDebug: true, maxRows: 100_000_000) + private static let mb = 1_048_576.0 + + private static func rows( + _ url: URL, + entity: String, + load: (URL) -> NSPersistentContainer?, + predicate: NSPredicate? = nil + ) -> (count: Int, oldest: Date?, newest: Date?) { + guard let container = load(url) else { return (0, nil, nil) } + defer { Fixtures.unload(container) } + let context = container.newBackgroundContext() + return context.performAndWait { + func edge(ascending: Bool) -> Date? { + let request = NSFetchRequest(entityName: entity) + request.resultType = .dictionaryResultType + request.predicate = predicate + request.propertiesToFetch = ["timestamp"] + request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: ascending)] + request.fetchLimit = 1 + return (try? context.fetch(request))?.first?["timestamp"] as? Date + } + let request = NSFetchRequest(entityName: entity) + request.predicate = predicate + return ((try? context.count(for: request)) ?? -1, edge(ascending: true), edge(ascending: false)) + } + } + + private static func appRows(_ url: URL, _ predicate: NSPredicate? = nil) -> (count: Int, oldest: Date?, newest: Date?) { + rows(url, entity: "AppLogEntry", load: AppLogStore.loadContainer, predicate: predicate) + } + + private static func deviceRows(_ url: URL, _ predicate: NSPredicate? = nil) -> (count: Int, oldest: Date?, newest: Date?) { + rows(url, entity: "Entry", load: Fixtures.loadDeviceLogContainer, predicate: predicate) + } + + private static func since(_ date: Date, manager: String? = nil) -> NSPredicate { + guard let manager else { return NSPredicate(format: "timestamp >= %@", date as NSDate) } + return NSPredicate(format: "timestamp >= %@ AND managerIdentifier == %@", date as NSDate, manager) + } + + private static func ingestAppLog(into stores: Stores) async throws -> AppLogStore { + let url = await stores.appLogURL + let end = await stores.end + let profile = stores.profile + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let store = AppLogStore( + storageURL: url, + policy: ingestPolicy, + notificationCenter: Foundation.NotificationCenter(), + isProtectedDataAvailable: { true } + ) + await store.flush() + + let sampler = MemorySampler() + var accepted = 0 + var newest = Date.distantPast + var elapsed = Duration.zero + for day in 0 ..< Fixtures.days { + let records = Fixtures.appRecords(day: day, end: end, profile: profile) + let start = Self.clock.now + for chunk in stride(from: 0, to: records.count, by: 2000) { + records[chunk ..< min(chunk + 2000, records.count)].forEach(store.accept) + await store.flush() + } + elapsed += Self.clock.now - start + accepted += records.count + newest = max(newest, records.last?.timestamp ?? newest) + } + for (index, date) in await stores.boundaries.enumerated() { + store.accept(LogRecord( + timestamp: date, + level: .info, + category: "Loop", + message: "#b-app-\(index + 1) slice boundary", + file: "Stress.swift", + function: "boundary()", + line: 1, + launchID: LaunchID.current + )) + } + await store.flush() + let peak = sampler.stop() + accepted += 7 + + let ingested = appRows(url) + let ingestedSize = Fixtures.storeSize(url) + Fixtures.report("appLog.ingest.\(profile.name)", [ + "records": "\(accepted)", + "perDay": "\(accepted / Fixtures.days)", + "rowsPerSec": String(format: "%.0f", Double(accepted) / Fixtures.seconds(elapsed)), + "seconds": String(format: "%.1f", Fixtures.seconds(elapsed)), + "peakResident": Fixtures.megabytes(peak.resident), + "peakFootprint": Fixtures.megabytes(peak.footprint), + "sqlite+wal": Fixtures.megabytes(ingestedSize) + ]) + #expect(ingested.count == accepted) + + var capped = LogRetentionPolicy.default + capped.maxAge = ingestPolicy.maxAge + let capStart = Self.clock.now + store.apply(capped) + await store.flush() + let capDuration = Self.clock.now - capStart + let afterCap = appRows(url) + let coveredDays = afterCap.oldest.map { end.timeIntervalSince($0) / Fixtures.day } ?? 0 + Fixtures.report("appLog.rowCap.\(profile.name)", [ + "maxRows": "\(capped.maxRows)", + "rows": "\(afterCap.count)", + "daysCovered": String(format: "%.2f", coveredDays), + "seconds": String(format: "%.2f", Fixtures.seconds(capDuration)), + "sqlite+wal": Fixtures.megabytes(Fixtures.storeSize(url)) + ]) + #expect(afterCap.count <= capped.maxRows) + #expect(afterCap.count > capped.maxRows - 1000) + #expect(afterCap.newest == newest) + #expect(coveredDays >= 7) + + let ageStart = Self.clock.now + store.apply(.default) + // The purge runs a day at a time; wait for the last day + let cutoff = Date().addingTimeInterval(-LogRetentionPolicy.default.maxAge - 60) + for _ in 0 ..< 600 { + await store.flush() + if (appRows(url).oldest ?? .distantFuture) >= cutoff { break } + try? await Task.sleep(nanoseconds: 50_000_000) + } + let ageDuration = Self.clock.now - ageStart + let afterAge = appRows(url) + Fixtures.report("appLog.agePurge.\(profile.name)", [ + "rows": "\(afterAge.count)", + "oldestAgeDays": String(format: "%.2f", afterAge.oldest.map { Date().timeIntervalSince($0) / Fixtures.day } ?? 0), + "seconds": String(format: "%.2f", Fixtures.seconds(ageDuration)), + "sqlite+wal": Fixtures.megabytes(Fixtures.storeSize(url)) + ]) + #expect(afterAge.count <= LogRetentionPolicy.default.maxRows) + #expect((afterAge.oldest ?? .distantPast) >= cutoff) + #expect(afterAge.newest == newest) + return store + } + + private static func ingestDeviceLog(into stores: Stores) async throws -> DeviceLogStore { + let url = await stores.deviceLogURL + let end = await stores.end + let profile = stores.profile + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let container = try #require(Fixtures.loadDeviceLogContainer(url)) + let context = container.newBackgroundContext() + + let sampler = MemorySampler() + var counter: Int64 = 0 + var inserted = 0 + var elapsed = Duration.zero + for day in 0 ..< Fixtures.days { + let entries = Fixtures.deviceEntries(day: day, end: end, profile: profile) + let start = Self.clock.now + try Fixtures.insert(entries, into: context, counter: &counter) + elapsed += Self.clock.now - start + inserted += entries.count + } + let markers = await stores.boundaries.enumerated().map { index, date in + StoredDeviceLogEntry( + type: .send, + managerIdentifier: Fixtures.pumpManager, + deviceIdentifier: Fixtures.pumpDevice, + message: "#b-dev-\(index + 1) slice boundary", + timestamp: date + ) + } + try Fixtures.insert(markers, into: context, counter: &counter) + inserted += markers.count + Fixtures.unload(container) + let ingestPeak = sampler.stop() + let ingestedSize = Fixtures.storeSize(url) + #expect(deviceRows(url).count == inserted) + Fixtures.report("deviceLog.ingest.\(profile.name)", [ + "entries": "\(inserted)", + "perDay": "\(inserted / Fixtures.days)", + "rowsPerSec": String(format: "%.0f", Double(inserted) / Fixtures.seconds(elapsed)), + "seconds": String(format: "%.1f", Fixtures.seconds(elapsed)), + "peakFootprint": Fixtures.megabytes(ingestPeak.footprint), + "sqlite+wal": Fixtures.megabytes(ingestedSize) + ]) + + let purgeSampler = MemorySampler() + let purgeStart = Self.clock.now + let store = DeviceLogStore(storageURL: url, isProtectedDataAvailable: { true }) + _ = await store.entries(in: DateInterval(start: end, duration: 1)) + let purgeDuration = Self.clock.now - purgeStart + let purgePeak = purgeSampler.stop() + let remaining = deviceRows(url) + Fixtures.report("deviceLog.openAndPurge.\(profile.name)", [ + "rows": "\(remaining.count)", + "oldestAgeDays": String(format: "%.2f", remaining.oldest.map { Date().timeIntervalSince($0) / Fixtures.day } ?? 0), + "seconds": String(format: "%.2f", Fixtures.seconds(purgeDuration)), + "peakFootprint": Fixtures.megabytes(purgePeak.footprint), + "footprintGrowth": Fixtures.megabytes(purgePeak.footprint - min(purgePeak.footprint, purgeSampler.baselineFootprint)), + "sqlite+walBefore": Fixtures.megabytes(ingestedSize), + "sqlite+walAfter": Fixtures.megabytes(Fixtures.storeSize(url)) + ]) + #expect((remaining.oldest ?? .distantPast) >= Date().addingTimeInterval(-LogRetentionPolicy.default.maxAge - 60)) + let retired = deviceRows(url, NSPredicate(format: "managerIdentifier == %@", Fixtures.retiredManager)).count + #expect(retired == 0) + return store + } + + private struct Analysis { + var counts: [String: Int] = [:] + var duplicates = 0 + var unsorted = 0 + var leaks: [String] = [] + var falseSecrets = 0 + var markers: [String: Int] = [:] + } + + private static func analyze(_ text: String) -> Analysis { + var analysis = Analysis() + analysis.leaks = Fixtures.planted.filter { text.contains($0) } + guard let range = text.range(of: "\n== Log ==\n") else { return analysis } + let formatter = LogFileWriter.timestampFormatter() + var seen = Set() + var previous: Substring = "" + var previousDate = Date.distantPast + for line in text[range.upperBound...].split(separator: "\n") { + guard line.count > 30, line.first?.isNumber == true else { continue } + let stamp = line.prefix(29) + if previous.suffix(5) == stamp.suffix(5) { + if stamp < previous { analysis.unsorted += 1 } + } else if let date = formatter.date(from: String(stamp)) { + if date < previousDate { analysis.unsorted += 1 } + previousDate = date + } + previous = stamp + if !seen.insert(line).inserted { analysis.duplicates += 1 } + let rest = line.dropFirst(30) + let source = rest.prefix { $0 != " " } + analysis.counts[String(source), default: 0] += 1 + if source == "Pump", rest.contains(" URL { + let folder = file.deletingLastPathComponent() + let archive = folder.deletingLastPathComponent().appendingPathComponent("\(folder.lastPathComponent).zip") + var coordinationError: NSError? + var copyError: Error? + NSFileCoordinator().coordinate(readingItemAt: folder, options: .forUploading, error: &coordinationError) { zipped in + do { + try FileManager.default.copyItem(at: zipped, to: archive) + } catch { + copyError = error + } + } + if let error = coordinationError ?? copyError { throw error } + return archive + } + + private static func makeExport( + _ appLog: AppLogStore, + _ deviceLog: DeviceLogStore, + algorithm: CoreDataStack? = nil + ) -> LogExport { + LogExport( + appLog: appLog, + deviceLogStore: deviceLog, + pump: StubManager(), + cgm: StubCGMManager(), + storedValues: Fixtures.storedValues, + coreDataStack: algorithm + ) + } + + @Test( + "App log store ingests 90 days and purges to the cap, then to the retention age", + arguments: [StressProfile.heavy, .typical] + ) func appLogIngest(profile: StressProfile) async throws { + await Stores.of(profile).reset() + _ = try await Stores.of(profile).preparedAppLog() + } + + @Test( + "Device log store ingests 90 days and purges to the retention age on open", + arguments: [StressProfile.heavy, .typical] + ) func deviceLogIngest(profile: StressProfile) async throws { + _ = try await Stores.of(profile).preparedDeviceLog() + } + + @Test("Device log live writes save one entry at a time") func deviceLogLiveWrites() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = DeviceLogStore( + storageURL: directory.appendingPathComponent("logs/DeviceLog.sqlite"), + isProtectedDataAvailable: { true } + ) + _ = await store.entries(in: DateInterval(start: Date(), duration: 1)) + let count = 5000 + let start = Self.clock.now + var calls = Duration.zero + await withCheckedContinuation { (continuation: CheckedContinuation) in + let callStart = Self.clock.now + defer { calls = Self.clock.now - callStart } + for index in 0 ..< count { + store.log( + managerIdentifier: Fixtures.pumpManager, + deviceIdentifier: Fixtures.pumpDevice, + type: .send, + message: "#live-dev-\(index) Sending data - OpCode: 1" + ) { _ in + if index == count - 1 { continuation.resume() } + } + } + } + let duration = Self.clock.now - start + Fixtures.report("deviceLog.liveLog", [ + "entries": "\(count)", + "entriesPerSec": String(format: "%.0f", Double(count) / Fixtures.seconds(duration)), + "secondsUntilPersisted": String(format: "%.2f", Fixtures.seconds(duration)), + "dayOfDanaSeconds": String(format: "%.1f", 20000 / (Double(count) / Fixtures.seconds(duration))), + "callSeconds": String(format: "%.2f", Fixtures.seconds(calls)) + ]) + #expect(await store.entries(in: DateInterval(start: Date().addingTimeInterval(-600), end: Date())).count == count) + } + + @Test( + "Exports of every kind and length are sorted, complete, redacted and fast", + arguments: [StressProfile.heavy, .typical] + ) func exports(profile: StressProfile) async throws { + let stores = Stores.of(profile) + let appLog = try await stores.preparedAppLog() + let deviceLog = try await stores.preparedDeviceLog() + let algorithm = try await stores.preparedAlgorithm() + let appURL = await stores.appLogURL + let deviceURL = await stores.deviceLogURL + let export = Self.makeExport(appLog, deviceLog, algorithm: algorithm) + + for days in [1, 3, 7] { + for kind in [LogExport.Kind.all, .pump, .cgm] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let span = Double(days) * Fixtures.day + let before = Date() + let expected: Set + switch kind { + case .all: expected = ["Trio", "Pump", "CGM"] + case .pump: expected = ["Pump"] + case .cgm: expected = ["CGM"] + } + func rows(_ source: String, since date: Date) -> Int { + switch source { + case "Trio": return Self.appRows(appURL, Self.since(date)).count + case "Pump": return Self.deviceRows(deviceURL, Self.since(date, manager: Fixtures.pumpManager)).count + default: return Self.deviceRows(deviceURL, Self.since(date, manager: Fixtures.cgmManager)).count + } + } + // Counted first: the device log purges rows that age out while the export runs + let upperStart = before.addingTimeInterval(-span) + let upper = Dictionary(uniqueKeysWithValues: expected.map { ($0, rows($0, since: upperStart)) }) + let sampler = MemorySampler() + let start = Self.clock.now + let url = try await export.writeLog(kind, days: days, into: directory.appendingPathComponent("log")) + let duration = Self.clock.now - start + let peak = sampler.stop() + let after = Date() + let zipStart = Self.clock.now + let archive = try Self.zip(url) + let zipDuration = Self.clock.now - zipStart + let textSize = Fixtures.fileSize(url) + let zipSize = Fixtures.fileSize(archive) + let analysis = Self.analyze(try String(contentsOf: url, encoding: .utf8)) + let name = "\(profile.name).\(kind)-\(days)d" + + Fixtures.report("export.\(name)", [ + "seconds": String(format: "%.2f", Fixtures.seconds(duration)), + "zipSeconds": String(format: "%.2f", Fixtures.seconds(zipDuration)), + "peakFootprint": Fixtures.megabytes(peak.footprint), + "footprintGrowth": Fixtures.megabytes(peak.footprint - min(peak.footprint, sampler.baselineFootprint)), + "txt": Fixtures.megabytes(textSize), + "zip": Fixtures.megabytes(zipSize), + "ratio": String(format: "%.1f", Double(textSize) / Double(max(zipSize, 1))), + "lines": "\(analysis.counts.values.reduce(0, +))", + "sources": analysis.counts.sorted { $0.key < $1.key }.map { "\($0.key):\($0.value)" }.joined(separator: ","), + "pumpPacketsRedactedAsSecret": "\(analysis.falseSecrets)" + ]) + + #expect(analysis.leaks.isEmpty, "\(name) leaks \(analysis.leaks)") + #expect(analysis.unsorted == 0, "\(name)") + #expect(analysis.duplicates == 0, "\(name)") + #expect(analysis.markers.values.allSatisfy { $0 == 1 }, "\(name) markers \(analysis.markers)") + #expect(analysis.counts[Fixtures.retiredManager] == nil, "\(name)") + + #expect(Set(analysis.counts.keys) == expected.union(kind == .all ? ["Algorithm"] : []), "\(name)") + #expect((analysis.counts["Algorithm"] ?? 0) <= days * Fixtures.cyclesPerDay + 1, "\(name)") + for source in expected { + let lower = rows(source, since: after.addingTimeInterval(-span)) + let found = analysis.counts[source] ?? 0 + let upperBound = upper[source] ?? 0 + #expect(found >= lower && found <= upperBound, "\(name) \(source): \(found) not in \(lower)...\(upperBound)") + } + let limit: Double = switch (kind, days) { + case (.all, 1): 1 + case (.all, _): 5 + default: 2 + } + #expect(Fixtures.seconds(duration) <= limit, "\(name) took \(duration)") + } + } + + let identifierStart = Self.clock.now + let window = DateInterval(start: Date().addingTimeInterval(-7 * Fixtures.day), end: Date()) + var identifierEntries = 0 + for slice in LogFileWriter.slices(of: window) { + identifierEntries += await deviceLog.entries(in: slice).count + } + Fixtures.report("export.\(profile.name).identifierPass7d", [ + "entries": "\(identifierEntries)", + "seconds": String(format: "%.2f", Fixtures.seconds(Self.clock.now - identifierStart)) + ]) + + let start = Self.clock.now + let archive = try await export.makeFile(.all, days: 7) + let makeFileDuration = Self.clock.now - start + defer { LogExport.discard(archive) } + Fixtures.report("export.\(profile.name).makeFile-all-7d", [ + "seconds": String(format: "%.2f", Fixtures.seconds(makeFileDuration)), + "zip": Fixtures.megabytes(Fixtures.fileSize(archive)) + ]) + #expect(Fixtures.seconds(makeFileDuration) <= 6) + } + + @Test("7-day export time per phase") func exportPhases() async throws { + let appLog = try await Stores.shared.preparedAppLog() + let deviceLog = try await Stores.shared.preparedDeviceLog() + let algorithm = try await Stores.shared.preparedAlgorithm() + let now = Date() + let window = DateInterval(start: now.addingTimeInterval(-7 * Fixtures.day), end: now) + let slices = LogFileWriter.slices(of: window) + for (name, appSource, manager) in [("all", true, String?.none), ("pump", false, Fixtures.pumpManager)] { + var phases: [String: Duration] = [:] + func measure(_ phase: String, _ body: () async throws -> T) async rethrows -> T { + let start = Self.clock.now + defer { phases[phase, default: .zero] += Self.clock.now - start } + return try await body() + } + var values = Fixtures.storedValues + for slice in slices { + for entry in await measure("identifiers", { await deviceLog.entries(in: slice) }) { + values.add(entry.deviceIdentifier, .device) + } + } + let redactor = Redactor(exactValues: values.values) + let formatter = LogFileWriter.timestampFormatter() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("log.txt") + FileManager.default.createFile(atPath: url.path, contents: nil) + let handle = try FileHandle(forWritingTo: url) + var lineCount = 0 + for slice in slices { + var lines: [LogLine] = [] + if appSource { + let records = await measure("appFetch") { await appLog.records(in: slice, minimumLevel: .debug) } + lines += records.map(LogExport.line) + lines += await measure("algorithm") { await LogExport.algorithmLines(in: slice, from: algorithm) } + } + lines += await measure("deviceFetch") { + await deviceLog.entries(in: slice, managerIdentifier: manager).map { LogExport.line(for: $0, source: "Pump") } + } + let sorted = await measure("sort") { lines.sorted { $0.date < $1.date } } + lineCount += sorted.count + let text = await measure("format") { sorted.map { LogFileWriter.format($0, formatter: formatter) }.joined() } + let redacted = await measure("redact") { redactor.redact(text) } + try await measure("write") { try handle.write(contentsOf: Data(redacted.utf8)) } + } + try handle.close() + _ = try await measure("zip") { try Self.zip(url) } + let total = phases.values.reduce(Duration.zero, +) + Fixtures.report("exportPhases.\(name)-7d", [ + "lines": "\(lineCount)", + "total": String(format: "%.2f", Fixtures.seconds(total)), + "phases": phases.sorted { $0.value > $1.value } + .map { "\($0.key):" + String(format: "%.2f", Fixtures.seconds($0.value)) }.joined(separator: ",") + ]) + } + } + + @Test("Slices meet exactly at 6 hour boundaries on real stores") func sliceBoundaries() async throws { + let appLog = try await Stores.shared.preparedAppLog() + let deviceLog = try await Stores.shared.preparedDeviceLog() + let end = await Stores.shared.end + let window = DateInterval(start: end.addingTimeInterval(-2 * Fixtures.day), end: end) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") + defer { try? FileManager.default.removeItem(at: url) } + + try await LogFileWriter.write(header: "Header\n", window: window, to: url, redactor: Redactor(exactValues: [])) { slice in + var lines = await appLog.records(in: slice, minimumLevel: .debug).map(LogExport.line) + lines += await deviceLog.entries(in: slice).map { LogExport.line(for: $0, source: $0.managerIdentifier) } + return lines + } + + let analysis = Self.analyze(try String(contentsOf: url, encoding: .utf8)) + let expectedApp = Self.appRows( + await Stores.shared.appLogURL, + NSPredicate(format: "timestamp >= %@ AND timestamp <= %@", window.start as NSDate, window.end as NSDate) + ).count + let expectedDevice = Self.deviceRows( + await Stores.shared.deviceLogURL, + NSPredicate(format: "timestamp >= %@ AND timestamp < %@", window.start as NSDate, window.end as NSDate) + ).count + Fixtures.report("sliceBoundaries", [ + "markers": analysis.markers.sorted { $0.key < $1.key }.map { "\($0.key):\($0.value)" }.joined(separator: ","), + "appLines": "\(analysis.counts["Trio"] ?? 0)/\(expectedApp)", + "deviceLines": "\(analysis.counts.filter { $0.key != "Trio" }.values.reduce(0, +))/\(expectedDevice)" + ]) + #expect(analysis.markers.count == 14) + #expect(analysis.markers.values.allSatisfy { $0 == 1 }) + #expect(analysis.duplicates == 0) + #expect(analysis.unsorted == 0) + #expect(analysis.counts["Trio"] == expectedApp) + #expect(analysis.counts.filter { $0.key != "Trio" }.values.reduce(0, +) == expectedDevice) + } + + @Test("Redactor throughput on 100 MB of log text") func redactorThroughput() throws { + let target = 100 * 1_048_576 + let formatter = LogFileWriter.timestampFormatter() + let end = Date() + var chunks: [String] = [] + var chunk = "" + var total = 0 + var day = 0 + while total < target { + let lines = Fixtures.appRecords(day: day % Fixtures.days, end: end).map(LogExport.line) + + Fixtures.deviceEntries(day: day % Fixtures.days, end: end).map { LogExport.line(for: $0, source: "Pump") } + for line in lines.sorted(by: { $0.date < $1.date }) { + chunk += LogFileWriter.format(line, formatter: formatter) + if chunk.utf8.count >= 64 * 1024 { + total += chunk.utf8.count + chunks.append(chunk) + chunk = "" + if total >= target { break } + } + } + day += 1 + } + + var values = Fixtures.storedValues + for device in [Fixtures.pumpDevice, Fixtures.cgmDevice, Fixtures.retiredDevice] { + values.add(device, .device) + } + let redactor = Redactor(exactValues: values.values) + var elapsed = Duration.zero + var leaked = 0 + for chunk in chunks { + let start = Self.clock.now + let redacted = redactor.redact(chunk) + elapsed += Self.clock.now - start + leaked += Fixtures.planted.filter { redacted.contains($0) }.count + } + let seconds = Fixtures.seconds(elapsed) + Fixtures.report("redactor", [ + "input": Fixtures.megabytes(UInt64(total)), + "chunks": "\(chunks.count)", + "seconds": String(format: "%.2f", seconds), + "mbPerSec": String(format: "%.1f", Double(total) / Self.mb / seconds) + ]) + #expect(leaked == 0) + #expect(Double(total) / Self.mb / seconds >= 20) + } + + @Test("A 20-byte BLE packet in hex survives redaction") func blePacketNotSecret() { + let packet = "Sending data - Name: CMD_7, OpCode: 7, Encoded data: a5a50c01c86e2a4b9d0f3e771122334455665a5a" + // A stored key that also occurs inside the packet must not split it + var values = Fixtures.storedValues + values.add("c86e2a4b9d0f", .secret) + #expect(Redactor(exactValues: values.values).redact(packet) == packet) + } + + @Test("Logging stays non-blocking during a 7-day export") func exportWhileLogging() async throws { + let appLog = try await Stores.shared.preparedAppLog() + let deviceLog = try await Stores.shared.preparedDeviceLog() + let appURL = await Stores.shared.appLogURL + let export = Self.makeExport(appLog, deviceLog) + let previous = TrioLogger.sink + TrioLogger.install(appLog) + defer { TrioLogger.install(previous) } + + let count = 50000 + func logAll(_ tag: String) async -> [UInt64] { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + var timebase = mach_timebase_info_data_t() + mach_timebase_info(&timebase) + var latencies = [UInt64](repeating: 0, count: count) + for index in 0 ..< count { + let start = mach_absolute_time() + debug(.loop, "#\(tag).\(index) export stress line with some text") + latencies[index] = (mach_absolute_time() - start) * UInt64(timebase.numer) / UInt64(timebase.denom) + // ~10 000 lines per second, ten times the heaviest real burst + if index % 500 == 499 { Thread.sleep(forTimeInterval: 0.05) } + } + continuation.resume(returning: latencies.sorted()) + } + } + } + func describe(_ latencies: [UInt64]) -> String { + let p50 = Double(latencies[latencies.count / 2]) / 1000 + let p99 = Double(latencies[latencies.count * 99 / 100]) / 1000 + let max = Double(latencies.last ?? 0) / 1000 + return String(format: "p50=%.1fus p99=%.1fus max=%.1fus", p50, p99, max) + } + + let baseline = await logAll("live-idle") + await appLog.flush() + + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let start = Self.clock.now + async let file = export.writeLog(.all, days: 7, into: directory) + let loggingStart = Self.clock.now + let during = await logAll("live-export") + let loggingDuration = Self.clock.now - loggingStart + _ = try await file + let exportDuration = Self.clock.now - start + await appLog.flush() + + let idleKept = Self.appRows(appURL, NSPredicate(format: "message BEGINSWITH %@", "#live-idle.")).count + let exportKept = Self.appRows(appURL, NSPredicate(format: "message BEGINSWITH %@", "#live-export.")).count + Fixtures.report("exportWhileLogging", [ + "exportSeconds": String(format: "%.2f", Fixtures.seconds(exportDuration)), + "loggingSeconds": String(format: "%.2f", Fixtures.seconds(loggingDuration)), + "idle": describe(baseline).replacingOccurrences(of: " ", with: ","), + "duringExport": describe(during).replacingOccurrences(of: " ", with: ","), + "persistedIdle": "\(idleKept)/\(count)", + "persistedDuringExport": "\(exportKept)/\(count)" + ]) + #expect(Double(during[count * 99 / 100]) < 1_000_000) + #expect(Fixtures.seconds(exportDuration) < 120) + #expect(idleKept == count) + #expect(exportKept == count) + } + + @Test("Algorithm determinations for 90 days in a test Core Data stack") func determinations() async throws { + let stack = try await CoreDataStack.createForTests() + let context = stack.newTaskContext() + let end = Date() + let sampler = MemorySampler() + let start = Self.clock.now + for day in 0 ..< Fixtures.days { + try Fixtures.insertDeterminations(day: day, end: end, into: context) + } + let insertDuration = Self.clock.now - start + let peak = sampler.stop() + + let window = DateInterval(start: end.addingTimeInterval(-7 * Fixtures.day), end: end) + let fetchStart = Self.clock.now + let fetched = try await context.perform { + let request: NSFetchRequest = OrefDetermination.fetchRequest() + request.predicate = NSPredicate( + format: "deliverAt >= %@ AND deliverAt <= %@", + window.start as NSDate, + window.end as NSDate + ) + request.fetchBatchSize = 100 + return try context.fetch(request).compactMap { determination in + determination.deliverAt.map { _ in "\(determination.reason ?? "") \(determination.glucose?.stringValue ?? "")" } + }.count + } + let fetchDuration = Self.clock.now - fetchStart + let total = try await context.perform { try context.count(for: OrefDetermination.fetchRequest()) } + Fixtures.report("determinations", [ + "rows": "\(total)", + "forecastValuesPerRow": "≤192", + "insertSeconds": String(format: "%.1f", Fixtures.seconds(insertDuration)), + "peakFootprint": Fixtures.megabytes(peak.footprint), + "fetch7dSeconds": String(format: "%.3f", Fixtures.seconds(fetchDuration)), + "fetched7d": "\(fetched)" + ]) + #expect(total == Fixtures.days * Fixtures.cyclesPerDay) + #expect(abs(fetched - 7 * Fixtures.cyclesPerDay) <= 1) + } + + @Test("Clean up stress stores") func cleanUp() async { + await Stores.shared.reset() + await Stores.typical.reset() + } +} diff --git a/TrioTests/Logging/RedactorTests.swift b/TrioTests/Logging/RedactorTests.swift new file mode 100644 index 0000000000..7484046179 --- /dev/null +++ b/TrioTests/Logging/RedactorTests.swift @@ -0,0 +1,210 @@ +import CryptoKit +import Foundation +import Testing + +@testable import Trio + +@Suite("Redactor Tests") struct RedactorTests { + private static func redactor(_ fill: (inout SensitiveValues) -> Void) -> Redactor { + var values = SensitiveValues() + fill(&values) + return Redactor(exactValues: values.values) + } + + private static func redactor(rawState: [String: Any], plugin: String) -> Redactor { + redactor { $0.add(rawState: rawState, pluginIdentifier: plugin) } + } + + @Test("Nightscout address, host, secret and its hash are replaced in every form") func nightscout() { + let secret = "synthetic secret/0001!" + let hash = Insecure.SHA1.hash(data: Data(secret.utf8)).map { String(format: "%02x", $0) }.joined() + let redactor = Self.redactor { $0.addNightscout(url: "https://my-site.example.test/", secret: secret) } + let cases = [ + ("GET https://my-site.example.test/api/v1/entries", "GET /api/v1/entries"), + ("GET HTTPS://MY-SITE.EXAMPLE.TEST/api", "GET /api"), + ("ping https://my-site.example.test failed", "ping failed"), + ("host my-site.example.test unreachable", "host unreachable"), + ("{\"url\":\"https:\\/\\/my-site.example.test\\/api\"}", "{\"url\":\"https:\\/\\/\\/api\"}"), + ("next=https%3A%2F%2Fmy-site.example.test%2Fapi", "next=%2Fapi"), + ("secret \(secret) set", "secret set"), + ("token=synthetic%20secret%2F0001%21 sent", "token= sent"), + ("body secret=synthetic+secret%2f0001%21", "body secret="), + ("api-secret: \(hash.uppercased())", "api-secret: ") + ] + for (input, expected) in cases { + #expect(redactor.redact(input) == expected) + } + } + + @Test("Trio's own identifiers are replaced") func trioIdentifiers() throws { + let defaults = try #require(UserDefaults(suiteName: UUID().uuidString)) + defaults.set("synthetic-remote-secret", forKey: "trioRemoteControlSharedSecret") + defaults.set("00aa11bb22cc33dd44ee55ff", forKey: "deviceToken") + let redactor = Self.redactor { $0.addTrioIdentifiers(defaults: defaults) } + #expect(redactor.redact("secret synthetic-remote-secret") == "secret ") + #expect(redactor.redact("push to 00AA11BB22CC33DD44EE55FF") == "push to ") + } + + @Test("Stored pump and CGM identifiers are replaced") func deviceState() { + let cases: [(String, [String: Any], String, String)] = [ + ( + "Omni", + ["podState": ["lotNo": 135_792_468, "lotSeq": 2_468_013, "address": 0x1F0E_89F0]], + "Pod Lot: 135792468 TID: 2468013 address 1F0E89F0", + "Pod Lot: TID: address " + ), + ("Omni", ["previousPodState": ["lotNo": 135_792_469]], "* lotNo: 135792469", "* lotNo: "), + ("Minimed500", ["pumpID": "654321"], "pumpID 654321 ok", "pumpID ok"), + ( + "Dana", + ["deviceName": "SYN00042AB", "pairingKey": Data([0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6])], + "name: SYN00042AB key a1b2c3d4e5f6", + "name: key " + ), + ( + "Medtrum", + ["pumpSN": Data([0x0A, 0x1B, 0x2C, 0x3D])], + "pumpSN: 0A1B2C3D adv 3d2c1b0a", + "pumpSN: adv " + ), + ("DexG6Transmitter", ["transmitterID": "8GSYN1"], "Transmitter 8GSYN1 found", "Transmitter found"), + ( + "G7CGMManager", + ["sensorID": "DXCMS1", "previousSensor": ["sensorID": "DXCMS0", "pairingCode": "1234"]], + "sensorID: DXCMS1 previousSensor: [\"pairingCode\": \"1234\", \"sensorID\": \"DXCMS0\"]", + "sensorID: previousSensor: [, \"sensorID\": \"\"]" + ), + ( + "G7CGMManager", + ["shareUsername": "synthetic.follower"], + "Signed in to Dexcom Share as synthetic.follower", + "Signed in to Dexcom Share as " + ), + ( + "LibreLoopCGMManager", + ["sensorSerial": "SYN0LIBRE3", "bleAddress": "aa:bb:cc:dd:ee:01"], + "serial=SYN0LIBRE3 bleAddress=aa:bb:cc:dd:ee:01 name=AABBCCDDEE01", + "serial= bleAddress= name=" + ), + ( + "SyaiCGMManager", + ["sensors": ["activeMAC": "AABBCCDDEE02", "pendingBind": ["mac": "AABBCCDDEE03"]]], + "activeMAC: AABBCCDDEE02 pendingBind: AABBCCDDEE03", + "activeMAC: pendingBind: " + ), + ( + "EversenseKit", + ["bleNameString": "SYN-EVR-01", "accessToken": "synthetic-access-token"], + "Connecting to: SYN-EVR-01 with synthetic-access-token", + "Connecting to: with " + ), + ( + "AccuChek", + ["sensorInfo": ["serialNumber": "SYNACC0001"], "pinCode": "987654"], + "serialNumber: SYNACC0001 pin 987654", + "serialNumber: pin " + ) + ] + for (plugin, rawState, input, expected) in cases { + #expect(Self.redactor(rawState: rawState, plugin: plugin).redact(input) == expected, "\(plugin)") + } + } + + @Test("Remote command senders and Eversense fleet secrets are replaced in transit") func inTransit() { + let redactor = Redactor(exactValues: []) + #expect( + redactor.redact("Remote command processed successfully. User: Synthetic Caregiver. Command Type: Bolus. ") + == "Remote command processed successfully. User: . Command Type: Bolus. " + ) + #expect(redactor.redact("Fleet secret: {\"key\":\"c2VjcmV0\"}\nnext") == "Fleet secret: \nnext") + } + + @Test( + "Packet dumps, clinical values, timestamps, UUIDs and plugin identifiers stay untouched", + arguments: [ + "Sending data - Name: CMD_7, OpCode: 7, Encoded data: a5a50c01c86e2a4b9d0f3e771122334455665a5a", + "Pump [receive] 0123456789abcdef0123456789abcdef01234567", + "send 1f0e89f00a1d0302abcd", + "BG 123 mg/dL, delta -4, IOB 1.25, lot 1357924680", + "2026-10-18T12:00:00.000Z 2026-10-18 14:00:00.000 +0200", + "3F2504E0-4F89-11D3-9A0C-0305E82C3301", + "com.randallknutson.DanaKit DexG6Transmitter G7CGMManager", + "Transmitter paired", + "TID search started" + ] + ) func untouched(input: String) { + let redactor = Self.redactor { + $0.add(rawState: ["podState": ["lotNo": 135_792_468, "address": 0x1F0E_89F0]], pluginIdentifier: "Omni") + $0.add(rawState: ["pairingKey": Data([0xC8, 0x6E, 0x2A, 0x4B, 0x9D, 0x0F])], pluginIdentifier: "Dana") + $0.add(rawState: ["transmitterID": "8GSYN1"], pluginIdentifier: "DexG6Transmitter") + $0.add("abc", .device) + } + #expect(redactor.redact(input) == input) + } + + @Test("Same value gets the same placeholder across inputs") func stablePlaceholders() { + let redactor = Redactor(exactValues: [ + Redactor.ExactValue(value: "SYN-DEV-0001", kind: .device), + Redactor.ExactValue(value: "SYN-DEV-0002", kind: .device), + Redactor.ExactValue(value: "synthetic.user@example.test", kind: .email) + ]) + #expect(redactor.redact("a SYN-DEV-0002 b syn-dev-0001") == "a b ") + #expect( + redactor + .redact("c SYN-DEV-0001 d SYN-DEV-0002 synthetic.user@example.test") == "c d " + ) + } + + @Test("Longest value wins where values overlap") func longestFirst() { + let redactor = Self.redactor { + $0.addNightscout(url: "https://my-site.example.test", secret: nil) + $0.add("DANA-XYZ01", .device) + } + #expect( + redactor.redact("GET https://my-site.example.test/api host my-site.example.test device dana-xyz01") + == "GET /api host device " + ) + } +} + +@Suite("Redactor Throughput") struct RedactorThroughputTests { + private static let lines = [ + "2026-10-18 12:00:00.123 +0000 Trio [Nightscout] Uploaded 12 glucose values to https://synthetic-site.example.test/api/v1/entries.json", + "2026-10-18 12:00:01.456 +0000 Pump [send] a5c3f0010203040506070809aabbccddeeff00112233", + "2026-10-18 12:00:01.789 +0000 Pump [receive] 0123456789abcdef0123456789abcdef01234567", + "2026-10-18 12:00:02.000 +0000 Algorithm enacted · BG 123 · IOB 1.25 · COB 12 · rate 0.85/30m · eventual 110", + "2026-10-18 12:00:03.000 +0000 CGM [trace] [G7CGMManager] Sensor DXSYN1 reading 123 mg/dL trend 1.2", + "2026-10-18 12:00:04.000 +0000 Trio [APS] Loop started, 3F2504E0-4F89-11D3-9A0C-0305E82C3301 at 2026-10-18T12:00:04.000Z", + "2026-10-18 12:00:05.000 +0000 Pump [connection] Connected to SYNDANA01 rssi -61" + ] + + @Test( + "Redacts at least 20 MB/s", + .enabled(if: ProcessInfo.processInfo.environment["TRIO_STRESS"] == "1") + ) func throughput() { + var chunk = "" + var index = 0 + while chunk.utf8.count < 64 * 1024 { + chunk += Self.lines[index % Self.lines.count] + "\n" + index += 1 + } + let chunks = (0 ..< 50 * 1024 * 1024 / chunk.utf8.count).map { "\($0) " + chunk } + let redactor = Redactor(exactValues: [ + Redactor.ExactValue(value: "https://synthetic-site.example.test", kind: .nightscoutURL), + Redactor.ExactValue(value: "synthetic-site.example.test", kind: .nightscoutHost), + Redactor.ExactValue(value: "synthetic-secret-value", kind: .apiSecret), + Redactor.ExactValue(value: "SYNDANA01", kind: .device), + Redactor.ExactValue(value: "DXSYN1", kind: .device) + ]) + let bytes = chunks.reduce(0) { $0 + $1.utf8.count } + let start = Date() + var output = 0 + for chunk in chunks { + output += redactor.redact(chunk).utf8.count + } + let rate = Double(bytes) / 1_048_576 / Date().timeIntervalSince(start) + print("Redactor throughput: \(String(format: "%.1f", rate)) MB/s over \(bytes / 1_048_576) MB") + #expect(output > 0) + #expect(rate >= 20) + } +} diff --git a/TrioTests/Logging/TrioLoggerTests.swift b/TrioTests/Logging/TrioLoggerTests.swift new file mode 100644 index 0000000000..81a9eb8625 --- /dev/null +++ b/TrioTests/Logging/TrioLoggerTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Trio Logger Tests", .serialized) struct TrioLoggerTests { + private func capture(_ body: () -> Void) -> [LogRecord] { + let sink = InMemoryLogSink(capacity: 20000) + TrioLogger.capturing(into: sink, body) + return sink.snapshot().filter { $0.file == "TrioLoggerTests.swift" } + } + + @Test("Records level, category, file and line for every level") func testRecordsLevels() { + let token = UUID().uuidString + let firstLine = UInt(#line) + 2 + let records = capture { + debug(.algorithm, "debug \(token)") + info(.loop, "info \(token)") + warning(.nightscout, "warning \(token)") + error(.storage, "error \(token)") + fault(.lifecycle, "fault \(token)") + }.filter { $0.message.contains(token) } + + #expect(records.map(\.level) == [.debug, .info, .warning, .error, .fault]) + #expect(records.map(\.category) == ["Algorithm", "Loop", "Nightscout", "Storage", "Lifecycle"]) + #expect(records.map(\.line) == Array(firstLine ..< firstLine + 5)) + #expect(records.allSatisfy { $0.file == "TrioLoggerTests.swift" && $0.launchID == LaunchID.current }) + #expect(records.first?.message == "debug \(token)") + } + + @Test("Error returns to the caller") func testErrorReturns() { + var reached = false + _ = capture { + error(.loop, "error returns", description: "detail") + reached = true + } + #expect(reached) + } + + @Test("Descriptions and errors follow the message as plain text") func testErrorFormatting() { + let failure = NSError(domain: "Test", code: 7) + let records = capture { + warning(.nightscout, "plain") + warning(.nightscout, "described", description: "detail") + error(.storage, "failed", error: failure) + fault(.lifecycle, "both", description: "detail", error: failure) + } + #expect(records.map(\.message) == [ + "plain", + "described: detail", + "failed: \(failure)", + "both: detail: \(failure)" + ]) + } + + @Test("Debug without console output still reaches the sink") func testPrintToConsoleFalse() { + let records = capture { + debug(.ui, "quiet", printToConsole: false) + } + #expect(records.last?.message == "quiet") + } + + @Test("10 000 debug calls stay cheap") func testDebugThroughput() { + let clock = ContinuousClock() + var elapsed: Duration = .zero + let records = capture { + elapsed = clock.measure { + for index in 0 ..< 10000 { + debug(.loop, "throughput \(index)") + } + } + } + print("10 000 debug calls took \(elapsed)") + #expect(records.count == 10000) + #expect(elapsed < .milliseconds(500)) + } + + @Test("In-memory sink keeps only the newest records") func testRingBuffer() { + let sink = InMemoryLogSink(capacity: 3) + for index in 0 ..< 5 { + sink.accept(LogRecord( + timestamp: Date(), + level: .debug, + category: "Loop", + message: "\(index)", + file: "File.swift", + function: "f()", + line: 1, + launchID: LaunchID.current + )) + } + #expect(sink.snapshot().map(\.message) == ["2", "3", "4"]) + } + + @Test("Timestamps strictly increase in call order") func testIncreasingTimestamps() { + let records = capture { + for index in 0 ..< 5000 { + debug(.loop, "tick \(index)", printToConsole: false) + } + } + let stamps = records.filter { $0.message.hasPrefix("tick ") }.map(\.timestamp) + #expect(stamps.count == 5000) + #expect(zip(stamps, stamps.dropFirst()).allSatisfy { $0 < $1 }) + } +} diff --git a/TrioTests/Logging/WatchLogIngestTests.swift b/TrioTests/Logging/WatchLogIngestTests.swift new file mode 100644 index 0000000000..6d076700c0 --- /dev/null +++ b/TrioTests/Logging/WatchLogIngestTests.swift @@ -0,0 +1,238 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Watch Log Ingest Tests") struct WatchLogIngestTests { + private final class Fixture { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let suiteName: String + let defaults: UserDefaults + let sink: InMemoryLogSink + let ingest: WatchLogIngest + + init() throws { + let suiteName = "WatchLogIngestTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName) ?? .standard + self.suiteName = suiteName + let sink = InMemoryLogSink() + self.defaults = defaults + self.sink = sink + ingest = WatchLogIngest(defaults: defaults, sink: { sink }) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: directory) + defaults.removePersistentDomain(forName: suiteName) + } + + func received(_ entries: [WatchLogEntry], installID: String = "install-a") throws -> (URL, [String: Any]) { + let url = directory.appendingPathComponent("\(UUID().uuidString).jsonl") + try WatchLogEntry.jsonLines(entries).write(to: url) + return (url, Self.metadata(entries, installID: installID)) + } + + func stage(_ entries: [WatchLogEntry], installID: String = "install-a") throws -> WatchLogIngest.Batch { + let (url, metadata) = try received(entries, installID: installID) + return try #require(WatchLogIngest.stage(url, metadata: metadata, into: directory.appendingPathComponent("staged"))) + } + + static func metadata(_ entries: [WatchLogEntry], installID: String) -> [String: Any] { + [ + WatchLogTransfer.marker: WatchLogTransfer.version, + WatchLogTransfer.firstSeq: NSNumber(value: entries.first?.seq ?? 1), + WatchLogTransfer.lastSeq: NSNumber(value: entries.last?.seq ?? 1), + WatchLogTransfer.watchBuild: "1.0 (1)", + WatchLogTransfer.installID: installID + ] + } + } + + private static let start = Date(timeIntervalSince1970: 1_800_000_000) + + private static func entries(_ seqs: ClosedRange) -> [WatchLogEntry] { + seqs.map { + WatchLogEntry( + seq: $0, + date: start.addingTimeInterval(Double($0) / 4), + level: .info, + category: .connectivity, + message: "entry \($0)" + ) + } + } + + @Test("Overlapping batches arriving together are stored once") func overlappingBatches() async throws { + let fixture = try Fixture() + let batches = try [Self.entries(1 ... 40), Self.entries(1 ... 60), Self.entries(21 ... 80)].map { try fixture.stage($0) } + + let acks = await withTaskGroup(of: UInt64.self) { group in + for batch in batches { + group.addTask { await fixture.ingest.ingest(batch) } + } + return await group.reduce(into: []) { $0.append($1) } + } + + let messages = fixture.sink.snapshot().map(\.message) + #expect(messages.count == 80) + #expect(Set(messages).count == 80) + #expect(acks.max() == 80) + } + + @Test("Entries survive a JSON lines round trip with millisecond dates") func codecRoundTrip() { + let entries = WatchLogEntry.Level.allCases.enumerated().map { index, level in + WatchLogEntry( + seq: UInt64(index + 1), + date: Self.start.addingTimeInterval(Double(index) * 0.25), + level: level, + category: WatchLogEntry.Category.allCases[index % WatchLogEntry.Category.allCases.count], + message: "message \(index) with \"quotes\"\nand a newline" + ) + } + var data = WatchLogEntry.jsonLines(entries) + data.append(Data(#"{"seq":99,"le"#.utf8)) + + #expect(WatchLogEntry.entries(fromJSONLines: data) == entries) + + let precise = WatchLogEntry( + seq: 1, + date: Self.start.addingTimeInterval(0.123), + level: .debug, + category: .data, + message: "" + ) + let decoded = WatchLogEntry.entries(fromJSONLines: WatchLogEntry.jsonLines([precise])).first + #expect(abs((decoded?.date.timeIntervalSince(precise.date)) ?? 1) < 0.0005) + } + + @Test("Ingested entries keep their level, category and date") func mapsRecords() async throws { + let fixture = try Fixture() + let levels: [(WatchLogEntry.Level, LogLevel)] = [ + (.debug, .debug), (.info, .info), (.warning, .warning), (.error, .error), (.fault, .fault) + ] + let entries = levels.enumerated().map { index, pair in + WatchLogEntry( + seq: UInt64(index + 1), + date: Self.start.addingTimeInterval(Double(index) + 0.5), + level: pair.0, + category: index == 0 ? .bolus : .treatments, + message: "message \(index)" + ) + } + + let ack = try await fixture.ingest.ingest(fixture.stage(entries)) + + let records = fixture.sink.snapshot() + #expect(ack == 5) + #expect(records.map(\.level) == levels.map(\.1)) + #expect(records.map(\.category) == ["Watch/Bolus"] + Array(repeating: "Watch/Treatments", count: 4)) + #expect(records.map(\.timestamp) == entries.map(\.date)) + #expect(records.map(\.message) == entries.map(\.message)) + #expect(records.allSatisfy { $0.file == "Watch" }) + } + + @Test("Repeated and overlapping batches are ingested once per install") func dedupes() async throws { + let fixture = try Fixture() + + let acks = try await [ + fixture.ingest.ingest(fixture.stage(Self.entries(1 ... 3))), + fixture.ingest.ingest(fixture.stage(Self.entries(1 ... 3))), + fixture.ingest.ingest(fixture.stage(Self.entries(2 ... 5))), + fixture.ingest.ingest(fixture.stage(Self.entries(1 ... 2), installID: "install-b")) + ] + #expect(acks == [3, 3, 5, 2]) + + let messages = fixture.sink.snapshot().map(\.message) + #expect(messages == ["entry 1", "entry 2", "entry 3", "entry 4", "entry 5", "entry 1", "entry 2"]) + + let sink = fixture.sink + let relaunched = WatchLogIngest(defaults: fixture.defaults, sink: { sink }) + let ack = try await relaunched.ingest(fixture.stage(Self.entries(4 ... 6))) + #expect(ack == 6) + #expect(fixture.sink.snapshot().map(\.message).suffix(1) == ["entry 6"]) + } + + @Test("A cut-off file is acknowledged only up to its last complete entry") func acksParsedEntries() async throws { + let fixture = try Fixture() + let url = fixture.directory.appendingPathComponent("cut.jsonl") + var data = WatchLogEntry.jsonLines(Self.entries(1 ... 3)) + data.append(Data(#"{"seq":4,"lev"#.utf8)) + try data.write(to: url) + let metadata = Fixture.metadata(Self.entries(1 ... 4), installID: "install-a") + let batch = try #require( + WatchLogIngest + .stage(url, metadata: metadata, into: fixture.directory.appendingPathComponent("staged")) + ) + + let ack = await fixture.ingest.ingest(batch) + + #expect(ack == 3) + #expect(fixture.sink.snapshot().map(\.message) == ["entry 1", "entry 2", "entry 3"]) + } + + @Test("Staging copies the file and removes it after ingest") func stagesCopy() async throws { + let fixture = try Fixture() + let (url, metadata) = try fixture.received(Self.entries(1 ... 2)) + + let staging = fixture.directory.appendingPathComponent("staged") + let batch = try #require(WatchLogIngest.stage(url, metadata: metadata, into: staging)) + try FileManager.default.removeItem(at: url) + #expect(batch.installID == "install-a") + #expect(batch.firstSeq == 1 && batch.lastSeq == 2) + + await fixture.ingest.ingest(batch) + #expect(fixture.sink.snapshot().count == 2) + #expect(!FileManager.default.fileExists(atPath: batch.file.path)) + } + + @Test("Files without valid metadata or over the size limit are rejected") func rejectsInvalidFiles() throws { + let fixture = try Fixture() + let staging = fixture.directory.appendingPathComponent("staged") + let (url, metadata) = try fixture.received(Self.entries(1 ... 2)) + + var wrongVersion = metadata + wrongVersion[WatchLogTransfer.marker] = 2 + var noInstall = metadata + noInstall[WatchLogTransfer.installID] = nil + var reversed = metadata + reversed[WatchLogTransfer.firstSeq] = NSNumber(value: 5) + var noLastSeq = metadata + noLastSeq[WatchLogTransfer.lastSeq] = nil + + for invalid in [nil, [:], wrongVersion, noInstall, reversed, noLastSeq] as [[String: Any]?] { + #expect(WatchLogIngest.stage(url, metadata: invalid, into: staging) == nil) + } + + let oversized = fixture.directory.appendingPathComponent("oversized.jsonl") + try Data(count: WatchLogTransfer.maxFileSize + 1).write(to: oversized) + #expect(WatchLogIngest.stage(oversized, metadata: metadata, into: staging) == nil) + + let missing = fixture.directory.appendingPathComponent("missing.jsonl") + #expect(WatchLogIngest.stage(missing, metadata: metadata, into: staging) == nil) + } + + @Test("Legacy string payloads still become watch records in order") func parsesLegacyLines() { + let received = Date(timeIntervalSince1970: 2_000_000_000) + let payload = """ + [2026-01-02T03:04:05+0000] [WatchState.swift:12] send() → first + no timestamp here + + [2026-01-02T03:04:06+0000] [WatchLogger.swift:40] flush() → third + """ + + let records = WatchLogIngest.legacyRecords(from: payload, receivedAt: received) + + #expect(records.map(\.message) == [ + "[WatchState.swift:12] send() → first", + "no timestamp here", + "[WatchLogger.swift:40] flush() → third" + ]) + #expect(records.allSatisfy { $0.category == "Watch/Legacy" && $0.level == .debug && $0.file == "Watch" }) + #expect(records.map(\.timestamp) == [ + Date(timeIntervalSince1970: 1_767_323_045), + received, + Date(timeIntervalSince1970: 1_767_323_046) + ]) + } +} diff --git a/TrioTests/Logging/WatchLogRingTests.swift b/TrioTests/Logging/WatchLogRingTests.swift new file mode 100644 index 0000000000..751743098a --- /dev/null +++ b/TrioTests/Logging/WatchLogRingTests.swift @@ -0,0 +1,150 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Watch Log Ring Tests") struct WatchLogRingTests { + private final class Fixture { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let suiteName: String + let defaults: UserDefaults + + init() { + suiteName = "WatchLogRingTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) ?? .standard + } + + deinit { + try? FileManager.default.removeItem(at: directory) + defaults.removePersistentDomain(forName: suiteName) + } + + func ring(_ limits: WatchLogRing.Limits = WatchLogRing.Limits()) -> WatchLogRing { + WatchLogRing(directory: directory, defaults: defaults, limits: limits) + } + + func segmentFiles() -> [URL] { + ((try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []) + .filter { $0.pathExtension == "jsonl" } + } + } + + private static func drafts(_ count: Int, from start: Date = Date(), message: String = "entry") -> [WatchLogDraft] { + (0 ..< count).map { + WatchLogDraft( + date: start.addingTimeInterval(Double($0)), + level: .info, + category: .connectivity, + message: "\(message) \($0)" + ) + } + } + + @Test("Appended entries get increasing seqs and read back in order") func append() { + let fixture = Fixture() + let ring = fixture.ring() + + ring.append(Self.drafts(3)) + ring.append(Self.drafts(2, message: "later")) + + let entries = ring.entries(after: 0) + #expect(entries.map(\.seq) == [1, 2, 3, 4, 5]) + #expect(entries.map(\.message) == ["entry 0", "entry 1", "entry 2", "later 0", "later 1"]) + #expect(ring.entries(after: 3).map(\.seq) == [4, 5]) + #expect(ring.lastSeq == 5) + } + + @Test("Oldest segments are dropped once over the entry cap") func capsByCount() throws { + let fixture = Fixture() + let ring = fixture.ring(WatchLogRing.Limits(maxEntries: 10, segmentEntries: 4)) + + ring.append(Self.drafts(25)) + ring.prune() + + let seqs = ring.entries(after: 0).map(\.seq) + #expect(ring.count <= 10) + #expect(seqs.last == 25) + let first = try #require(seqs.first) + #expect(seqs == Array(first ... 25)) + #expect(fixture.segmentFiles().count == (ring.count + 3) / 4) + } + + @Test("Oldest segments are dropped once over the size cap") func capsBySize() { + let fixture = Fixture() + let ring = fixture.ring(WatchLogRing.Limits(maxBytes: 2000, segmentEntries: 5)) + + ring.append(Self.drafts(50, message: String(repeating: "x", count: 60))) + ring.prune() + + #expect(ring.bytes <= 2000) + #expect(ring.entries(after: 0).last?.seq == 50) + } + + @Test("Segments older than the age cap are dropped") func capsByAge() { + let fixture = Fixture() + let ring = fixture.ring(WatchLogRing.Limits(maxAge: 60 * 60, segmentEntries: 5)) + let now = Date() + + ring.append(Self.drafts(5, from: now.addingTimeInterval(-3 * 60 * 60))) + ring.append(Self.drafts(5, from: now.addingTimeInterval(-60))) + ring.prune(now: now) + + #expect(ring.entries(after: 0).map(\.seq) == [6, 7, 8, 9, 10]) + + ring.prune(now: now.addingTimeInterval(2 * 60 * 60)) + #expect(ring.entries(after: 0).isEmpty) + #expect(fixture.segmentFiles().isEmpty) + } + + @Test("Seqs and the install ID survive re-creation, even with every segment gone") func persistsAcrossLaunches() throws { + let fixture = Fixture() + let first = fixture.ring() + first.append(Self.drafts(4)) + let installID = first.installID + + let second = fixture.ring() + second.append(Self.drafts(1, message: "relaunch")) + #expect(second.installID == installID) + #expect(second.entries(after: 0).map(\.seq) == [1, 2, 3, 4, 5]) + + for file in fixture.segmentFiles() { + try FileManager.default.removeItem(at: file) + } + let third = fixture.ring() + third.append(Self.drafts(1)) + #expect(third.entries(after: 0).map(\.seq) == [6]) + } + + @Test("An ack moves the unsent window and persists, clamped to what exists") func unsentWindowAfterAck() { + let fixture = Fixture() + let ring = fixture.ring() + ring.append(Self.drafts(6)) + + ring.acknowledge(4) + #expect(ring.entries(after: ring.ackedSeq).map(\.seq) == [5, 6]) + #expect(ring.entries(after: 0).count == 6) + + ring.acknowledge(2) + #expect(ring.ackedSeq == 4) + ring.acknowledge(99) + #expect(ring.ackedSeq == 6) + + #expect(fixture.ring().ackedSeq == 6) + } + + @Test("A torn final line doesn't swallow later appends") func tornWrite() throws { + let fixture = Fixture() + fixture.ring().append(Self.drafts(2)) + let segment = try #require(fixture.segmentFiles().first) + let handle = try FileHandle(forWritingTo: segment) + try handle.seekToEnd() + try handle.write(contentsOf: Data(#"{"seq":3,"da"#.utf8)) + try handle.close() + + let ring = fixture.ring() + ring.append(Self.drafts(1, message: "after")) + + #expect(ring.entries(after: 0).map(\.message) == ["entry 0", "entry 1", "after 0"]) + #expect(fixture.segmentFiles().count == 2) + } +} diff --git a/TrioTests/ShareLogsTests/ShareLogsTests.swift b/TrioTests/ShareLogsTests/ShareLogsTests.swift deleted file mode 100644 index 759dced65b..0000000000 --- a/TrioTests/ShareLogsTests/ShareLogsTests.swift +++ /dev/null @@ -1,826 +0,0 @@ -import Foundation -import Testing -@testable import Trio - -@Suite("Share Logs Function Tests") struct ShareLogsTests { - // MARK: - Mockup Directory and File Creation - - /// Creates a temporary directory for testing - private func createTemporaryDirectory() throws -> URL { - let tempDir = FileManager.default.temporaryDirectory - let testDir = tempDir.appendingPathComponent("LogZipTests-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: testDir, withIntermediateDirectories: true) - return testDir - } - - /// Creates a complete test environment with logExports directory and 6 files each (12 total) - /// Includes files from 2 weeks ago, recent days, and 1 future date - private func createTestLogEnvironment() throws -> (logsDir: URL, logFiles: [URL], watchLogFiles: [URL]) { - let tempDir = try createTemporaryDirectory() - let logsDir = tempDir.appendingPathComponent("logExports") - try FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) - - // Create 6 log files with varied dates: 2 weeks ago, recent days, and 1 future - let calendar = Calendar.current - let today = Date() - - let testDates = [ - calendar.date(byAdding: .day, value: -14, to: today)!, // 2 weeks back - calendar.date(byAdding: .day, value: -7, to: today)!, // 1 week back - calendar.date(byAdding: .day, value: -3, to: today)!, // 3 days back - calendar.date(byAdding: .day, value: -1, to: today)!, // yesterday - today, // today - calendar.date(byAdding: .day, value: 1, to: today)! // 1 day future - ] - - var logFiles: [URL] = [] - var watchLogFiles: [URL] = [] - - for (index, date) in testDates.enumerated() { - let logName = Formatter.logDateFormatter.string(from: date) - - // Create regular log file - let logFileURL = logsDir.appendingPathComponent("\(logName).log") - let logContent = """ - \(Formatter.iso8601.string(from: date)) [INFO] Application started for \(logName) (file \(index + 1)/6) - \(Formatter.iso8601.string(from: date.addingTimeInterval(60))) [DEBUG] Processing glucose data - \(Formatter.iso8601.string(from: date.addingTimeInterval(120))) [WARN] Low battery warning - \(Formatter.iso8601.string(from: date.addingTimeInterval(180))) [ERROR] Failed to connect to CGM - \(Formatter.iso8601.string(from: date.addingTimeInterval(240))) [INFO] Meal bolus calculated: 2.5 units - \(Formatter.iso8601.string(from: date.addingTimeInterval(300))) [DEBUG] Background refresh completed - """ - try logContent.write(to: logFileURL, atomically: true, encoding: .utf8) - - // Set file creation date to match the log date - try FileManager.default.setAttributes([.creationDate: date], ofItemAtPath: logFileURL.path) - logFiles.append(logFileURL) - - // Create watch log file - let watchLogFileURL = logsDir.appendingPathComponent("watch_\(logName).log") - let watchLogContent = """ - \(Formatter.iso8601.string(from: date)) [WATCH] Heart rate: \(70 + index * 2) BPM for \(logName) (file \(index + 1)/6) - \(Formatter.iso8601 - .string(from: date.addingTimeInterval(60))) [WATCH] Activity: \(["Walking", "Running", "Cycling", "Swimming", - "Resting", "Sleeping"][index]) - \(Formatter.iso8601.string(from: date.addingTimeInterval(120))) [WATCH] Battery: \(85 - index * 5)% - \(Formatter.iso8601.string(from: date.addingTimeInterval(180))) [WATCH] Steps: \(5000 + index * 1000) - \(Formatter.iso8601 - .string(from: date.addingTimeInterval(240))) [WATCH] Sleep tracking: \(index % 2 == 0 ? "started" : "stopped") - \(Formatter.iso8601 - .string(from: date.addingTimeInterval(300))) [WATCH] Workout session: \(index % 3 == 0 ? "active" : "paused") - """ - try watchLogContent.write(to: watchLogFileURL, atomically: true, encoding: .utf8) - - // Set file creation date to match the log date - try FileManager.default.setAttributes([.creationDate: date], ofItemAtPath: watchLogFileURL.path) - watchLogFiles.append(watchLogFileURL) - } - - return (logsDir, logFiles, watchLogFiles) - } - - /// Creates test environment with specific log file names and dates - private func createTestLogEnvironmentWithNames(_ names: [String]) throws - -> (logsDir: URL, logFiles: [URL], watchLogFiles: [URL]) - { - let tempDir = try createTemporaryDirectory() - let logsDir = tempDir.appendingPathComponent("logExports") - try FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) - - var logFiles: [URL] = [] - var watchLogFiles: [URL] = [] - - for (index, name) in names.enumerated() { - // Create regular log file - let logFileURL = logsDir.appendingPathComponent("\(name).log") - let logContent = """ - [\(Date())] [INFO] Test log entry for \(name) (file \(index + 1)/\(names.count)) - [\(Date())] [DEBUG] Debug message for testing - [\(Date())] [ERROR] Error simulation for \(name) - """ - try logContent.write(to: logFileURL, atomically: true, encoding: .utf8) - logFiles.append(logFileURL) - - // Create watch log file - let watchLogFileURL = logsDir.appendingPathComponent("watch_\(name).log") - let watchLogContent = """ - [\(Date())] [WATCH] Test watch log entry for \(name) (file \(index + 1)/\(names.count)) - [\(Date())] [WATCH] Heart rate: \(65 + index * 3) BPM - [\(Date())] [WATCH] Activity tracking active - """ - try watchLogContent.write(to: watchLogFileURL, atomically: true, encoding: .utf8) - watchLogFiles.append(watchLogFileURL) - } - - return (logsDir, logFiles, watchLogFiles) - } - - // MARK: - Future File Exclusion Tests - - @Test("future files should not be included in zip-ready structure") func futureFilesNotInZipStructure() throws { - let testEnv = try createTestLogEnvironment() // Creates files including 1 future file - defer { try? FileManager.default.removeItem(at: testEnv.logsDir.deletingLastPathComponent()) } - - let calendar = Calendar.current - let today = Date() - let futureDate = calendar.date(byAdding: .day, value: 1, to: today)! - - // Find the future file that was created - var futureFiles: [URL] = [] - for file in testEnv.logFiles + testEnv.watchLogFiles { - let attributes = try FileManager.default.attributesOfItem(atPath: file.path) - if let creationDate = attributes[.creationDate] as? Date, - calendar.isDate(creationDate, inSameDayAs: futureDate) - { - futureFiles.append(file) - } - } - - #expect(futureFiles.count == 2) // Should have 1 regular + 1 watch future file - - // Now filter for zip-ready files (should exclude future files) - let expectedDates = [ - calendar.date(byAdding: .day, value: 0, to: today)!, // today - calendar.date(byAdding: .day, value: -1, to: today)!, // yesterday - calendar.date(byAdding: .day, value: -2, to: today)!, // 2 days ago - calendar.date(byAdding: .day, value: -3, to: today)! // 3 days ago - ] - - var zipReadyFiles: [URL] = [] - for file in testEnv.logFiles + testEnv.watchLogFiles { - let attributes = try FileManager.default.attributesOfItem(atPath: file.path) - if let creationDate = attributes[.creationDate] as? Date { - for expectedDate in expectedDates { - if calendar.isDate(creationDate, inSameDayAs: expectedDate) { - zipReadyFiles.append(file) - break - } - } - } - } - - // Verify future files are NOT in zip-ready files - for futureFile in futureFiles { - #expect(!zipReadyFiles.contains(futureFile)) - } - - // Verify we have some zip-ready files (but not the future ones) - #expect(zipReadyFiles.count >= 2) // At least today's files - #expect(zipReadyFiles.count < (testEnv.logFiles.count + testEnv.watchLogFiles.count)) // But not all files - } - - @Test("future files should not be included in fallback scenario") func futureFilesNotInFallback() throws { - let testEnv = try createTestLogEnvironment() // Creates files including 1 future file - defer { try? FileManager.default.removeItem(at: testEnv.logsDir.deletingLastPathComponent()) } - - let calendar = Calendar.current - let today = Date() - - // Simulate fallback scenario - only current log file should be returned - let currentLogName = SimpleLogReporter.currentLogName() - - // Find today's files (what would be used as fallback) - var todaysFiles: [URL] = [] - for file in testEnv.logFiles + testEnv.watchLogFiles { - if file.lastPathComponent.contains(currentLogName) { - todaysFiles.append(file) - } - } - - // Verify fallback only includes today's files, not future files - for file in todaysFiles { - let attributes = try FileManager.default.attributesOfItem(atPath: file.path) - if let creationDate = attributes[.creationDate] as? Date { - // Should be today or earlier, never future - #expect(creationDate <= today.addingTimeInterval(86400)) // Allow for small time differences - #expect(!calendar.isDate(creationDate, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!)) - } - } - - #expect(todaysFiles.count <= 2) // At most today's regular + watch file - } - - @Test("future files should be cleaned up") func futureFilesCleanedUp() throws { - let tempDir = try createTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: tempDir) } - - let logsDir = tempDir.appendingPathComponent("logExports") - try FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) - - let calendar = Calendar.current - let today = Date() - var allCreatedFiles: [URL] = [] - - // Create files including future dates - for i in -7 ... 7 { // 1 week before to 1 week after - guard let date = calendar.date(byAdding: .day, value: i, to: today) else { continue } - let logName = Formatter.logDateFormatter.string(from: date) - - let logFileURL = logsDir.appendingPathComponent("\(logName).log") - let logContent = "Log entry for \(logName) (day offset: \(i))\n" - try logContent.write(to: logFileURL, atomically: true, encoding: .utf8) - allCreatedFiles.append(logFileURL) - - // Set creation date to match the log date - try FileManager.default.setAttributes([.creationDate: date], ofItemAtPath: logFileURL.path) - } - - #expect(allCreatedFiles.count == 15) // 7 past + today + 7 future - - // Files to keep: today through 3 days ago (4 days total) - let keepDates = [ - today, // today (day 0) - calendar.date(byAdding: .day, value: -1, to: today)!, // yesterday - calendar.date(byAdding: .day, value: -2, to: today)!, // 2 days ago - calendar.date(byAdding: .day, value: -3, to: today)! // 3 days ago - ] - - var filesToKeep: [URL] = [] - var filesToRemove: [URL] = [] - - for fileURL in allCreatedFiles { - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - if let creationDate = attributes[.creationDate] as? Date { - var shouldKeep = false - for keepDate in keepDates { - if calendar.isDate(creationDate, inSameDayAs: keepDate) { - shouldKeep = true - break - } - } - - if shouldKeep { - filesToKeep.append(fileURL) - } else { - filesToRemove.append(fileURL) - } - } - } - - // Verify future files are marked for removal - let futureDate = calendar.date(byAdding: .day, value: 1, to: today)! - var futureFilesMarkedForRemoval = 0 - - for fileURL in filesToRemove { - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - if let creationDate = attributes[.creationDate] as? Date, - calendar.isDate(creationDate, inSameDayAs: futureDate) - { - futureFilesMarkedForRemoval += 1 - } - } - - #expect(futureFilesMarkedForRemoval >= 1) // At least 1 future file should be removed - #expect(filesToKeep.count == 4) // Should keep exactly 4 days worth - #expect(filesToRemove.count == 11) // Should remove 11 files (7 old + 1 future + 3 more old) - - // Verify that no future files are in the keep list - for fileURL in filesToKeep { - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - if let creationDate = attributes[.creationDate] as? Date { - #expect(creationDate <= today) // All kept files should be today or earlier - } - } - } - - @Test("getAllLogNames should not include future dates") func getAllLogNamesNoFuture() { - let logNames = SimpleLogReporter.getAllLogNames() - let calendar = Calendar.current - let today = Date() - - // Parse each log name and verify it's not in the future - for logName in logNames { - if let logDate = Formatter.logDateFormatter.date(from: logName) { - #expect(logDate <= today.addingTimeInterval(86400)) // Allow small time buffer - #expect(!calendar.isDate(logDate, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!)) - } - } - - // Verify we get expected retention period (4 days: today, yesterday, 2 days ago, 3 days ago) - #expect(logNames.count == 4) - - // Verify the dates are in the expected range - if let firstLogDate = Formatter.logDateFormatter.date(from: logNames[0]) { - let daysDiff = calendar.dateComponents([.day], from: firstLogDate, to: today).day ?? 0 - #expect(daysDiff >= 0) // First log should be today or earlier - #expect(daysDiff <= 3) // First log should not be more than 3 days ago - } - } - - // MARK: - Disk+Directory Tests - - @Test("Disk AppDirectory constants should be correctly defined") func diskAppDirectoryConstants() { - #expect(Disk.AppDirectory.logs == "logs") - #expect(Disk.AppDirectory.logExports == "logExports") - } - - @Test("Disk AppFilenames should generate correct filenames") func diskAppFilenamesGeneration() { - let logName = "2025-06-19" - - let logFilename = Disk.AppFilenames.logFile(name: logName) - #expect(logFilename == "2025-06-19.log") - - let watchLogFilename = Disk.AppFilenames.watchLogFile(name: logName) - #expect(watchLogFilename == "watch_2025-06-19.log") - } - - @Test("Disk AppDirectoryURL functions should work correctly") func diskAppDirectoryURLFunctions() throws { - // Test logs directory URL - let logsURL = try Disk.AppDirectoryURL.logs() - #expect(logsURL.lastPathComponent == "logs") - #expect(logsURL.pathExtension.isEmpty) // Should be a directory - - // Test log exports directory URL - let exportsURL = try Disk.AppDirectoryURL.logExports() - #expect(exportsURL.lastPathComponent == "logExports") - #expect(exportsURL.pathExtension.isEmpty) // Should be a directory - - // Test log file URL - let logName = "2025-06-19" - let logFileURL = try Disk.AppDirectoryURL.logFile(name: logName) - #expect(logFileURL.lastPathComponent == "2025-06-19.log") - #expect(logFileURL.pathExtension == "log") - #expect(logFileURL.deletingLastPathComponent().lastPathComponent == "logs") - - // Test watch log file URL - let watchLogFileURL = try Disk.AppDirectoryURL.watchLogFile(name: logName) - #expect(watchLogFileURL.lastPathComponent == "watch_2025-06-19.log") - #expect(watchLogFileURL.pathExtension == "log") - #expect(watchLogFileURL.deletingLastPathComponent().lastPathComponent == "logs") - - // Test that file URLs are properly nested under directory URLs - #expect(logFileURL.deletingLastPathComponent().path == logsURL.path) - #expect(watchLogFileURL.deletingLastPathComponent().path == logsURL.path) - - // Test edge cases - let emptyLogFileURL = try Disk.AppDirectoryURL.logFile(name: "") - #expect(emptyLogFileURL.lastPathComponent == ".log") - - let emptyWatchLogFileURL = try Disk.AppDirectoryURL.watchLogFile(name: "") - #expect(emptyWatchLogFileURL.lastPathComponent == "watch_.log") - } - - // MARK: - File System Tests (Independent of logItems method) - - @Test("should create and read log files correctly") func createAndReadLogFiles() throws { - let testEnv = try createTestLogEnvironment() // Creates 6 files with varied dates - defer { try? FileManager.default.removeItem(at: testEnv.logsDir.deletingLastPathComponent()) } - - // Verify 6 log files were created (including future and 2-week-old files) - #expect(testEnv.logFiles.count == 6) - #expect(testEnv.watchLogFiles.count == 6) - - // Verify directory is logExports - #expect(testEnv.logsDir.lastPathComponent == "logExports") - - // Verify file contents and dates - for (index, logFile) in testEnv.logFiles.enumerated() { - #expect(FileManager.default.fileExists(atPath: logFile.path)) - let content = try String(contentsOf: logFile) - #expect(content.contains("INFO")) - #expect(content.contains("file \(index + 1)/6")) // Verify unique content per file - - // Check creation date was set correctly - let attributes = try FileManager.default.attributesOfItem(atPath: logFile.path) - let creationDate = attributes[.creationDate] as? Date - #expect(creationDate != nil) - } - - for (index, watchLogFile) in testEnv.watchLogFiles.enumerated() { - #expect(FileManager.default.fileExists(atPath: watchLogFile.path)) - let content = try String(contentsOf: watchLogFile) - #expect(content.contains("WATCH")) - #expect(content.contains("file \(index + 1)/6")) // Verify unique content per file - - // Check for varied content (different heart rates, activities, etc.) - if index == 0 { - #expect(content.contains("Heart rate: 70 BPM")) - #expect(content.contains("Activity: Walking")) - } else if index == 5 { - #expect(content.contains("Heart rate: 80 BPM")) - #expect(content.contains("Activity: Sleeping")) - } - } - } - - @Test("should handle log file naming conventions") func logFileNamingConventions() throws { - let testEnv = try createTestLogEnvironmentWithNames(["2025-06-19"]) - defer { try? FileManager.default.removeItem(at: testEnv.logsDir.deletingLastPathComponent()) } - - let logFile = testEnv.logFiles[0] - let watchLogFile = testEnv.watchLogFiles[0] - - // Check naming conventions - #expect(logFile.lastPathComponent == "2025-06-19.log") - #expect(watchLogFile.lastPathComponent == "watch_2025-06-19.log") - - // Check they're in the logExports directory - #expect(logFile.deletingLastPathComponent().lastPathComponent == "logExports") - #expect(watchLogFile.deletingLastPathComponent().lastPathComponent == "logExports") - } - - @Test("should handle realistic log file content") func realisticLogFileContent() throws { - let testEnv = try createTestLogEnvironment() - defer { try? FileManager.default.removeItem(at: testEnv.logsDir.deletingLastPathComponent()) } - - #expect(!testEnv.logFiles.isEmpty) - #expect(!testEnv.watchLogFiles.isEmpty) - - // Check realistic content in log files - for logFile in testEnv.logFiles { - let content = try String(contentsOf: logFile) - #expect(content.contains("Application started")) - #expect(content.contains("Processing glucose data")) - #expect(content.contains("Meal bolus calculated")) - } - - // Check realistic content in watch log files - for watchLogFile in testEnv.watchLogFiles { - let content = try String(contentsOf: watchLogFile) - #expect(content.contains("Heart rate")) - #expect(content.contains("Activity:")) - #expect(content.contains("Sleep tracking")) - } - } - - @Test("should create zip-ready file structure") func zipReadyFileStructure() throws { - // Create test environment with files spanning more than retention period - let testEnv = try createTestLogEnvironment() // Creates 6 files with varied dates - defer { try? FileManager.default.removeItem(at: testEnv.logsDir.deletingLastPathComponent()) } - - // Filter files to only include those from the last 4 days (today through 3 days ago) - let calendar = Calendar.current - let today = Date() - - // Create the 4 expected dates: today, yesterday, 2 days ago, 3 days ago - let expectedDates = [ - calendar.date(byAdding: .day, value: 0, to: today)!, // today - calendar.date(byAdding: .day, value: -1, to: today)!, // yesterday - calendar.date(byAdding: .day, value: -2, to: today)!, // 2 days ago - calendar.date(byAdding: .day, value: -3, to: today)! // 3 days ago - ] - - var recentLogFiles: [URL] = [] - var recentWatchLogFiles: [URL] = [] - - // Filter files to only include those matching the 4-day retention period - for logFile in testEnv.logFiles { - let attributes = try FileManager.default.attributesOfItem(atPath: logFile.path) - if let creationDate = attributes[.creationDate] as? Date { - // Check if creation date matches any of our expected 4 days - for expectedDate in expectedDates { - if calendar.isDate(creationDate, inSameDayAs: expectedDate) { - recentLogFiles.append(logFile) - break - } - } - } - } - - for watchLogFile in testEnv.watchLogFiles { - let attributes = try FileManager.default.attributesOfItem(atPath: watchLogFile.path) - if let creationDate = attributes[.creationDate] as? Date { - // Check if creation date matches any of our expected 4 days - for expectedDate in expectedDates { - if calendar.isDate(creationDate, inSameDayAs: expectedDate) { - recentWatchLogFiles.append(watchLogFile) - break - } - } - } - } - - // Should have exactly 4 files each (today through 3 days ago) - // But may be fewer if some days don't exist in our test data - #expect(recentLogFiles.count >= 1) // At least today's file - #expect(recentLogFiles.count <= 4) // At most 4 days worth - #expect(recentWatchLogFiles.count >= 1) // At least today's watch file - #expect(recentWatchLogFiles.count <= 4) // At most 4 days worth - - let allRecentFiles = recentLogFiles + recentWatchLogFiles - #expect(allRecentFiles.count >= 2) // At least today's regular + watch - #expect(allRecentFiles.count <= 8) // At most 4 days × 2 file types = 8 files - - // All recent files should be in the logExports directory structure - for file in allRecentFiles { - #expect(file.deletingLastPathComponent().path == testEnv.logsDir.path) - #expect(file.deletingLastPathComponent().lastPathComponent == "logExports") - #expect(FileManager.default.fileExists(atPath: file.path)) - - // Verify these files are from the correct date range (today through 3 days ago) - let attributes = try FileManager.default.attributesOfItem(atPath: file.path) - if let creationDate = attributes[.creationDate] as? Date { - var isInExpectedRange = false - for expectedDate in expectedDates { - if calendar.isDate(creationDate, inSameDayAs: expectedDate) { - isInExpectedRange = true - break - } - } - #expect(isInExpectedRange) // File should be from one of the 4 expected days - } - } - - // Verify file sizes are reasonable (not empty) - for file in allRecentFiles { - let attributes = try FileManager.default.attributesOfItem(atPath: file.path) - let fileSize = attributes[.size] as? Int64 ?? 0 - #expect(fileSize > 0) - } - } - - // MARK: - File Operations Tests - - @Test("should simulate file copying for staging") func simulateFileCopying() throws { - let testEnv = try createTestLogEnvironmentWithNames(["2025-06-19", "2025-06-18"]) - defer { try? FileManager.default.removeItem(at: testEnv.logsDir.deletingLastPathComponent()) } - - // Create a staging directory like logItems() would - let stagingDir = testEnv.logsDir.deletingLastPathComponent().appendingPathComponent("staging") - try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: stagingDir) } - - // Copy files to staging (simulating what logItems does) - var copiedFiles: [URL] = [] - - for logFile in testEnv.logFiles { - let destURL = stagingDir.appendingPathComponent(logFile.lastPathComponent) - try FileManager.default.copyItem(at: logFile, to: destURL) - copiedFiles.append(destURL) - } - - for watchLogFile in testEnv.watchLogFiles { - let destURL = stagingDir.appendingPathComponent(watchLogFile.lastPathComponent) - try FileManager.default.copyItem(at: watchLogFile, to: destURL) - copiedFiles.append(destURL) - } - - // Verify all files were copied correctly - #expect(copiedFiles.count == 4) // 2 regular + 2 watch logs - - for copiedFile in copiedFiles { - #expect(FileManager.default.fileExists(atPath: copiedFile.path)) - - // Verify content is identical - let originalPath = testEnv.logsDir.appendingPathComponent(copiedFile.lastPathComponent) - if FileManager.default.fileExists(atPath: originalPath.path) { - let originalContent = try String(contentsOf: originalPath) - let copiedContent = try String(contentsOf: copiedFile) - #expect(originalContent == copiedContent) - } - } - } - - @Test("should handle export directory creation") func exportDirectoryCreation() throws { - let tempDir = try createTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: tempDir) } - - // Simulate creating a logExports directory like logItems() does - let exportsDir = tempDir.appendingPathComponent("logExports") - - // Create directory if it doesn't exist - if !FileManager.default.fileExists(atPath: exportsDir.path) { - try FileManager.default.createDirectory(at: exportsDir, withIntermediateDirectories: true) - } - - #expect(FileManager.default.fileExists(atPath: exportsDir.path)) - #expect(exportsDir.lastPathComponent == "logExports") - - // Test creating a zip file name with timestamp - let timestamp = Formatter.iso8601.string(from: Date()).replacingOccurrences(of: ":", with: "-") - let zipFileName = "Trio-Logs-\(timestamp).zip" - let zipFileURL = exportsDir.appendingPathComponent(zipFileName) - - #expect(zipFileURL.lastPathComponent.hasPrefix("Trio-Logs-")) - #expect(zipFileURL.pathExtension == "zip") - #expect(zipFileURL.deletingLastPathComponent().path == exportsDir.path) - } - - @Test("should test cleanup functionality with test files") func cleanupFunctionality() throws { - let tempDir = try createTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: tempDir) } - - let logsDir = tempDir.appendingPathComponent("logExports") - try FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) - - // Create log files with specific dates spanning more than retention period - let calendar = Calendar.current - let today = Date() - var createdFiles: [URL] = [] - - // Create files from 2 weeks ago to 1 day in future (16 total days) - for i in -14 ... 1 { - guard let date = calendar.date(byAdding: .day, value: i, to: today) else { continue } - let logName = Formatter.logDateFormatter.string(from: date) - - let logFileURL = logsDir.appendingPathComponent("\(logName).log") - let logContent = "Log entry for \(logName) (day offset: \(i))\n" - try logContent.write(to: logFileURL, atomically: true, encoding: .utf8) - createdFiles.append(logFileURL) - - // Set creation date to match the log date - try FileManager.default.setAttributes([.creationDate: date], ofItemAtPath: logFileURL.path) - } - - #expect(createdFiles.count == 16) // 14 past days + today + 1 future day - - // Simulate cleanup logic (files older than 4 days should be removed) - let retentionDays = 4 - guard let cutoffDate = calendar.date(byAdding: .day, value: -retentionDays, to: today) else { - throw NSError(domain: "TestError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not create cutoff date"]) - } - - var filesToRemove: [URL] = [] - var filesToKeep: [URL] = [] - - for fileURL in createdFiles { - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - if let creationDate = attributes[.creationDate] as? Date { - if creationDate < cutoffDate { - filesToRemove.append(fileURL) - } else { - filesToKeep.append(fileURL) - } - } - } - - #expect(filesToRemove.count >= 10) // Should remove files older than 4 days (about 10+ files) - #expect(filesToKeep.count <= 6) // Should keep recent files (4 days + today + 1 future = ~6 files) - - // Verify the cleanup logic identifies the right files - for fileURL in filesToRemove { - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - let creationDate = attributes[.creationDate] as? Date - #expect(creationDate != nil) - #expect(creationDate! < cutoffDate) - } - - for fileURL in filesToKeep { - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - let creationDate = attributes[.creationDate] as? Date - #expect(creationDate != nil) - #expect(creationDate! >= cutoffDate) - } - } - - @Test("should test cleanup helper function behavior") func cleanupHelperFunctionBehavior() throws { - let tempDir = try createTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: tempDir) } - - let testDir = tempDir.appendingPathComponent("testCleanup") - try FileManager.default.createDirectory(at: testDir, withIntermediateDirectories: true) - - let calendar = Calendar.current - let today = Date() - - // Create test files with various ages - var testFiles: [URL] = [] - for i in 0 ..< 10 { - guard let date = calendar.date(byAdding: .day, value: -i, to: today) else { continue } - - let fileName = "test-file-\(i).log" - let fileURL = testDir.appendingPathComponent(fileName) - let content = "Test content for day \(i)\n" - try content.write(to: fileURL, atomically: true, encoding: .utf8) - - // IMPORTANT: Set creation date attribute so fallback logic can use it - try FileManager.default.setAttributes([.creationDate: date], ofItemAtPath: fileURL.path) - testFiles.append(fileURL) - } - - #expect(testFiles.count == 10) - - // Test cleanup logic behavior (simulating cleanupDirectory parameters) - let retentionDays = 4 - guard let cutoffDate = calendar.date(byAdding: .day, value: -retentionDays, to: today) else { - throw NSError(domain: "TestError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not create cutoff date"]) - } - - // Debug: Print the cutoff date to understand what we're comparing against - // print("Cutoff date: \(cutoffDate)") - - // Simulate the cleanup directory logic from the patch - let fileManager = FileManager.default - let contents = try fileManager.contentsOfDirectory( - at: testDir, - includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey], - options: [.skipsHiddenFiles] - ) - - var filesToDelete: [URL] = [] - for fileURL in contents { - // Get file creation date directly from FileManager (more reliable) - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - if let creationDate = attributes[.creationDate] as? Date { - // Debug: Print each file's date - // print("File: \(fileURL.lastPathComponent), Created: \(creationDate), Cutoff: \(cutoffDate), Should delete: \(creationDate < cutoffDate)") - - if creationDate < cutoffDate { - filesToDelete.append(fileURL) - } - } - } - - // Verify cleanup logic identifies correct files - #expect(filesToDelete.count >= 5) // At least 5 files should be deleted (files 5,6,7,8,9 are definitely 4+ days old) - #expect(filesToDelete.count <= 7) // Allow for edge cases around day boundaries (file 4 might be right at cutoff) - - // Verify the correct files are marked for deletion (should be files 4-9, which are 4+ days old) - for fileURL in filesToDelete { - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - let creationDate = attributes[.creationDate] as? Date - #expect(creationDate != nil) - #expect(creationDate! < cutoffDate) - - // The file should be one of the older files (4+ days old) - let fileName = fileURL.lastPathComponent - if fileName.hasPrefix("test-file-") { - let indexStr = fileName.replacingOccurrences(of: "test-file-", with: "") - .replacingOccurrences(of: ".log", with: "") - if let index = Int(indexStr) { - #expect(index >= 4) // Files 4,5,6,7,8,9 should be deleted (4+ days old) - } - } - } - } - - // MARK: - Component Integration Tests - - @Test("should test Disk filename utilities") func diskFilenameUtilities() { - let logName = "2025-06-19" - - let logFilename = Disk.AppFilenames.logFile(name: logName) - #expect(logFilename == "2025-06-19.log") - - let watchLogFilename = Disk.AppFilenames.watchLogFile(name: logName) - #expect(watchLogFilename == "watch_2025-06-19.log") - - // Test edge cases - let emptyName = "" - #expect(Disk.AppFilenames.logFile(name: emptyName) == ".log") - #expect(Disk.AppFilenames.watchLogFile(name: emptyName) == "watch_.log") - } - - @Test("should test timestamp formatting for zip files") func timestampFormattingForZip() { - let testDate = Date() - let timestamp = Formatter.iso8601.string(from: testDate).replacingOccurrences(of: ":", with: "-") - - let zipFileName = "Trio-Logs-\(timestamp).zip" - - #expect(zipFileName.hasPrefix("Trio-Logs-")) - #expect(zipFileName.hasSuffix(".zip")) - #expect(!zipFileName.contains(":")) // Colons should be replaced - - // Test that the timestamp is reasonable length - #expect(timestamp.count > 15) // Should be at least YYYY-MM-DDTHH-MM-SS - } - - // MARK: - SimpleLogReporter Static Method Tests (Safe to call) - - @Test("SimpleLogReporter getAllLogNames should return valid log names") func simpleLogReporterGetAllLogNames() { - let logNames = SimpleLogReporter.getAllLogNames() - - #expect(!logNames.isEmpty) - #expect(logNames.count >= 4) // Should have at least 4 days worth based on retention - - // Check that log names follow expected date format - for logName in logNames { - // Should be in format like "2025-06-19" - #expect(logName.count == 10) // YYYY-MM-DD format - #expect(logName.contains("-")) - } - } - - @Test("SimpleLogReporter currentLogName should return today's log name") func simpleLogReporterCurrentLogName() { - let currentLogName = SimpleLogReporter.currentLogName() - let expectedFormat = Formatter.logDateFormatter.string(from: Date()) - - #expect(currentLogName == expectedFormat) - #expect(currentLogName.count == 10) // YYYY-MM-DD format - } - - @Test("SimpleLogReporter logNameForDate should generate correct names") func simpleLogReporterLogNameForDate() { - let today = Date() - let calendar = Calendar.current - - // Test current day (0 days ago) - let todayName = SimpleLogReporter.logNameForDate(daysAgo: 0) - let expectedToday = Formatter.logDateFormatter.string(from: today) - #expect(todayName == expectedToday) - - // Test yesterday (1 day ago) - if let yesterday = calendar.date(byAdding: .day, value: -1, to: today) { - let yesterdayName = SimpleLogReporter.logNameForDate(daysAgo: 1) - let expectedYesterday = Formatter.logDateFormatter.string(from: yesterday) - #expect(yesterdayName == expectedYesterday) - } - } - - @Test("Settings StateModel can be instantiated") func settingsStateModelInstantiation() { - let stateModel = Settings.StateModel() - #expect(stateModel != nil) - - // Just verify the object exists and has the expected type - #expect(type(of: stateModel) == Settings.StateModel.self) - } -} diff --git a/TrioTests/WatchCommandTests/GarminCommandRouterTests.swift b/TrioTests/WatchCommandTests/GarminCommandRouterTests.swift index add878f9b1..d24155ee48 100644 --- a/TrioTests/WatchCommandTests/GarminCommandRouterTests.swift +++ b/TrioTests/WatchCommandTests/GarminCommandRouterTests.swift @@ -288,7 +288,7 @@ import Testing let outcome = await router.route(["v": 1, "req": "presets"], from: appUUID, isRegistered: true, appName: "test") #expect(outcome.reply == nil) - #expect(logs.lines.contains { $0.hasPrefix("⌚️❌") && $0.contains("(unexpected)") }) + #expect(logs.entries.contains { $0.level == .warning && $0.line.contains("(unexpected)") }) #expect(logs.lines.allSatisfy { !$0.contains(SensitiveTestError.marker) }) } diff --git a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift index f7c76a55b3..d1238ff382 100644 --- a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift @@ -682,7 +682,7 @@ final class RefreshTriggerRecorder: @unchecked Sendable { let checks = LogCapture() let delivered = try await actions.enactBolus(2) { - checks.record(pump.bolusRequests.isEmpty ? "before" : "after") + checks.record(.debug, pump.bolusRequests.isEmpty ? "before" : "after") } #expect(delivered) #expect(checks.lines == ["before"]) diff --git a/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift index 786c39a8bc..24e41529b0 100644 --- a/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift +++ b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift @@ -331,12 +331,13 @@ struct SensitiveTestError: LocalizedError, CustomStringConvertible { /// Collects log lines from the injectable log sinks. final class LogCapture: @unchecked Sendable { private let lock = NSLock() - private var storage: [String] = [] + private var storage: [(level: LogLevel, line: String)] = [] - var lines: [String] { lock.withLock { storage } } + var lines: [String] { lock.withLock { storage.map(\.line) } } + var entries: [(level: LogLevel, line: String)] { lock.withLock { storage } } - func record(_ line: String) { - lock.withLock { storage.append(line) } + func record(_ level: LogLevel, _ line: String) { + lock.withLock { storage.append((level, line)) } } }